#!/usr/bin/env python3

"""
ShellOut.py
call an external program passing the active layer as a temp file.  Windows Only(?)

Gimp 3.2 Adoption:
migf1

Version:
0.9 updated for GIMP 3.2, fixed behavior when a marquee selection is present, added user-defined temp folder

Author:
Rob Antonishen

Version:
0.8 updated for GIMP 3.x compatibility
0.7 fixed file save bug where all files were png regardless of extension
0.6 modified to allow for a returned layer that is a different size
   than the saved layer for
0.5 file extension parameter in program list.
0.4 modified to support many optional programs.

this script is modelled after the mm extern LabCurves trace plugin
by Michael Munzert http://www.mm-log.com/lab-curves-gimp

and thanks to the folds at gimp-chat has grown a bit ;)

License:

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; version 3 of the License.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

The GNU Public License is available at
http://www.gnu.org/copyleft/gpl.html

"""

import gi
gi.require_version('Gimp', '3.0')

import shlex
import subprocess
import os, sys
import tempfile
import traceback

from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gimp
from gi.repository import Gio

# Define plug-in metadata
PROC_NAME = "python-fu-shellout"
HELP = "Call an external program"
DOC = "Call an external program passing the active layer as a temp file"
AUTHOR = "Rob Antonishen"
COPYRIGHT = "Copyright 2011 Rob Antonishen"
DATE = "2025-03-24"

# How the plugin name shows across different places in Gimp
PLUGIN_DISPLAY_NAMES = {
    "title": "ShellOut for Gimp 3.x ...",
    "menu_label": "_ShellOut for Gimp 3.x ...",
    "menu_path": "<Image>/Filters/ShellOut for Gimp 3.x ...",
}

TEMPFILE_BASENAME = 'ShellOutTempFile'

# Program list function (globals are evil)
def listcommands(option=None):
    #
    # Insert additional shell command into this list. They will show up in the drop menu in this order.
    # Use the syntax:
    # ["Menu Label", "command", "ext"]
    # 
    # Where what gets executed is command filename, so include any flags needed in the command.
    programlist = [
        ["DFine 2", "\"C:\\Program Files\\Google\\Nik Collection\\Dfine 2\\Dfine2.exe\"", "png"],
        ["Sharpener Pro 3", "\"C:\\Program Files\\Google\\Nik Collection\\Sharpener Pro 3\\SHP3OS.exe\"", "png"],
        ["Viveza 2", "\"C:\\Program Files\\Google\\Nik Collection\\Viveza 2\\Viveza 2.exe\"", "png"],
        ["Color Efex Pro 4", "\"C:\\Program Files\\Google\\Nik Collection\\Color Efex Pro 4\\Color Efex Pro 4.exe\"",
         "jpg"],
        ["Analog Efex Pro 2", "\"C:\\Program Files\\Google\\Nik Collection\\Analog Efex Pro 2\\Analog Efex Pro 2.exe\"",
         "jpg"],
        ["HDR Efex Pro 2", "\"C:\\Program Files\\Google\\Nik Collection\\HDR Efex Pro 2\\HDR Efex Pro 2.exe\"", "jpg"],
        ["Silver Efex Pro 2", "\"C:\\Program Files\\Google\\Nik Collection\\Silver Efex Pro 2\\Silver Efex Pro 2.exe\"",
         "jpg"],
        ["", "", ""]
    ]

    if option is None:  # no parameter return menu list, otherwise return the appropriate array
        menulist = []
        for i in programlist:
            if i[0] != "":
                menulist.append(i[0])
        return menulist
    else:
        return programlist[option]


def plugin_main(procedure, run_mode, image, layers, config, data):

    # Handle interactive mode - show dialog
    if run_mode == Gimp.RunMode.INTERACTIVE:
        gi.require_version('GimpUi', '3.0')
        gi.require_version('Gtk', '3.0')
        from gi.repository import GimpUi
        from gi.repository import Gtk
        
        GimpUi.init("python-fu-shellout")
        dialog = GimpUi.ProcedureDialog.new(procedure, config, PLUGIN_DISPLAY_NAMES["title"])
        dialog.set_size_request(600, -1)    # set minimum dialog width in pixels
        dialog.set_property("window-position", Gtk.WindowPosition.CENTER)
        
        dialog.fill(["visible", "command"])

        # Build custom folder row

        content_area = dialog.get_content_area()

        folder_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)

        folder_label = Gtk.Label(label="Temp folder:")
        folder_box.pack_start(folder_label, False, False, 0)

        folder_entry = Gtk.Entry()
        folder_entry.set_text(config.get_property("folder"))
        folder_box.pack_start(folder_entry, True, True, 0)

        browse_button = Gtk.Button(label="Browse...")
        folder_box.pack_start(browse_button, False, False, 0)

        default_button = Gtk.Button(label="Default")
        folder_box.pack_start(default_button, False, False, 0)

        content_area.pack_start(folder_box, False, False, 0)
        dialog.show_all()

        def on_default_clicked(button):
            default_path = tempfile.gettempdir()
            folder_entry.set_text(default_path)
            config.set_property("folder", default_path)

        default_button.connect("clicked", on_default_clicked)

        def on_browse_clicked(button):
            native = Gtk.FileChooserNative.new(
                "Select Temp Folder",
                dialog,
                Gtk.FileChooserAction.SELECT_FOLDER,
                "_Select",
                "_Cancel"
            )
            # Set starting folder from current entry
            current = folder_entry.get_text()
            if current and os.path.isdir(current):
                native.set_current_folder(current)
            response = native.run()
            if response == Gtk.ResponseType.ACCEPT:
                chosen = native.get_file()
                if chosen:
                    path = chosen.get_path()
                    folder_entry.set_text(path)
                    config.set_property("folder", path)
            native.destroy()

        browse_button.connect("clicked", on_browse_clicked)

        if not dialog.run():
            dialog.destroy()
            return procedure.new_return_values(Gimp.PDBStatusType.CANCEL, GLib.Error())

        # Sync folder from entry (in case user typed/pasted)
        config.set_property("folder", folder_entry.get_text())
        
        dialog.destroy()

        # Validate folder exists
        folder_path = config.get_property("folder")
        if not os.path.isdir(folder_path):
            return procedure.new_return_values(
                Gimp.PDBStatusType.CALLING_ERROR,
                GLib.Error(message=f"Folder does not exist: {folder_path}")
            )

    # Get parameters - convert choice nicks (strings) to integers
    visible = int(config.get_property("visible"))
    command_idx = int(config.get_property("command"))

    if len(layers) == 0:
        return procedure.new_return_values(
            Gimp.PDBStatusType.CALLING_ERROR,
            GLib.Error(message="No drawables selected")
        )

    drawable = layers[0]

    # Start an undo group
    Gimp.context_push()
    image.undo_group_start()

    try:
        # Copy so the save operations doesn't affect the original
        if visible == 0:
            # Use the active drawable
            temp = drawable
        else:
            # Get the current visible
            temp = Gimp.Layer.new_from_visible(image, image, "Visible")
            image.insert_layer(temp, None, 0)

        # Copy the layer content
        buffer = Gimp.edit_named_copy([temp], "ShellOutTemp")

        # Save selection if one exists
        hassel = not Gimp.Selection.is_empty(image)
        if hassel:
            savedsel = Gimp.Selection.save(image)

        # Create a new image with the copied content
        tempimage = Gimp.edit_named_paste_as_new_image(buffer)
        Gimp.buffer_delete(buffer)
        if not tempimage:
            raise RuntimeError("Could not create temporary image")

        Gimp.Image.undo_disable(tempimage)

        # Get the program to run and filetype
        progtorun = listcommands(command_idx)

        # Use temp file names
        tempfilename = os.path.join(folder_path, TEMPFILE_BASENAME + "." + progtorun[2])

        # Save the temporary image
        Gimp.progress_init("Saving a copy")
        Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, tempimage, 
                      Gio.File.new_for_path(tempfilename))

        # Build command line call
        command = progtorun[1] + " \"" + tempfilename + "\""
        args = shlex.split(command)

        # Invoke external command
        Gimp.progress_init("Calling " + progtorun[0] + "...")
        Gimp.progress_pulse()
        child = subprocess.Popen(args, shell=False)
        child.communicate()

        # Check if the external program saved the file
        if not os.path.exists(tempfilename):
            raise RuntimeError("External program did not save the file")

        # Load the modified file as a new layer
        newlayer2 = Gimp.file_load_layer(Gimp.RunMode.NONINTERACTIVE, tempimage, 
                                        Gio.File.new_for_path(tempfilename))
        
        if not newlayer2:
            raise RuntimeError("Could not load modified file as layer")

        tempimage.insert_layer(newlayer2, None, -1)
        buffer = Gimp.edit_named_copy([newlayer2], "ShellOutTemp")

        if visible == 0:
            #drawable.resize( newlayer2.get_width(), newlayer2.get_height(), 0, 0)
            sel = Gimp.edit_named_paste(drawable, buffer, True)
            #drawable.transform_translate( 
            #                             (tempimage.get_width() - newlayer2.get_width()) / 2,
            #                             (tempimage.get_height() - newlayer2.get_height()) / 2)
        else:
            #temp.resize( newlayer2.get_width(), newlayer2.get_height(), 0, 0)
            sel = Gimp.edit_named_paste(temp, buffer, True)
            #temp.transform_translate( 
            #                            (tempimage.get_width() - newlayer2.get_width()) / 2,
            #                            (tempimage.get_height() - newlayer2.get_height()) / 2)

        Gimp.buffer_delete(buffer)
        temp.edit_clear()
        Gimp.floating_sel_anchor(sel)

        # load up old selection
        if hassel:
            image.select_item(Gimp.ChannelOps.REPLACE, savedsel)
            image.remove_channel(savedsel)

        # cleanup
        try:
            if os.path.exists(tempfilename):
                os.remove(tempfilename)
        except Exception as e:
            print(f"Cleanup warning (temp file): {e}")

        try:
            tempimage.delete()
        except Exception as e:
            print(f"Cleanup warning (temp image): {e}")

    except Exception as e:
        print(f"ShellOut Error: {e}")
        print(traceback.format_exc())
        # Cleanup on error
        if 'tempfilename' in locals() and os.path.exists(tempfilename):
            os.remove(tempfilename)
        if 'tempimage' in locals() and tempimage:
            tempimage.delete()
        
        image.undo_group_end()
        Gimp.context_pop()
        
        return procedure.new_return_values(
            Gimp.PDBStatusType.EXECUTION_ERROR,
            GLib.Error(message=str(e))
        )

    # End the undo group
    image.undo_group_end()
    Gimp.displays_flush()
    Gimp.context_pop()
    
    return procedure.new_return_values(Gimp.PDBStatusType.SUCCESS, GLib.Error())


class ShellOut(Gimp.PlugIn):

    def do_query_procedures(self):
        """Return the name of the procedure this plugin defines"""
        return [PROC_NAME]

    def do_create_procedure(self, name):
        """Create the procedure"""
        procedure = Gimp.ImageProcedure.new(self, name,
                                           Gimp.PDBProcType.PLUGIN,
                                           plugin_main, None)
        
        procedure.set_image_types("RGB*, GRAY*")
        procedure.set_menu_label(PLUGIN_DISPLAY_NAMES["menu_label"])
        procedure.set_attribution(AUTHOR, COPYRIGHT, DATE)
        procedure.set_documentation(HELP, DOC, None)

        procedure.add_menu_path(PLUGIN_DISPLAY_NAMES["menu_path"])

        # Add the visible layer argument (radio button)
        visible_choice = Gimp.Choice.new()
        visible_choice.add("1", 1, "New from visible", "Create a merged copy of visible layers")
        visible_choice.add("0", 0, "Current layer", "Use the active layer")
        
        procedure.add_choice_argument(
            "visible",
            "Layer source:",
            "Choose which layer to send to external program",
            visible_choice,
            "1",  # default value matching nick "1" (new from visible)
            GObject.ParamFlags.READWRITE
        )

        # Add the command argument (dropdown menu)
        command_choice = Gimp.Choice.new()
        commands = listcommands()
        for i, cmd_name in enumerate(commands):
            command_choice.add(str(i), i, cmd_name, cmd_name)

        procedure.add_choice_argument(
            "command",
            "Program:",
            "Choose which external program to use",
            command_choice,
            "0",  # default value matching nick "0" (first program)
            GObject.ParamFlags.READWRITE
        )

        # Add folder argument for temp file location
        procedure.add_string_argument(
            "folder",
            "Temp folder:",
            "Folder where temporary files will be written",
            tempfile.gettempdir(),
            GObject.ParamFlags.READWRITE
        )
        
        return procedure

Gimp.main(ShellOut.__gtype__, sys.argv)
