# coding=utf-8
# python 2

# 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:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##           My Blank Tab 0.98             ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.98 - allows running external scripts from buttons                                       - 6/06/2026
# 0.97 - Can coexist with WCS Matrix, new 5x5 button grid, INI-based buttons, live refresh  - 6/04/2026
# 0.96 - public beta                                                                        - 6/03/2026

import os
import gtk
import glib
import ConfigParser
import linuxcnc
import constants
import subprocess
import singletons
from ui_hooks import plugin, version_list

CURRENT_VER          = "0.98"
SCRIPT_NAME          = "My Blank Tab"
DESCRIPTION          = "Single-file custom PathPilot tab with twenty-five user-configurable MDI buttons."
ENABLED              = 1
DEV_MACHINE          = 0
DEV_MACHINE_FLAG     = "/home/operator/gcode/python/dev_machine.txt"
PAGE_ID              = "tt_blank_button_tab_fixed"
TAB_LABEL_TEXT       = "Buttons"
TAB_TITLE_TEXT       = "Custom Button Tab %s" % CURRENT_VER
TAB_POSITION         = 8
TAB_WIDTH            = 1002
TAB_HEIGHT           = 409
BACKGROUND_COLOR     = "#2b2b2b"
BUTTON_LABEL_FG      = "#000000"
SCRIPT_FILE_PATH     = "/home/operator/gcode/python/blank_tab_plugin.py"
BUTTON_INI_PATH      = "/home/operator/gcode/python/blank_tab_buttons.ini"
SCRIPT_COMMAND_ROOT  = "/home/operator/gcode/"
SCRIPT_PYTHON_EXE    = "/usr/bin/python"
BUTTON_REFRESH_MS    = 1000
BUTTON_COUNT         = 25

DEFAULT_BUTTON_INI = """# Blank Tab Button Configuration
#
# title   = what appears on the button
# command = LinuxCNC MDI command, or full path to a Python script.
#
# If command starts with /home/operator/gcode/, it is launched as a script.
# Otherwise, command is sent through PathPilot as LinuxCNC MDI.
#
# Script Example:
#
# [Button3]
# title = Midpoint Finder
# command = /home/operator/gcode/scripts/midpoint.py
#
# Blank titles hide buttons.
# Blank commands show the button, but they do nothing.
#
# Changes refresh live in PathPilot. If the tab/plugin itself is edited, reboot PathPilot.

[Button1]
title = G30
command = G30

[Button2]
title = G54
command = G54

[Button3]
title = G55
command = G55

[Button4]
title = G56
command = G56

[Button5]
title = G57
command = G57

[Button6]
title = Go XY Zero
command = G0 X0 Y0

[Button7]
title = Go X Zero
command = G0 X0

[Button8]
title = Go Y Zero
command = G0 Y0

[Button9]
title = Go Z Zero
command = G0 Z0

[Button10]
title = G53 Home
command = G53 G0 X0 Y0 Z0

[Button11]
title = Spindle CW 1000
command = M3 S1000

[Button12]
title = Spindle CW 2500
command = M3 S2500

[Button13]
title = Spindle CW 5000
command = M3 S5000

[Button14]
title = Spindle Stop
command = M5

[Button15]
title = Imperial
command = G20

[Button16]
title = Metric
command = G21

[Button17]
title = Coolant M7
command = M7

[Button18]
title = Coolant M8
command = M8

[Button19]
title = Coolant Off
command = M9

[Button20]
title = Max in X
command = G53 G0 X18

[Button21]
title = Max in Y
command = G53 G0 Y-11

[Button22]
title = F10
command = F10

[Button23]
title = F20
command = F20

[Button24]
title = F30
command = F30

[Button25]
title = F40
command = F40"""

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, SCRIPT_NAME)
        self._restore_pages_idle_id = None
        self._notebook_switch_handler_id = None
        self._page_widget = None
        self._tab_label_widget = None
        self._my_tab_original_hide_notebook_tabs = None
        self._button_widgets = {}
        self._button_commands = {}
        self._button_ini_mtime = None
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled:
            glib.timeout_add(3000, self.start_process)
        else:
            if dev_machine_found:
                self.error_handler.write("[%s] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            else:
                self.error_handler.write("[%s] Plugin loaded, but disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
                self.error_handler.write("[%s] To enable, open script, find ENABLED = 0 and change to ENABLED = 1" % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)

    def start_process(self):
        try:
            ui = singletons.g_Machine
            if ui:
                self.ensure_page(ui)
                self.register_always_visible_page(ui)
                self.install_notebook_visibility_patch(ui)
                self._connect_notebook_switch_once(ui)
                self._queue_restore_plugin_pages(ui)
                glib.timeout_add(BUTTON_REFRESH_MS, self.refresh_buttons_from_ini_if_changed)
                self.error_handler.write("[%s] Runtime custom MDI button tab initialized." % SCRIPT_NAME, constants.ALARM_LEVEL_MEDIUM)
            else:
                self.error_handler.write("[%s] UI not ready yet." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
        except Exception as e:
            self.error_handler.write("[%s] Startup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def _queue_restore_plugin_pages(self, ui):
        if self._restore_pages_idle_id is not None:
            return
        self._restore_pages_idle_id = glib.idle_add(self._restore_plugin_pages, ui)

    def _restore_plugin_pages(self, ui):
        self._restore_pages_idle_id = None
        try:
            if hasattr(ui, "_always_visible_plugin_pages"):
                for page in ui._always_visible_plugin_pages:
                    try:
                        if page is not None:
                            page.show_all()
                    except Exception:
                        pass
        except Exception:
            pass
        return False

    def _connect_notebook_switch_once(self, ui):
        if hasattr(ui, "notebook") and ui.notebook is not None:
            if self._notebook_switch_handler_id is None:
                self._notebook_switch_handler_id = ui.notebook.connect_after("switch-page", self.on_switch_page)

    def on_switch_page(self, notebook, page, page_num):
        ui = singletons.g_Machine
        if ui is not None:
            self._queue_restore_plugin_pages(ui)

    def register_always_visible_page(self, ui):
        if not hasattr(ui, "_always_visible_plugin_pages"):
            ui._always_visible_plugin_pages = []
        if self._page_widget is None:
            return
        if self._page_widget in ui._always_visible_plugin_pages:
            return
        ui._always_visible_plugin_pages.append(self._page_widget)

    def install_notebook_visibility_patch(self, ui):
        if hasattr(ui, "hide_notebook_tabs"):
            current = ui.hide_notebook_tabs
            if hasattr(current, "_my_tab_hide_wrapper"):
                self._connect_notebook_switch_once(ui)
                self._queue_restore_plugin_pages(ui)
                return
            self._my_tab_original_hide_notebook_tabs = current

            def patched_hide_notebook_tabs():
                try:
                    if self._my_tab_original_hide_notebook_tabs:
                        self._my_tab_original_hide_notebook_tabs()
                except Exception:
                    pass
                try:
                    for i in range(ui.notebook.get_n_pages()):
                        page = ui.notebook.get_nth_page(i)
                        page_id = gtk.Buildable.get_name(page)
                        if page_id == "notebook_main_fixed" or page_id == "alarms_fixed":
                            page.show()
                except Exception:
                    pass
                self._queue_restore_plugin_pages(ui)

            patched_hide_notebook_tabs._my_tab_hide_wrapper = True
            ui.hide_notebook_tabs = patched_hide_notebook_tabs
            self._connect_notebook_switch_once(ui)
            self._queue_restore_plugin_pages(ui)
            self.error_handler.write("[%s] Installed deferred hide_notebook_tabs patch." % SCRIPT_NAME, constants.ALARM_LEVEL_MEDIUM)
        else:
            self.error_handler.write("[%s] Could not find hide_notebook_tabs on UI object." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)

    def create_tab_label_widget(self):
        if version_list[0] > 2 or (version_list[0] == 2 and version_list[1] >= 10):
            label = gtk.Label()
            label.set_use_markup(True)
            label.set_markup('<span weight="bold" font_desc="Roboto Condensed 11" foreground="white">%s</span>' % TAB_LABEL_TEXT)
            label.show()
            return label
        label = gtk.Label(TAB_LABEL_TEXT)
        label.show()
        return label

    def ensure_page(self, ui):
        if self._page_widget is None:
            self._page_widget = gtk.Fixed()
            self._page_widget.set_name(PAGE_ID)
            self._page_widget.set_size_request(TAB_WIDTH, TAB_HEIGHT)
            self._page_widget.show()
            self.build_ui(ui)
        if self._tab_label_widget is None:
            self._tab_label_widget = self.create_tab_label_widget()
        if hasattr(ui, "_TormachMillUI__page_ids"):
            if PAGE_ID not in ui._TormachMillUI__page_ids:
                ui._TormachMillUI__page_ids.append(PAGE_ID)
        page_num = ui.notebook.page_num(self._page_widget)
        if page_num == -1:
            ui.notebook.insert_page(self._page_widget, self._tab_label_widget, TAB_POSITION)
            self._page_widget.show()
            ui.notebook.show()

    def build_ui(self, ui):
        self.add_oem_background()
        self.add_title_area()
        self.add_custom_button_grid()

    def get_oem_background_path(self):
        version_str = "v%s.%s.%s" % (version_list[0], version_list[1], version_list[2])
        return os.path.join("/home/operator", version_str, "python", "images", "dark_background.jpg")

    def add_oem_background(self):
        bg_path = self.get_oem_background_path()
        if os.path.exists(bg_path):
            try:
                pixbuf = gtk.gdk.pixbuf_new_from_file(bg_path)
                scaled = pixbuf.scale_simple(TAB_WIDTH, TAB_HEIGHT, gtk.gdk.INTERP_BILINEAR)
                image = gtk.Image()
                image.set_from_pixbuf(scaled)
                self._page_widget.put(image, 0, 0)
                image.show()
                return
            except Exception:
                pass
        try:
            self._page_widget.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(BACKGROUND_COLOR))
        except Exception:
            pass

    def put(self, widget, x, y):
        self._page_widget.put(widget, x, y)
        widget.show()
        return widget

    def create_button(self, label_text, command_text=None):
        button = gtk.Button()
        button.set_size_request(150, 54)
        button.set_relief(gtk.RELIEF_NORMAL)
        label = gtk.Label(label_text)
        label.set_line_wrap(True)
        label.set_alignment(0.5, 0.5)
        try:
            label.modify_fg(gtk.STATE_NORMAL, gtk.gdk.color_parse(BUTTON_LABEL_FG))
        except Exception:
            pass
        label.show()
        button.add(label)
        if command_text is not None:
            button.connect("clicked", self.make_custom_mdi_handler(command_text))
        button._tt_label = label
        return button

    def add_title_area(self):
        title = gtk.Label()
        title.set_use_markup(True)
        title.set_markup('<span weight="bold" foreground="white" size="large">%s</span>' % TAB_TITLE_TEXT)
        title.set_alignment(0.0, 0.5)
        self.put(title, 55, 18)
        edit_button = self.create_button("Edit INI")
        edit_button.set_size_request(95, 34)
        edit_button.connect("clicked", self.on_edit_this_script)
        self.put(edit_button, 855, 14)

    def add_custom_button_grid(self):
        start_x = 65
        start_y = 58
        button_w = 150
        button_h = 54
        gap_x = 25
        gap_y = 14
        columns = 5
        for index in range(BUTTON_COUNT):
            button_number = index + 1
            row = index / columns
            col = index % columns
            x = start_x + (col * (button_w + gap_x))
            y = start_y + (row * (button_h + gap_y))
            key = "Button%d" % button_number
            button = self.create_button("", key)
            self._button_widgets[key] = button
            self._button_commands[key] = ""
            self.put(button, x, y)
        self.load_buttons_from_ini(force=True)

    def ensure_button_ini_exists(self):
        if os.path.isfile(BUTTON_INI_PATH):
            return
        try:
            f = open(BUTTON_INI_PATH, "w")
            try:
                f.write(DEFAULT_BUTTON_INI)
            finally:
                f.close()
            self.error_handler.write("[%s] Created default button INI: %s" % (SCRIPT_NAME, BUTTON_INI_PATH), constants.ALARM_LEVEL_MEDIUM)
        except Exception as e:
            self.error_handler.write("[%s] Could not create button INI: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def get_ini_value(self, parser, section, option, default_value=""):
        try:
            if parser.has_section(section) and parser.has_option(section, option):
                return parser.get(section, option).strip()
        except Exception:
            pass
        return default_value

    def load_buttons_from_ini(self, force=False):
        try:
            self.ensure_button_ini_exists()
            try:
                mtime = os.path.getmtime(BUTTON_INI_PATH)
            except Exception:
                mtime = None
            if force == False and mtime == self._button_ini_mtime:
                return True
            parser = ConfigParser.ConfigParser()
            parser.read(BUTTON_INI_PATH)
            for button_number in range(1, BUTTON_COUNT + 1):
                section = "Button%d" % button_number
                title = self.get_ini_value(parser, section, "title", "")
                command = self.get_ini_value(parser, section, "command", "")
                key = "Button%d" % button_number
                button = self._button_widgets.get(key)
                if button is None:
                    continue
                if title == "":
                    button.hide()
                else:
                    button.show()
                    try:
                        button._tt_label.set_text(title)
                    except Exception:
                        pass
                self._button_commands[key] = command
            self._button_ini_mtime = mtime
            self.error_handler.write("[%s] Button INI loaded: %s" % (SCRIPT_NAME, BUTTON_INI_PATH), constants.ALARM_LEVEL_QUIET)
            return True
        except Exception as e:
            self.error_handler.write("[%s] Button INI load failed: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            return True

    def refresh_buttons_from_ini_if_changed(self):
        self.load_buttons_from_ini(force=False)
        return True

    def make_custom_mdi_handler(self, button_key):
        def handler(widget):
            self.run_custom_mdi(button_key)
        return handler

    def on_edit_this_script(self, widget):
        try:
            self.ensure_button_ini_exists()
            if not os.path.isfile(BUTTON_INI_PATH):
                self.error_handler.write("[%s] Button INI file not found: %s" % (SCRIPT_NAME, BUTTON_INI_PATH), constants.ALARM_LEVEL_MEDIUM)
                return
            ui = singletons.g_Machine
            if ui:
                ui._run_mdi_admin_program(["gedit", BUTTON_INI_PATH])
            else:
                self.error_handler.write("[%s] Machine UI is not available." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
        except Exception as e:
            self.error_handler.write("[%s] Could not open button INI in gedit: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def run_custom_mdi(self, button_key):
        command_text = str(self._button_commands.get(button_key, "")).strip()
        title = self.get_button_title(button_key)

        if command_text == "":
            self.error_handler.write(
                "[%s] Button '%s' has no command assigned." % (SCRIPT_NAME, title),
                constants.ALARM_LEVEL_MEDIUM)
            return

        if self.command_is_script(command_text):
            self.run_custom_script(title, command_text)
        else:
            self.run_custom_mdi_command(title, command_text)

    def get_button_title(self, button_key):
        title = button_key
        button = self._button_widgets.get(button_key)

        try:
            if button is not None and hasattr(button, "_tt_label"):
                title = button._tt_label.get_text()
        except Exception:
            pass

        return title

    def command_is_script(self, command_text):
        try:
            command_path = os.path.abspath(os.path.expanduser(command_text))
            script_root = os.path.abspath(SCRIPT_COMMAND_ROOT)

            return command_path.startswith(script_root + os.sep)

        except Exception:
            return False

    def run_custom_script(self, title, script_path):
        try:
            script_path = os.path.abspath(os.path.expanduser(script_path))
            script_root = os.path.abspath(SCRIPT_COMMAND_ROOT)

            if not script_path.startswith(script_root + os.sep):
                self.error_handler.write(
                    "[%s] Script blocked outside allowed folder from '%s': %s" %
                    (SCRIPT_NAME, title, script_path),
                    constants.ALARM_LEVEL_LOW)
                return

            if not os.path.isfile(script_path):
                self.error_handler.write(
                    "[%s] Script not found from '%s': %s" %
                    (SCRIPT_NAME, title, script_path),
                    constants.ALARM_LEVEL_LOW)
                return

            if os.path.splitext(script_path)[1].lower() != ".py":
                self.error_handler.write(
                    "[%s] Script must be a .py file from '%s': %s" %
                    (SCRIPT_NAME, title, script_path),
                    constants.ALARM_LEVEL_LOW)
                return

            subprocess.Popen(
                [SCRIPT_PYTHON_EXE, script_path],
                cwd=os.path.dirname(script_path))

            self.error_handler.write(
                "[%s] Script launched from '%s': %s" %
                (SCRIPT_NAME, title, script_path),
                constants.ALARM_LEVEL_QUIET)

        except Exception as e:
            self.error_handler.write(
                "[%s] Script launch failed from '%s': %s" %
                (SCRIPT_NAME, title, str(e)),
                constants.ALARM_LEVEL_LOW)

    def run_custom_mdi_command(self, title, command_text):
        ui = singletons.g_Machine

        if not ui:
            self.error_handler.write(
                "[%s] Machine UI is not available." % SCRIPT_NAME,
                constants.ALARM_LEVEL_LOW)
            return

        try:
            ui.ensure_mode(linuxcnc.MODE_MDI)
            ui.command.mdi(command_text)
            ui.command.wait_complete()

            self.error_handler.write(
                "[%s] MDI command sent from '%s': %s" %
                (SCRIPT_NAME, title, command_text),
                constants.ALARM_LEVEL_QUIET)

        except Exception as e:
            self.error_handler.write(
                "[%s] MDI command failed from '%s': %s | %s" %
                (SCRIPT_NAME, title, command_text, str(e)),
                constants.ALARM_LEVEL_LOW)

DESCRIPTION_LONG = """<font face="Verdana" size="2"><b>CUSTOM MDI BUTTON TAB</b></font>
<p><font face="Verdana" size="2">This plugin creates a simple custom PathPilot tab with up to twenty-five user-configurable buttons.
Each button sends one LinuxCNC MDI command.</font></p>
<p><font face="Verdana" size="2">Edit /home/operator/gcode/python/blank_tab_buttons.ini to customize the buttons. Button changes refresh live.</font></p>"""