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

#############################################
##                                         ##
##         Quick Tool Editor 0.95          ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.95 - public beta - 4/29/2026

import os
import ast
import gtk
import glib
import linuxcnc
import constants
import singletons
import admincmd
import datetime
import re
from ui_hooks import plugin

CURRENT_VER         = "0.95"
SCRIPT_NAME         = "Quick Tool Table Entry"
DESCRIPTION         = "Simple popup entry for tool number, description, diameter, and length."
ENABLED             = 1
DEV_MACHINE         = 1
DEV_MACHINE_FLAG    = "/home/operator/gcode/python/dev_machine.txt"
ADMIN_COMMAND_NAME  = "QUICKTOOL"

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, SCRIPT_NAME)
        self._registered = False
        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(1000, self._try_register_admin_cmd)
            self._status("[Quick Tool Table] loaded; waiting for UI")
            return
        else:
            if dev_machine_found:
                self._status("[Quick Tool Table] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE.")
            else:
                self._status("[Quick Tool Table] Plugin loaded, but disabled.")
                self._status("[Quick Tool Table] To enable, open script, find ENABLED = 0 and change to ENABLED = 1")
            return
    
    def _status(self, msg, level=constants.ALARM_LEVEL_QUIET):
        try:
            self.error_handler.write(msg, level)
        except Exception:
            pass
    
    def _try_register_admin_cmd(self):
        if self._registered:
            return False
        ui = getattr(singletons, 'g_Machine', None)
        if not ui:
            return True
        try:
            cmd = QuickToolAdminCmd(ui)
            if hasattr(ui, 'admincmd_namedict'):
                ui.admincmd_namedict[cmd.name()] = cmd
            if hasattr(ui, 'admincmd_list'):
                found = False
                for existing in ui.admincmd_list:
                    if existing.name() == cmd.name():
                        found = True
                        break
                if not found:
                    ui.admincmd_list.append(cmd)
            self._registered = True
            self._status("[Quick Tool Table] command registered: ADMIN %s" % ADMIN_COMMAND_NAME)
            return False
        except Exception as e:
            self._status("[Quick Tool Table] command registration failed: %s" % str(e), constants.ALARM_LEVEL_LOW)
            return True

class QuickToolAdminCmd(admincmd.AdminCmd):
    def __init__(self, uibase):
        super(QuickToolAdminCmd, self).__init__(ADMIN_COMMAND_NAME, doc=True)
        self.uibase = uibase
        self._help = "Open a simple tool table entry popup."
    
    def add_completion_strings(self, store):
        try:
            try:
                store.append(['ADMIN ' + ADMIN_COMMAND_NAME])
            except Exception:
                store.append(['ADMIN ' + ADMIN_COMMAND_NAME, 'ADMIN ' + ADMIN_COMMAND_NAME])
        except Exception:
            pass
    
    def activate(self, arglist):
        try:
            requested_tool = self._tool_from_arglist(arglist)
            ToolEntryDialog(self.uibase, requested_tool).run()
        except Exception as e:
            try:
                self.uibase.error_handler.write("[ADMIN %s] Failed: %s" % (ADMIN_COMMAND_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            except Exception:
                pass
    
    def _tool_from_arglist(self, arglist):
        if arglist is None:
            return None
        if isinstance(arglist, basestring):
            words = arglist.replace(",", " ").split()
        else:
            words = []
            for item in arglist:
                words.extend(str(item).replace(",", " ").split())
        for word in words:
            cleaned = word.strip().upper()
            if cleaned.startswith("T") or cleaned.startswith("P"):
                cleaned = cleaned[1:]
            try:
                tool_num = int(cleaned)
                if tool_num > 0:
                    return tool_num
            except Exception:
                pass
        return None                

class ToolEntryDialog(object):
    def __init__(self, ui, requested_tool=None):
        self.ui = ui
        self.requested_tool = requested_tool        
    
    def _requested_or_selected_tool(self):
        if self.requested_tool is not None:
            try:
                tool_num = int(self.requested_tool)
                min_tool, max_tool = self._tool_range()
                if tool_num >= min_tool and tool_num <= max_tool:
                    return tool_num
            except Exception:
                pass
        return self._selected_or_spindle_tool()
    
    def _append_installed_stamp(self, description):
        base = self._strip_installed_stamp(description)
        stamp = self._installed_stamp()
        if base:
            return "%s [%s]" % (base, stamp)
        return "[%s]" % stamp

    def _strip_installed_stamp(self, description):
        text = description.strip()
        return re.sub(r'\s*\[installed [^\]]+\]\s*$', '', text, flags=re.IGNORECASE).strip()

    def _installed_stamp(self):
        return "installed " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M")

    def _show_verify_result(self, result):
        if result.get('ok'):
            message_type = gtk.MESSAGE_INFO
            title = "Quick Tool Table Verified"
        else:
            message_type = gtk.MESSAGE_ERROR
            title = "Quick Tool Table Verification Failed"
        dlg = gtk.MessageDialog(
            self.ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            message_type,
            gtk.BUTTONS_OK,
            result.get('message', '')        )
        dlg.set_title(title)
        dlg.set_keep_above(True)
        dlg.set_position(gtk.WIN_POS_NONE)
        dlg.realize()
        dlg.move(40, 180)
        dlg.present()
        dlg.run()
        dlg.destroy()
    
    def _verify_tool_write(self, tool_num, description, diameter, length):
        ui = self.ui
        submitted_radius = float(diameter) / 2.0
        submitted_length = float(length)
        for attempt in range(5):
            try:
                ui.poll()
                t = ui.status.tool_table[int(tool_num)]
                factor = ui.get_linear_scale()
                actual_diameter = float(getattr(t, 'diameter', 0.0)) * factor
                actual_radius = actual_diameter / 2.0
                actual_length = float(getattr(t, 'zoffset', 0.0)) * factor
                actual_description = ui.get_tool_description(tool_num, 'high_precision') or ""
                if self._nearly_equal(actual_radius, submitted_radius) and self._nearly_equal(actual_length, submitted_length) and actual_description == description:
                    return {
                        'ok': True,
                        'message': "Verified tool %d was written.\n\nDescription: %s\nDiameter: %s\nZ length: %s" % (tool_num, actual_description, self._fmt_tool_value(actual_diameter), self._fmt_tool_value(actual_length))
                    }
                glib.usleep(200000)
            except Exception:
                glib.usleep(200000)
        return self._verify_failure_result(tool_num, description, diameter, length)
    
    def _verify_failure_result(self, tool_num, description, diameter, length):
        try:
            self.ui.poll()
            t = self.ui.status.tool_table[int(tool_num)]
            factor = self.ui.get_linear_scale()
            actual_diameter = float(getattr(t, 'diameter', 0.0)) * factor
            actual_length = float(getattr(t, 'zoffset', 0.0)) * factor
            actual_description = self.ui.get_tool_description(tool_num, 'high_precision') or ""
            return {
                'ok': False,
                'message': "Tool %d did not verify after write.\n\nSubmitted:\nDescription: %s\nDiameter: %s\nZ length: %s\n\nRead back:\nDescription: %s\nDiameter: %s\nZ length: %s" % (tool_num, description, self._fmt_tool_value(diameter), self._fmt_tool_value(length), actual_description, self._fmt_tool_value(actual_diameter), self._fmt_tool_value(actual_length))
            }
        except Exception as e:
            return {
                'ok': False,
                'message': "Tool %d did not verify after write.\n\nReadback failed: %s" % (tool_num, str(e))            }
    
    def _fmt_tool_value(self, value):
        return "%.4f" % float(value)

    def _nearly_equal(self, a, b):
        return abs(float(a) - float(b)) <= 0.00001

    def run(self):
        current_tool = self._requested_or_selected_tool()
        current_desc = ""
        current_diam = ""
        current_len = ""
        if current_tool > 0:
            current_desc = self._safe_tool_description(current_tool)
            current_diam, current_len = self._safe_tool_values(current_tool)
        data = self._show_dialog(current_tool, current_desc, current_diam, current_len)
        if data is None:
            return
        tool_num, description, diameter, length, update_installed = data
        if update_installed:
            description = self._append_installed_stamp(description)
        result = self._write_tool(tool_num, description, diameter, length)
        self._show_verify_result(result)
    
    def _selected_or_spindle_tool(self):
        try:
            store, tree_iter = self.ui.treeselection.get_selected()
            if tree_iter is not None:
                value = store.get_value(tree_iter, 0)
                if int(value) > 0:
                    return int(value)
        except Exception:
            pass
        try:
            if int(self.ui.status.tool_in_spindle) > 0:
                return int(self.ui.status.tool_in_spindle)
        except Exception:
            pass
        return 1
    
    def _safe_tool_description(self, tool_num):
        try:
            return self.ui.get_tool_description(tool_num, 'high_precision') or ""
        except Exception:
            return ""
    
    def _safe_tool_values(self, tool_num):
        try:
            self.ui.poll()
            factor = self.ui.get_linear_scale()
            t = self.ui.status.tool_table[int(tool_num)]
            diam = getattr(t, 'diameter', 0.0) * factor
            length = getattr(t, 'zoffset', 0.0) * factor
            return self._fmt_tool_value(diam), self._fmt_tool_value(length)
        except Exception:
            return "", ""
    
    def _show_dialog(self, tool_num, description, diameter, length):
        dialog = gtk.Dialog(
            "Quick Tool Table Entry",
            self.ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (
                gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
                gtk.STOCK_OK, gtk.RESPONSE_OK            )        )
        dialog.set_default_size(520, 260)
        dialog.set_keep_above(True)
        dialog.set_default_response(gtk.RESPONSE_OK)
        table = gtk.Table(5, 2, False)
        table.set_row_spacings(8)
        table.set_col_spacings(10)
        table.set_border_width(12)
        tool_entry = gtk.Entry()
        tool_entry.set_text(str(tool_num if tool_num else ""))
        tool_entry.set_activates_default(True)
        desc_entry = gtk.Entry()
        desc_entry.set_text(description or "")
        desc_entry.set_activates_default(True)
        diam_entry = gtk.Entry()
        diam_entry.set_text(diameter or "")
        diam_entry.set_activates_default(True)
        len_entry = gtk.Entry()
        len_entry.set_text(length or "")
        len_entry.set_activates_default(True)
        installed_check = gtk.CheckButton("Append / update installed timestamp")
        installed_check.set_active(True)        
        self._attach_row(table, 0, "Tool number:", tool_entry)
        self._attach_row(table, 1, "Description:", desc_entry)
        self._attach_row(table, 2, "Diameter:", diam_entry)
        self._attach_row(table, 3, "Length:", len_entry)
        table.attach(installed_check, 1, 2, 4, 5, gtk.EXPAND | gtk.FILL, gtk.FILL)        
        dialog.vbox.pack_start(table, True, True, 0)
        dialog.show_all()
        tool_entry.grab_focus()
        tool_entry.select_region(0, -1)
        while True:
            response = dialog.run()
            if response != gtk.RESPONSE_OK:
                dialog.destroy()
                return None
            try:
                parsed = self._parse_values(
                    tool_entry.get_text(),
                    desc_entry.get_text(),
                    diam_entry.get_text(),
                    len_entry.get_text(),                
                    installed_check.get_active()                )                                        
                dialog.destroy()
                return parsed
            except ValueError as e:
                self._error_popup(str(e))
    
    def _attach_row(self, table, row, label_text, entry):
        label = gtk.Label(label_text)
        label.set_alignment(0.0, 0.5)
        table.attach(label, 0, 1, row, row + 1, gtk.FILL, gtk.FILL)
        table.attach(entry, 1, 2, row, row + 1, gtk.EXPAND | gtk.FILL, gtk.FILL)
    
    def _parse_values(self, tool_text, desc_text, diam_text, len_text, update_installed):
        try:
            tool_num = int(tool_text.strip())
        except Exception:
            raise ValueError("Tool number must be an integer.")
        min_tool, max_tool = self._tool_range()
        if tool_num < min_tool or tool_num > max_tool:
            raise ValueError("Tool number must be from %d through %d." % (min_tool, max_tool))
        description = desc_text.strip()
        diameter = self._parse_float(diam_text, "Diameter")
        length = self._parse_float(len_text, "Length")
        if diameter < 0.0:
            raise ValueError("Diameter cannot be negative.")
        return tool_num, description, diameter, length, update_installed
    
    def _tool_range(self):
        try:
            return self.ui._get_min_max_tool_numbers()
        except Exception:
            return (1, 998)
    
    def _parse_float(self, text, name):
        s = self._normalize_math_text(text)
        if s == "":
            return 0.0
        try:
            return float(self._safe_eval_math(s))
        except Exception:
            raise ValueError("%s must be numeric or simple math." % name)

    def _normalize_math_text(self, text):
        s = str(text).strip()
        s = s.replace(" ", "")
        s = s.replace('"', "")
        s = s.replace("'", "")
        return s

    def _safe_eval_math(self, expression):
        node = ast.parse(expression, mode="eval")
        return self._eval_math_node(node.body)

    def _eval_math_node(self, node):
        if isinstance(node, ast.Num):
            return float(node.n)

        if isinstance(node, ast.UnaryOp):
            value = self._eval_math_node(node.operand)
            if isinstance(node.op, ast.UAdd):
                return value
            if isinstance(node.op, ast.USub):
                return -value

        if isinstance(node, ast.BinOp):
            left = self._eval_math_node(node.left)
            right = self._eval_math_node(node.right)

            if isinstance(node.op, ast.Add):
                return left + right
            if isinstance(node.op, ast.Sub):
                return left - right
            if isinstance(node.op, ast.Mult):
                return left * right
            if isinstance(node.op, ast.Div):
                if right == 0.0:
                    raise ValueError("Division by zero.")
                return left / right

        raise ValueError("Unsupported math expression.")
    
    def _error_popup(self, message):
        dlg = gtk.MessageDialog(
            self.ui.window,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_ERROR,
            gtk.BUTTONS_OK,
            message        )
        dlg.set_keep_above(True)
        dlg.run()
        dlg.destroy()
    
    def _write_tool(self, tool_num, description, diameter, length):
        ui = self.ui
        if hasattr(ui, 'program_running') and ui.program_running():
            raise RuntimeError("Cannot change the tool table while a program is running.")
        try:
            ui.poll()
        except Exception:
            pass
        radius = float(diameter) / 2.0
        cmd = "G10 L1 P%d R%.12g Z%.12g" % (int(tool_num), radius, float(length))
        ui.issue_mdi(cmd)
        try:
            ui.command.wait_complete()
        except Exception:
            pass
        try:
            ui.set_tool_description(tool_num, description, 'report_error')
        except Exception:
            raise RuntimeError("Offsets were written, but the description write failed.")
        try:
            ui.poll()
            if int(ui.status.tool_in_spindle) == int(tool_num) and ui.status.task_state == linuxcnc.STATE_ON:
                ui.issue_mdi("G43")
                ui.command.wait_complete()
        except Exception:
            pass
        refreshed = False
        try:
            ui.poll()
            ui.refresh_tool_liststore(forced_refresh=True)
            refreshed = True
        except Exception:
            pass
        if not refreshed:
            self._patch_visible_row(tool_num, description, diameter, length)
        result = self._verify_tool_write(tool_num, description, diameter, length)
        try:
            if result.get('ok'):
                ui.error_handler.write("[Quick Tool Table] Verified tool %d: diameter %s, Z %s" % (tool_num, self._fmt_tool_value(diameter), self._fmt_tool_value(length)),constants.ALARM_LEVEL_QUIET)
            else:
                ui.error_handler.write("[Quick Tool Table] Verification failed for tool %d" % tool_num,constants.ALARM_LEVEL_MEDIUM)
        except Exception:
            pass
        return result
    
    def _patch_visible_row(self, tool_num, description, diameter, length):
        try:
            for row in self.ui.tool_liststore:
                if int(row[0]) == int(tool_num):
                    row[1] = description
                    row[2] = self._fmt_tool_value(diameter)
                    row[3] = self._fmt_tool_value(length)
                    return
        except Exception:
            pass
            
DESCRIPTION_LONG = """This script is a faster dialog, directly from 
    the MDI line, to update tools. You should also download
    <a href="plugins/output.htm">Custom Admin Commands</a> for this to work 
    properly.</font></p>
    <p><a href="images/quicktool3.png">
    <img border="2" src="images/quicktool3_small.png" xthumbnail-orig-image="images/quicktool3.png" width="200" height="150"></a>
    <a href="images/quicktool2.png">
    <img border="2" src="images/quicktool2_small.png" xthumbnail-orig-image="images/quicktool2.png" width="200" height="150"></a>
    <a href="images/quicktool1.png">
    <img border="2" src="images/quicktool1_small.png" xthumbnail-orig-image="images/quicktool1.png" width="200" height="150"></a>"""            