# coding: utf-8
# python 2 only

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormachtips@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#######################################
##                                   ##
##        G-code Edit Tab 1.00       ##
##        www.tormachtips.com        ##
##                                   ##
#######################################

# 1.00 - Makes the future-edit context-menu label show the exact right-clicked line number. - 9/20/2026
# 0.99 - Uses the actual right-click mouse position to choose the G-code line instead of PathPilot active/start marks. - 9/20/2026
# 0.98 - Prompts for an edit reason before appending the marker; blank Enter uses a default reason and confirms the line number. - 9/20/2026
# 0.97 - Adds G-code right-click "Mark for Future Edit"; appends a marker comment to the highlighted source line. - 9/20/2026
# 0.96 - Edit tab now copies the installed PathPilot View Options tab styling. - 9/6/2026
# 0.95 - Public beta. Adds an EDIT command tab beside G-code and View Options. - 9/6/2026

import os
import gtk
import glib
import pango
import subprocess
import types
import constants
import singletons
from ui_hooks import plugin

CURRENT_VER                = "1.00"
SCRIPT_NAME                = "G-code Edit Tab"
DESCRIPTION                = "Adds an EDIT command tab beside G-code and View Options for the loaded NC program."
ENABLED                    = 1
DEV_MACHINE                = 1
DEV_MACHINE_FLAG           = "/home/operator/gcode/python/dev_machine.txt"
EDIT_TAB_TEXT              = "Edit G-Code"
EDIT_TAB_FONT              = "Roboto Condensed 10"
START_DELAY_MS             = 1500
BOOTSTRAP_MS               = 250
BOOTSTRAP_LIMIT            = 40
STATE_POLL_MS              = 500
EDIT_PAGE_NAME             = "tt_gcode_edit_command_page"
EDIT_TAB_NAME              = "tt_gcode_edit_command_tab"
FUTURE_EDIT_MENU_FORMAT    = "Mark line %d for editing"
FUTURE_EDIT_DEFAULT_REASON = "Future edit needed"
FUTURE_EDIT_MARKER_FORMAT  = " ; >>> FUTURE EDIT: %s <<<"

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, "%s %s" % (SCRIPT_NAME, CURRENT_VER))
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled == 0:
            self.ShowMsg("Plugin loaded, but disabled.", constants.ALARM_LEVEL_QUIET)
            return
        self.ui = None
        self.notebook = None
        self.edit_page = None
        self.edit_tab_label = None
        self.edit_page_num = -1
        self.last_real_page_num = 0
        self.switch_handler_id = None
        self._bootstrap_count = 0
        self._future_edit_popup_handler_id = None
        self._future_edit_sourceview = None
        self._future_edit_button_handler_id = None
        self._future_edit_right_click_line = 0
        glib.timeout_add(START_DELAY_MS, self.start_process)

    def start_process(self):
        try:
            self.ui = singletons.g_Machine
            if self.ui:
                pass
            else:
                glib.timeout_add(BOOTSTRAP_MS, self.bootstrap_edit_tab)
                return False
            if self.install_edit_tab():
                self.install_future_edit_marker_menu()
                glib.timeout_add(STATE_POLL_MS, self.update_edit_tab_state)
                self.error_handler.write("[%s] %s %s started." % (SCRIPT_NAME, SCRIPT_NAME, CURRENT_VER), constants.ALARM_LEVEL_QUIET)
            else:
                glib.timeout_add(BOOTSTRAP_MS, self.bootstrap_edit_tab)
        except Exception as e:
            self.error_handler.write("[%s] Startup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def bootstrap_edit_tab(self):
        try:
            self._bootstrap_count += 1
            if self.ui:
                pass
            else:
                self.ui = singletons.g_Machine
            if self.install_edit_tab():
                self.install_future_edit_marker_menu()
                glib.timeout_add(STATE_POLL_MS, self.update_edit_tab_state)
                self.error_handler.write("[%s] %s %s started." % (SCRIPT_NAME, SCRIPT_NAME, CURRENT_VER), constants.ALARM_LEVEL_QUIET)
                return False
            if self._bootstrap_count >= BOOTSTRAP_LIMIT:
                self.error_handler.write("[%s] Could not find the G-code options notebook." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
                return False
        except Exception as e:
            self.error_handler.write("[%s] Bootstrap error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            return False
        return True

    def get_gcode_sourceview(self):
        try:
            if self.ui and hasattr(self.ui, "sourceview"):
                sourceview = self.ui.sourceview
                if sourceview:
                    return sourceview
            if self.ui and hasattr(self.ui, "builder"):
                for name in ("sourceview", "gcode_sourceview"):
                    try:
                        sourceview = self.ui.builder.get_object(name)
                        if sourceview:
                            return sourceview
                    except:
                        pass
        except:
            pass
        return None

    def install_future_edit_marker_menu(self):
        try:
            sourceview = self.get_gcode_sourceview()
            if sourceview is None:
                return False
            if self._future_edit_popup_handler_id is not None and self._future_edit_sourceview is sourceview:
                return True
            # Capture the actual row beneath the mouse before GTK builds the
            # popup. Right-clicking a read-only GtkSourceView does not reliably
            # move its insert cursor, so cursor/active-mark based line lookup can
            # point at a different line.
            if self._future_edit_button_handler_id is None:
                self._future_edit_button_handler_id = sourceview.connect(
                    "button-press-event",
                    self.on_gcode_sourceview_button_press)
            # connect_after lets PathPilot build its normal right-click menu first,
            # then we append our one extra command without replacing OEM behavior.
            handler_id = sourceview.connect_after("populate-popup", self.on_gcode_sourceview_populate_popup_after)
            self._future_edit_popup_handler_id = handler_id
            self._future_edit_sourceview = sourceview
            return True
        except Exception as e:
            self.error_handler.write(
                "[%s] Could not install future-edit marker menu: %s" %
                (SCRIPT_NAME, str(e)),
                constants.ALARM_LEVEL_LOW)
            return False

    def on_gcode_sourceview_button_press(self, textview, event):
        try:
            if int(getattr(event, "button", 0) or 0) != 3:
                return False
            x = int(getattr(event, "x", 0) or 0)
            y = int(getattr(event, "y", 0) or 0)
            # Mouse-event coordinates in the G-code text area are window
            # coordinates. Convert them to buffer coordinates before asking
            # GtkTextView which iterator is at that location.
            try:
                bx, by = textview.window_to_buffer_coords(gtk.TEXT_WINDOW_TEXT, x, y)
            except:
                bx, by = x, y
            result = textview.get_iter_at_location(int(bx), int(by))
            if isinstance(result, tuple):
                line_iter = result[0]
            else:
                line_iter = result
            self._future_edit_right_click_line = int(line_iter.get_line()) + 1
        except:
            self._future_edit_right_click_line = 0
        return False

    def get_highlighted_gcode_line_number(self):
        # The line physically right-clicked is authoritative.
        try:
            line_num = int(self._future_edit_right_click_line or 0)
            if line_num > 0:
                return line_num
        except:
            pass
        # Keyboard-invoked context menus have no mouse click, so retain a
        # cursor fallback for that uncommon case.
        try:
            sourceview = self.get_gcode_sourceview()
            if sourceview:
                text_buffer = sourceview.get_buffer()
                insert_mark = text_buffer.get_insert()
                line_iter = text_buffer.get_iter_at_mark(insert_mark)
                return int(line_iter.get_line()) + 1
        except:
            pass
        return 0

    def on_gcode_sourceview_populate_popup_after(self, textview, menu):
        try:
            separator = gtk.SeparatorMenuItem()
            menu.append(separator)
            separator.show()
            program_path = self.get_loaded_program_path()
            line_num = self.get_highlighted_gcode_line_number()
            if line_num > 0:
                menu_text = FUTURE_EDIT_MENU_FORMAT % int(line_num)
            else:
                menu_text = "Mark line for editing"
            item = gtk.MenuItem(menu_text)
            item.set_sensitive(bool(program_path and line_num > 0))
            item.connect("activate", self.on_future_edit_marker_activate, line_num)
            menu.append(item)
            item.show()
        except Exception as e:
            self.error_handler.write(
                "[%s] Future-edit context menu error: %s" %
                (SCRIPT_NAME, str(e)),
                constants.ALARM_LEVEL_LOW)

    def _ask_future_edit_reason(self, line_num):
        dialog = gtk.Dialog("Enter edit reason", None, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, ())
        dialog.set_default_size(500, 150)
        outer = gtk.VBox(False, 8)
        outer.set_border_width(12)
        dialog.vbox.pack_start(outer, True, True, 0)
        label = gtk.Label(
            "Enter edit reason for line %d.\n"
            "Press Enter with this field blank to use the default." % int(line_num))
        label.set_alignment(0.0, 0.5)
        label.set_line_wrap(True)
        outer.pack_start(label, False, False, 0)
        entry = gtk.Entry()
        entry.set_activates_default(True)
        outer.pack_start(entry, False, False, 0)
        default_label = gtk.Label("Default: %s" % FUTURE_EDIT_DEFAULT_REASON)
        default_label.set_alignment(0.0, 0.5)
        outer.pack_start(default_label, False, False, 0)
        dialog.add_button("Cancel", gtk.RESPONSE_CANCEL)
        dialog.add_button("OK", gtk.RESPONSE_OK)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.show_all()
        try:
            entry.grab_focus()
        except:
            pass
        response = dialog.run()
        reason = str(entry.get_text() or "").strip()
        dialog.destroy()
        if response != gtk.RESPONSE_OK:
            return None
        if not reason:
            reason = FUTURE_EDIT_DEFAULT_REASON
        return reason

    def _show_future_edit_confirmation(self, line_num, reason):
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_INFO,
            gtk.BUTTONS_OK,
            "Edit reason entered at line %d" % int(line_num))
        dialog.format_secondary_text("Reason: %s" % str(reason))
        dialog.run()
        dialog.destroy()

    def on_future_edit_marker_activate(self, menu_item, line_num):
        program_path = self.get_loaded_program_path()
        if not program_path or not os.path.isfile(program_path):
            self.error_handler.write("[%s] No loaded NC program to mark." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
            return
        try:
            line_num = int(line_num)
        except:
            line_num = 0
        if line_num <= 0:
            line_num = self.get_highlighted_gcode_line_number()
        if line_num <= 0:
            self.error_handler.write(
                "[%s] Could not determine the highlighted G-code line." % SCRIPT_NAME,
                constants.ALARM_LEVEL_LOW)
            return
        reason = self._ask_future_edit_reason(line_num)
        if reason is None:
            return
        # Keep the comment single-line and G-code friendly.
        reason = reason.replace("\r", " ").replace("\n", " ").strip()
        if not reason:
            reason = FUTURE_EDIT_DEFAULT_REASON
        try:
            handle = open(program_path, "rb")
            try:
                raw = handle.read()
            finally:
                handle.close()
            # Keep original line-ending style and byte content. splitlines(True)
            # retains each line's CR/LF bytes, so only the chosen line changes.
            lines = raw.splitlines(True)
            if line_num > len(lines):
                raise RuntimeError("Highlighted line %d is beyond the file's %d lines." % (line_num, len(lines)))
            index = line_num - 1
            original = lines[index]
            if original.endswith("\r\n"):
                body = original[:-2]
                ending = "\r\n"
            elif original.endswith("\n") or original.endswith("\r"):
                body = original[:-1]
                ending = original[-1:]
            else:
                body = original
                ending = ""
            marker_bytes = FUTURE_EDIT_MARKER_FORMAT % reason
            if ">>> FUTURE EDIT:" in body:
                self.error_handler.write(
                    "[%s] Future edit marker already exists on line %d." %
                    (SCRIPT_NAME, line_num),
                    constants.ALARM_LEVEL_QUIET)
                return
            lines[index] = body + marker_bytes + ending
            new_raw = "".join(lines)
            # Atomic same-directory replacement: PathPilot keeps its already-loaded
            # program untouched; only the file on disk changes for the future edit.
            temp_path = "%s.tt_future_edit_%d.tmp" % (program_path, os.getpid())
            temp_handle = open(temp_path, "wb")
            try:
                temp_handle.write(new_raw)
                temp_handle.flush()
                try:
                    os.fsync(temp_handle.fileno())
                except:
                    pass
            finally:
                temp_handle.close()
            try:
                file_mode = os.stat(program_path).st_mode
                os.chmod(temp_path, file_mode)
            except:
                pass
            os.rename(temp_path, program_path)
            self.error_handler.write(
                "[%s] Future edit reason appended to line %d: %s" %
                (SCRIPT_NAME, line_num, os.path.basename(program_path)),
                constants.ALARM_LEVEL_QUIET)
            self._show_future_edit_confirmation(line_num, reason)
        except Exception as e:
            try:
                temp_path
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except:
                pass
            self.error_handler.write(
                "[%s] Could not append future edit marker: %s" %
                (SCRIPT_NAME, str(e)),
                constants.ALARM_LEVEL_LOW)

    def get_gcode_options_notebook(self):
        try:
            if self.ui and hasattr(self.ui, "gcode_options_notebook"):
                notebook = self.ui.gcode_options_notebook
                if notebook:
                    return notebook
            if self.ui and hasattr(self.ui, "builder"):
                notebook = self.ui.builder.get_object("gcode_options_notebook")
                if notebook:
                    return notebook
        except Exception as e:
            self.error_handler.write("[%s] Notebook lookup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return None

    def find_existing_edit_page(self, notebook):
        try:
            page_count = notebook.get_n_pages()
            for page_num in range(page_count):
                page = notebook.get_nth_page(page_num)
                if page:
                    try:
                        if page.get_name() == EDIT_PAGE_NAME:
                            return page_num, page
                    except:
                        pass
                    try:
                        tab_label = notebook.get_tab_label(page)
                        if tab_label and tab_label.get_name() == EDIT_TAB_NAME:
                            return page_num, page
                    except:
                        pass
        except:
            pass
        return -1, None

    def get_reference_tab_label(self, notebook):
        # PathPilot adds View Options immediately before this plugin adds its page.
        # Use that live OEM label as the style source so this follows the installed
        # PathPilot version instead of hard-coding a color, weight, or font size.
        try:
            page_count = notebook.get_n_pages()
            for page_num in range(page_count - 1, -1, -1):
                page = notebook.get_nth_page(page_num)
                if page is self.edit_page:
                    continue
                tab_label = notebook.get_tab_label(page)
                if isinstance(tab_label, gtk.Label):
                    return tab_label
        except:
            pass
        return None

    def copy_tab_label_style(self, source_label, target_label):
        if source_label:
            pass
        else:
            return False
        copied = False
        # set_markup() styling used by PathPilot is carried by the label's Pango
        # layout attributes. Copy those attributes so 2.9.1, 2.14.4, and later
        # versions can each supply their own font, weight, and foreground color.
        try:
            attributes = source_label.get_layout().get_attributes()
            if attributes:
                try:
                    attributes = attributes.copy()
                except:
                    pass
                target_label.set_attributes(attributes)
                copied = True
        except:
            pass
        # Also inherit the GTK widget style for any theme-driven properties that
        # are not part of the Pango markup.
        try:
            target_label.set_style(source_label.get_style().copy())
            copied = True
        except:
            pass
        try:
            xalign, yalign = source_label.get_alignment()
            target_label.set_alignment(xalign, yalign)
        except:
            pass
        try:
            xpad, ypad = source_label.get_padding()
            target_label.set_padding(xpad, ypad)
        except:
            pass
        return copied

    def make_edit_tab_label(self, notebook):
        label = gtk.Label()
        label.set_name(EDIT_TAB_NAME)
        label.set_text(EDIT_TAB_TEXT)
        source_label = self.get_reference_tab_label(notebook)
        if self.copy_tab_label_style(source_label, label):
            pass
        else:
            # Defensive fallback matching PathPilot 2.9.1 if an OEM label cannot
            # be obtained for some unexpected layout/version.
            label.set_markup('<span weight="regular" font_desc="%s" foreground="black">%s</span>' % (EDIT_TAB_FONT, EDIT_TAB_TEXT))
        label.show()
        return label

    def install_edit_tab(self):
        try:
            notebook = self.get_gcode_options_notebook()
            if notebook:
                pass
            else:
                return False
            self.notebook = notebook
            existing_page_num, existing_page = self.find_existing_edit_page(notebook)
            if existing_page_num >= 0:
                self.edit_page_num = existing_page_num
                self.edit_page = existing_page
                self.edit_tab_label = notebook.get_tab_label(existing_page)
                if isinstance(self.edit_tab_label, gtk.Label):
                    self.edit_tab_label.set_text(EDIT_TAB_TEXT)
                    self.copy_tab_label_style(self.get_reference_tab_label(notebook), self.edit_tab_label)
                if self.switch_handler_id is None:
                    self.switch_handler_id = notebook.connect("switch-page", self.on_gcode_options_switch_page)
                self.capture_current_real_page()
                self.update_edit_tab_state()
                return True
            # Wait until the OEM View Options page has been appended. The normal
            # PathPilot layout then has G-code at page 0 and View Options at page 1.
            if notebook.get_n_pages() < 2:
                return False
            self.capture_current_real_page()
            self.edit_page = gtk.Fixed()
            self.edit_page.set_name(EDIT_PAGE_NAME)
            self.edit_tab_label = self.make_edit_tab_label(notebook)
            self.edit_page_num = notebook.append_page(self.edit_page, self.edit_tab_label)
            self.edit_page.show()
            self.switch_handler_id = notebook.connect("switch-page", self.on_gcode_options_switch_page)
            self.update_edit_tab_state()
            return True
        except Exception as e:
            self.error_handler.write("[%s] Install error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            return False

    def capture_current_real_page(self):
        try:
            if self.notebook:
                current_page_num = self.notebook.get_current_page()
                if current_page_num >= 0 and current_page_num != self.edit_page_num:
                    self.last_real_page_num = current_page_num
        except:
            pass

    def get_loaded_program_path(self):
        try:
            if self.ui:
                pass
            else:
                return ""
            if hasattr(self.ui, "get_current_gcode_path"):
                path = self.ui.get_current_gcode_path()
                if path and os.path.isfile(path):
                    return path
            if hasattr(self.ui, "current_gcode_file_path"):
                path = getattr(self.ui, "current_gcode_file_path", "") or ""
                if path and os.path.isfile(path):
                    return path
        except Exception as e:
            self.error_handler.write("[%s] Loaded program lookup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return ""

    def update_edit_tab_state(self):
        try:
            if self.edit_tab_label:
                program_path = self.get_loaded_program_path()
                self.edit_tab_label.set_sensitive(bool(program_path and os.path.isfile(program_path)))
        except:
            pass
        return True

    def restore_previous_page(self):
        try:
            if self.notebook:
                page_count = self.notebook.get_n_pages()
                page_num = self.last_real_page_num
                if page_num < 0 or page_num >= page_count or page_num == self.edit_page_num:
                    page_num = 0
                self.notebook.set_current_page(page_num)
        except Exception as e:
            self.error_handler.write("[%s] Could not restore the previous G-code tab: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def open_loaded_program(self):
        program_path = self.get_loaded_program_path()
        if program_path and os.path.isfile(program_path):
            try:
                subprocess.Popen(["editscript", program_path])
            except Exception as e:
                self.error_handler.write("[%s] Could not open %s in editor: %s" % (SCRIPT_NAME, os.path.basename(program_path), str(e)), constants.ALARM_LEVEL_LOW)
        else:
            self.error_handler.write("[%s] No loaded NC program to edit." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)

    def on_gcode_options_switch_page(self, notebook, page_ptr_unused, page_num):
        try:
            if page_num == self.edit_page_num:
                self.open_loaded_program()
                glib.idle_add(self.restore_previous_page)
            else:
                self.last_real_page_num = page_num
        except Exception as e:
            self.error_handler.write("[%s] EDIT tab error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

DESCRIPTION_LONG = """This script adds a third &quot;tab&quot; to the G-Code 
    Preview Pane. The &quot;tab&quot; is actually an Edit G-Code button that will open the 
    currently-loaded program for editing.</font></p>
    <p><font face="Verdana" size="2">It's the same thing as ALT + E, except the 
    hard button can be used while the program is running.</font></p>
    <p><font face="Verdana" size="2">There is no harm in editing a running 
    program, it will not &quot;update&quot; in real-time. When the current job is finished 
    (or aborted), PathPilot will <i>then</i> ask you to refresh the file. </font>
    </p>
    <p><a href="images/edittab.jpg">
    <img border="2" src="images/edittab_small.jpg" xthumbnail-orig-image="images/edittab.jpg" width="200" height="150">"""            