# 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

#######################################
##                                   ##
##        Tools On Deck 0.98         ##
##        www.tormachtips.com        ##
##                                   ##
#######################################

# 0.98 - added totals row                   - 6/19/2026
# 0.97 - segment estimation on the main tab - 6/16/2026
# 0.96 - Better regex for M6.               - 6/10/2026
# 0.95 - Public beta.                       - 6/10/2026

import os
import re
import gtk
import glib
import time
import pango
import linuxcnc
import constants
import singletons
from ui_hooks import plugin

CURRENT_VER             = "0.98"
SCRIPT_NAME             = "Tools On Deck"
DESCRIPTION             = "Shows the loaded program's tool list, highlights the current tool in yellow, and, if applicable, marks unpocketed ATC tools in red."
ENABLED                 = 1
DEV_MACHINE             = 1
DEV_MACHINE_FLAG        = "/home/operator/gcode/python/dev_machine.txt"
TOOLS_ON_DECK           = "/home/operator/gcode/python/cycle_time_tools_on_deck_status.txt"
LOCATION_RIGHT          = 1010
LOCATION_Y              = -8
BOX_MIN_WIDTH           = 140
BOX_TEXT_PADDING        = 14
TOOL_CELL_HEIGHT        = 22
TOOL_CELL_SPACING       = 1
START_DELAY_MS          = 1500
POLL_MS                 = 500
FILE_CHECK_MS           = 500
ATC_REFRESH_SECONDS     = 2.0
OVERLAY_BOOTSTRAP_MS    = 250
OVERLAY_BOOTSTRAP_LIMIT = 40
OVERLAY_RESTACK_MS      = 1000
OVERLAY_RESTACK_LIMIT   = 30
PARSE_ONLY_TOOL_CHANGES = 1
SHOW_WHEN_NO_PROGRAM    = 0
MAX_TOOLS_SHOWN         = 24
DEBUG_TO_STATUS         = 0
NORMAL_BG               = "#2d2d2d"
NORMAL_FG               = "#eeeeee"
CURRENT_BG              = "#ffd84d"
CURRENT_FG              = "#000000"
MISSING_BG              = "#b00000"
MISSING_FG              = "#ffffff"
ATC_DEBUG_TO_STATUS     = 1
BORDER_BG               = "#111111"
EMPTY_TEXT              = "Program tools: none"
BUILDING_ESTIMATES_TEXT = "building estimates..."
OVERLAY_FONT_NAME       = "monospace 9"
TOOL_TEXT_WIDTH         = 4

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 not plugin_enabled:
            self.ShowMsg("Plugin loaded, but disabled.", constants.ALARM_LEVEL_QUIET)
            return
        self.ui = None
        self.overlay_box = None
        self.overlay_content_box = None        
        self.overlay_parent = None
        self.tools_box = None
        self.tool_labels = {}
        self.loaded_program_path = ""
        self.loaded_program_mtime = None
        self.loaded_program_tools = []
        self.tool_estimate_labels = {}
        self.timing_summary_text = ""        
        self.tool_estimate_file_mtime = None
        self.tool_estimate_program_key = ""
        self.atc_missing_tools_cache = set()
        self._atc_debug_signature = ""
        self._last_atc_refresh_time = 0.0
        self.current_tool = 0
        self.program_running = False        
        self._overlay_bootstrap_count = 0
        self._overlay_restack_id = None
        self._overlay_restack_count = 0
        glib.timeout_add(OVERLAY_BOOTSTRAP_MS, self.bootstrap_overlay_first)
        glib.timeout_add(START_DELAY_MS, self.start_process)

    def _debug(self, message):
        try:
            if DEBUG_TO_STATUS:
                self.error_handler.write("[%s] %s" % (SCRIPT_NAME, message), constants.ALARM_LEVEL_LOW)
        except:
            pass

    def _atc_debug(self, message):
        try:
            if ATC_DEBUG_TO_STATUS:
                self.error_handler.write("[%s] %s" % (SCRIPT_NAME, message), constants.ALARM_LEVEL_QUIET)
        except:
            pass

    def start_process(self):
        try:
            self.ui = singletons.g_Machine
            if not self.ui:
                return True
            self.ensure_program_tools_overlay()
            if self._overlay_restack_id is None:
                self._overlay_restack_id = glib.timeout_add(OVERLAY_RESTACK_MS, self.restack_program_tools_overlay)
            glib.timeout_add(FILE_CHECK_MS, self.check_loaded_program)
            glib.timeout_add(POLL_MS, self.update_overlay)
            self.error_handler.write("[%s] %s %s started." % (SCRIPT_NAME, SCRIPT_NAME, CURRENT_VER), constants.ALARM_LEVEL_QUIET)
        except Exception as e:
            self.error_handler.write("[%s] Startup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def get_overlay_parent(self):
        try:
            ui = singletons.g_Machine
            if ui:
                pass
            else:
                self._debug("UI singleton not ready")
                return None
            if hasattr(ui, "get_normal_fixed_object"):
                parent = ui.get_normal_fixed_object("notebook_main_fixed")
                if parent:
                    return parent
            if hasattr(ui, "notebook_main_fixed") and ui.notebook_main_fixed:
                return ui.notebook_main_fixed
            if hasattr(ui, "builder"):
                parent = ui.builder.get_object("notebook_main_fixed")
                if parent:
                    return parent
        except Exception as e:
            self.error_handler.write("[%s] Parent lookup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return None

    def bootstrap_overlay_first(self):
        try:
            self._overlay_bootstrap_count += 1
            if self.ensure_program_tools_overlay():
                self._debug("Overlay ready after %d attempt(s)" % self._overlay_bootstrap_count)
                if self._overlay_restack_id is None:
                    self._overlay_restack_id = glib.timeout_add(OVERLAY_RESTACK_MS, self.restack_program_tools_overlay)
                return False
            if self._overlay_bootstrap_count >= OVERLAY_BOOTSTRAP_LIMIT:
                self.error_handler.write("[%s] Overlay bootstrap gave up after %d attempts" % (SCRIPT_NAME, self._overlay_bootstrap_count), constants.ALARM_LEVEL_LOW)
                return False
            return True
        except Exception as e:
            self.error_handler.write("[%s] Overlay bootstrap error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            return True

    def _overlay_should_be_visible(self):
        if self.loaded_program_tools:
            return True
        if SHOW_WHEN_NO_PROGRAM:
            return True
        return False

    def _apply_overlay_visibility(self):
        try:
            if not self.overlay_box:
                return
            if self._overlay_should_be_visible():
                self.overlay_box.show_all()
            else:
                self.overlay_box.hide()
        except:
            pass

    def ensure_program_tools_overlay(self):
        try:
            parent = self.get_overlay_parent()
            if parent:
                pass
            else:
                return False
            if self.overlay_box:
                current_parent = None
                try:
                    current_parent = self.overlay_box.get_parent()
                except:
                    current_parent = None
                if current_parent is parent:
                    parent.move(self.overlay_box, self._get_overlay_container_x(), LOCATION_Y)
                    self.overlay_box.set_size_request(self._get_overlay_container_width(), self._get_overlay_height())
                    self._apply_overlay_visibility()
                    self.overlay_box.queue_resize()
                    self.overlay_box.queue_draw()
                    parent.queue_draw()
                    return True
                if current_parent:
                    try:
                        self.overlay_box.destroy()
                    except:
                        pass
                    self.overlay_box = None
                    self.overlay_content_box = None                    
                    self.overlay_parent = None
            return self.create_program_tools_overlay(parent)
        except Exception as e:
            self.error_handler.write("[%s] Overlay watchdog error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def _get_overlay_height(self):
        tool_count = len(self.loaded_program_tools)
        if tool_count <= 0:
            if SHOW_WHEN_NO_PROGRAM:
                tool_count = 1
            else:
                tool_count = 1
        row_count = tool_count
        if len(self.loaded_program_tools) > 0:
            row_count += 1
        return (row_count * TOOL_CELL_HEIGHT) + max(0, row_count - 1) * TOOL_CELL_SPACING

    def _destroy_existing_named_overlays(self, parent):
        # Prevent duplicate overlays after plugin reloads or position/width changes.
        # PathPilot can leave an older fixed child alive during development reloads.
        try:
            for child in parent.get_children():
                try:
                    if child.get_name() == "program_tools_overlay":
                        parent.remove(child)
                        child.destroy()
                except:
                    pass
        except:
            pass

    def create_program_tools_overlay(self, parent):
        try:
            if parent:
                pass
            else:
                return False
            if self.overlay_box:
                return True
            self._destroy_existing_named_overlays(parent)
            # Full-width invisible alignment container. It locks the right edge.
            outer = gtk.Alignment(1.0, 0.0, 0.0, 0.0)
            outer.set_size_request(self._get_overlay_container_width(), self._get_overlay_height())
            outer.set_name("program_tools_overlay")
            # Visible right-aligned backing box. Row gaps show this background,
            # not the Gremlin toolpath behind it.
            content_box = gtk.EventBox()
            content_box.set_size_request(self._get_overlay_width(), self._get_overlay_height())
            content_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(BORDER_BG))
            tools_box = gtk.VBox(False, TOOL_CELL_SPACING)
            tools_box.set_border_width(0)
            content_box.add(tools_box)
            outer.add(content_box)
            parent.put(outer, self._get_overlay_container_x(), LOCATION_Y)
            outer.show_all()
            outer.queue_draw()
            self.overlay_box = outer
            self.overlay_content_box = content_box
            self.overlay_parent = parent
            self.tools_box = tools_box
            self._rebuild_tool_widgets()
            self._debug("Created overlay parent=%s x=%d y=%d w=%d h=%d" % (str(parent), self._get_overlay_container_x(), LOCATION_Y, self._get_overlay_width(), self._get_overlay_height()))
            return True
        except Exception as e:
            self.overlay_box = None
            self.overlay_content_box = None
            self.overlay_parent = None
            self.tools_box = None
            self.error_handler.write("[%s] Create overlay error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            return False

    def restack_program_tools_overlay(self):
        try:
            self._overlay_restack_count += 1
            parent = self.get_overlay_parent()
            if parent:
                pass
            else:
                return self._overlay_restack_count < OVERLAY_RESTACK_LIMIT
            if self.overlay_box:
                pass
            else:
                self.ensure_program_tools_overlay()
                return self._overlay_restack_count < OVERLAY_RESTACK_LIMIT
            current_parent = None
            try:
                current_parent = self.overlay_box.get_parent()
            except:
                current_parent = None
            if current_parent is parent:
                try:
                    parent.remove(self.overlay_box)
                except:
                    pass
                parent.put(self.overlay_box, self._get_overlay_container_x(), LOCATION_Y)
                self._apply_overlay_visibility()
                self.overlay_box.queue_resize()
                self.overlay_box.queue_draw()
                parent.queue_draw()
            else:
                self.ensure_program_tools_overlay()
            if self._overlay_restack_count >= OVERLAY_RESTACK_LIMIT:
                self._overlay_restack_id = None
                return False
        except Exception as e:
            self.error_handler.write("[%s] Overlay restack error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        if self._overlay_restack_count >= OVERLAY_RESTACK_LIMIT:
            self._overlay_restack_id = None
            return False
        return True

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

    def is_program_running(self):
        try:
            ui = singletons.g_Machine
            if not ui or not hasattr(ui, "status"):
                return False
            ui.status.poll()
            return ui.status.interp_state != linuxcnc.INTERP_IDLE
        except:
            return False

    def get_current_tool_num(self):
        try:
            ui = singletons.g_Machine
            if not ui or not hasattr(ui, "status"):
                return 0
            ui.status.poll()
            return int(getattr(ui.status, "tool_in_spindle", 0) or 0)
        except:
            return 0

    def _get_carousel_atc(self):
        # Carousel ATC only. Rack ATC intentionally not handled by this helper.
        try:
            if self.ui and hasattr(self.ui, "atc"):
                atc = self.ui.atc
                if atc and getattr(atc, "operational", False):
                    return atc
        except:
            pass
        return None

    def _get_carousel_atc_pocket_report(self, atc):
        # Returns human-readable pocket/tool text for status-window verification.
        pocket_texts = []
        try:
            with atc.pocket_dict_rlock:
                atc.refresh_pocket_dict()
                pocket_count = int(atc.hal["atc-tools-in-tray"])
                for pocket_index in range(pocket_count):
                    tool_text = str(atc.pocket_dict.get(str(pocket_index), "0"))
                    if tool_text == "0":
                        pocket_texts.append("P%d=empty" % (pocket_index + 1))
                    else:
                        pocket_texts.append("P%d=T%s" % (pocket_index + 1, tool_text))
        except Exception as e:
            pocket_texts.append("pocket report failed: %s" % str(e))
        return ", ".join(pocket_texts)

    def _periodic_atc_refresh_due(self):
        # ATC pocket state does not need the same fast refresh cadence as the timer/footer.
        # Program-load checks still call refresh_atc_missing_tools() immediately.
        try:
            now = time.time()
            if now - self._last_atc_refresh_time >= ATC_REFRESH_SECONDS:
                self._last_atc_refresh_time = now
                return True
        except:
            self._last_atc_refresh_time = 0.0
        return False

    def refresh_atc_missing_tools(self, report_reason):
        # Updates the red-highlight cache. Missing means required by the loaded program but not assigned to any carousel pocket.
        missing_tools = set()
        atc = self._get_carousel_atc()
        if atc is None:
            if self._atc_debug_signature != "no-carousel-atc":
                self._atc_debug("Carousel ATC not found or not operational. Missing-tool highlighting disabled.")
                self._atc_debug_signature = "no-carousel-atc"
            self.atc_missing_tools_cache = missing_tools
            return False
        pocket_report = self._get_carousel_atc_pocket_report(atc)
        assigned_reports = []
        missing_reports = []
        for tool_num in self.loaded_program_tools:
            try:
                slot = int(atc.lookup_slot(int(tool_num)))
            except:
                slot = -1
            if slot >= 0:
                assigned_reports.append("T%d=P%d" % (int(tool_num), slot + 1))
            else:
                missing_tools.add(int(tool_num))
                missing_reports.append("T%d" % int(tool_num))
        signature = "%s|%s|%s" % (
            ",".join([str(int(tool_num)) for tool_num in self.loaded_program_tools]),
            pocket_report,
            ",".join(missing_reports))
        if signature != self._atc_debug_signature:
            self._atc_debug("Carousel ATC found on this machine.")
            self._atc_debug("ATC pockets: %s" % pocket_report)
            if len(self.loaded_program_tools) > 0:
                self._atc_debug("Loaded program tools: %s" % ", ".join(["T%d" % int(tool_num) for tool_num in self.loaded_program_tools]))
            else:
                self._atc_debug("Loaded program tools: none")
            if len(assigned_reports) > 0:
                self._atc_debug("Program tools found in ATC: %s" % ", ".join(assigned_reports))
            if len(missing_reports) > 0:
                self._atc_debug("Program tools missing from ATC: %s" % ", ".join(missing_reports))
            else:
                self._atc_debug("Program tools missing from ATC: none")
            self._atc_debug_signature = signature
        changed = missing_tools != self.atc_missing_tools_cache
        self.atc_missing_tools_cache = missing_tools
        return changed

    def check_loaded_program(self):
        try:
            program_path = self.get_loaded_program_path()
            program_mtime = None
            if program_path:
                try:
                    program_mtime = os.path.getmtime(program_path)
                except:
                    program_mtime = None
            if program_path != self.loaded_program_path or program_mtime != self.loaded_program_mtime:
                self.loaded_program_path = program_path
                self.loaded_program_mtime = program_mtime
                self.loaded_program_tools = self.parse_program_tools(program_path)
                # A newly loaded program may be parsed by TOD before Cycle Time has finished
                # building and publishing the matching estimate file. Do not show stale data
                # from the previous program during that gap.
                self.tool_estimate_labels = {}
                self.timing_summary_text = BUILDING_ESTIMATES_TEXT
                self.tool_estimate_file_mtime = None
                self.tool_estimate_program_key = ""
                if len(self.loaded_program_tools) > 0:
                    self._atc_debug("Parsed program tools from %s: %s" % (os.path.basename(program_path), ", ".join(["T%d" % int(tool_num) for tool_num in self.loaded_program_tools])))
                else:
                    self._atc_debug("Parsed program tools from %s: none" % os.path.basename(program_path))
                self.atc_missing_tools_cache = set()
                self.refresh_atc_missing_tools("program-change")
                self._rebuild_tool_widgets()
                if self.load_tool_estimate_labels():
                    self._rebuild_tool_widgets()
        except Exception as e:
            self.error_handler.write("[%s] Program check error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return True

    def strip_gcode_comments(self, line):
        try:
            line = re.sub(r"\([^)]*\)", " ", line)
            if ";" in line:
                line = line.split(";", 1)[0]
            return line.strip()
        except:
            return ""

    def parse_program_tools(self, program_path):
        tools = []
        seen = set()
        pending_tool = None
        if not program_path or not os.path.isfile(program_path):
            return tools
        try:
            f = open(program_path, "r")
            try:
                for raw_line in f:
                    line = self.strip_gcode_comments(raw_line).upper()
                    if not line:
                        continue
                    # Accept normal, compact, and reversed tool-change formats:
                    # T5 M6, T5M6, T5 M06, T5M06, M6 T5, M6T5, M06 T5, M06T5.
                    inline_changes = re.findall(r"\bT\s*([0-9]+)\s*M\s*0?6(?=\D|$)", line)
                    reversed_changes = re.findall(r"\bM\s*0?6\s*T\s*([0-9]+)(?=\D|$)", line)
                    if PARSE_ONLY_TOOL_CHANGES:
                        if inline_changes:
                            for t_text in inline_changes:
                                tool_num = int(t_text)
                                if tool_num > 0 and tool_num not in seen:
                                    tools.append(tool_num)
                                    seen.add(tool_num)
                            pending_tool = None
                        elif reversed_changes:
                            for t_text in reversed_changes:
                                tool_num = int(t_text)
                                if tool_num > 0 and tool_num not in seen:
                                    tools.append(tool_num)
                                    seen.add(tool_num)
                            pending_tool = None
                        else:
                            t_matches = re.findall(r"\bT\s*([0-9]+)(?=\D|$)", line)
                            has_m6 = re.search(r"\bM\s*0?6(?=\D|$)", line) is not None
                            if t_matches:
                                pending_tool = int(t_matches[-1])
                            if has_m6 and pending_tool is not None:
                                if pending_tool > 0 and pending_tool not in seen:
                                    tools.append(pending_tool)
                                    seen.add(pending_tool)
                                pending_tool = None
                    else:
                        t_matches = re.findall(r"\bT\s*([0-9]+)(?=\D|$)", line)
                        for t_text in t_matches:
                            tool_num = int(t_text)
                            if tool_num > 0 and tool_num not in seen:
                                tools.append(tool_num)
                                seen.add(tool_num)
                    if len(tools) >= MAX_TOOLS_SHOWN:
                        break
            finally:
                f.close()
        except Exception as e:
            self.error_handler.write("[%s] Program parse error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return tools

    def get_loaded_program_signature_text(self):
        # Must match the signature Cycle Time writes, so stale estimate files are ignored.
        try:
            if self.loaded_program_path and os.path.isfile(self.loaded_program_path):
                return "%s|%s" % (int(os.path.getmtime(self.loaded_program_path)), int(os.path.getsize(self.loaded_program_path)))
        except:
            pass
        return "0|0"

    def format_tool_estimate_seconds(self, seconds):
        # Same compact style as Cycle Time's publisher, kept here as a defensive fallback.
        try:
            seconds = int(round(float(seconds)))
        except:
            seconds = 0
        if seconds < 0:
            seconds = 0
        hours = seconds // 3600
        minutes = (seconds % 3600) // 60
        seconds = seconds % 60
        if hours > 0:
            return "%dh %02dm" % (hours, minutes)
        if minutes > 0:
            return "%dm %ds" % (minutes, seconds)
        return "%ds" % seconds

    def get_tool_estimate_mode_text(self, label_mode):
        # Cycle Time publishes compact machine-readable modes.
        # TOD expands them only for display.
        label_mode = str(label_mode).strip().lower()
        if label_mode == "over":
            return "over"
        if label_mode == "rem":
            return "rem"
        if label_mode == "est":
            return "est"
        return label_mode

    def load_tool_estimate_labels(self):
        # Reads Cycle Time's optional overlay file. Missing, stale, or mismatched files simply clear labels.
        try:
            try:
                file_mtime = os.path.getmtime(TOOLS_ON_DECK)
            except:
                file_mtime = None
            program_key = "%s|%s" % (os.path.normpath(self.loaded_program_path), self.get_loaded_program_signature_text())
            if file_mtime is not None and file_mtime == self.tool_estimate_file_mtime and program_key == self.tool_estimate_program_key:
                return False
            self.tool_estimate_file_mtime = file_mtime
            self.tool_estimate_program_key = program_key
            new_labels = {}
            new_summary_text = self.timing_summary_text
            if file_mtime is not None:
                header_program = ""
                header_signature = ""
                handle = open(TOOLS_ON_DECK, "r")
                try:
                    for raw_line in handle:
                        line = raw_line.strip()
                        if line.startswith("program="):
                            header_program = os.path.normpath(line.split("=", 1)[1].strip())
                        elif line.startswith("signature="):
                            header_signature = line.split("=", 1)[1].strip()
                        elif line.startswith("summary|"):
                            parts = line.split("|")
                            if len(parts) >= 5:
                                try:
                                    elapsed_text = self.format_tool_estimate_seconds(parts[1].strip())
                                    expected_text = self.format_tool_estimate_seconds(parts[2].strip())
                                    summary_mode = self.get_tool_estimate_mode_text(parts[3].strip())
                                    summary_amount = self.format_tool_estimate_seconds(parts[4].strip())
                                    if summary_mode == "est":
                                        new_summary_text = "%s | %s | %s est" % (elapsed_text, expected_text, summary_amount)
                                    else:
                                        new_summary_text = "%s | %s | %s %s" % (elapsed_text, expected_text, summary_amount, summary_mode)
                                except:
                                    new_summary_text = ""
                        elif line.startswith("T") and "|" in line:
                            parts = line.split("|")
                            if len(parts) >= 3:
                                tool_text = parts[0].strip().upper()
                                label_mode = parts[1].strip().lower()
                                seconds_text = parts[2].strip()
                                try:
                                    tool_num = int(tool_text.replace("T", ""))
                                    new_labels[tool_num] = "%s %s" % (self.format_tool_estimate_seconds(seconds_text), self.get_tool_estimate_mode_text(label_mode))
                                except:
                                    pass
                finally:
                    handle.close()
                if header_program != os.path.normpath(self.loaded_program_path) or header_signature != self.get_loaded_program_signature_text():
                    new_labels = {}
                    new_summary_text = self.timing_summary_text
            changed = new_labels != self.tool_estimate_labels or new_summary_text != self.timing_summary_text
            self.tool_estimate_labels = new_labels
            self.timing_summary_text = new_summary_text
            return changed
        except Exception as e:
            self.error_handler.write("[%s] Estimate label read error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
            self.timing_summary_text = ""
            return False

    def get_tool_display_text(self, tool_num):
        tool_text = "T%d" % int(tool_num)
        estimate_text = self.tool_estimate_labels.get(int(tool_num), "")
        if estimate_text:
            return "%-*s | %s" % (TOOL_TEXT_WIDTH, tool_text, estimate_text)
        return tool_text

    def _get_overlay_right_edge(self):
        # Hard right anchor in the same fixed coordinate space used by parent.put()/move().
        # Do not use parent allocation here; PathPilot's fixed allocation can report
        # narrower than the visible area and pull the overlay too far left.
        return LOCATION_RIGHT

    def _get_overlay_container_width(self):
        # Invisible alignment container runs from x=0 to LOCATION_RIGHT.
        # Actual row widgets right-align inside it, so growth always goes left.
        return self._get_overlay_right_edge()

    def _get_overlay_container_x(self):
        return 0        

    def _get_text_pixel_width(self, text):
        # Measure the actual rendered text width instead of guessing by character count.
        try:
            if self.overlay_box:
                layout = self.overlay_box.create_pango_layout(str(text))
            else:
                label = gtk.Label()
                layout = label.create_pango_layout(str(text))
            layout.set_font_description(pango.FontDescription(OVERLAY_FONT_NAME))
            width, height = layout.get_pixel_size()
            return int(width)
        except:
            return len(str(text)) * 8

    def _get_longest_overlay_text(self):
        # Choose by rendered pixel width, not string length. This matters because
        # the summary row and tool rows can have different character mixes.
        longest_text = ""
        longest_width = 0
        for tool_num in self.loaded_program_tools:
            display_text = self.get_tool_display_text(tool_num)
            display_width = self._get_text_pixel_width(display_text)
            if display_width > longest_width:
                longest_text = display_text
                longest_width = display_width
        if self.timing_summary_text:
            summary_width = self._get_text_pixel_width(self.timing_summary_text)
            if summary_width > longest_width:
                longest_text = self.timing_summary_text
                longest_width = summary_width
        if not longest_text:
            longest_text = EMPTY_TEXT
        return longest_text

    def _get_overlay_width(self):
        # Grow wide enough for the longest visible row.
        # No ellipses, no clipping, no artificial max width unless the text is
        # physically wider than the available screen.
        longest_text = self._get_longest_overlay_text()
        calculated_width = self._get_text_pixel_width(longest_text) + BOX_TEXT_PADDING
        if calculated_width < BOX_MIN_WIDTH:
            calculated_width = BOX_MIN_WIDTH
        right_edge = self._get_overlay_right_edge()
        if calculated_width > right_edge:
            calculated_width = right_edge
        return calculated_width

    def _apply_cell_widths(self, box_width=None):
        # Existing row widgets keep their old size_request unless we update them.
        # This prevents rem/over text changes from visually outgrowing stale row widths.
        try:
            if box_width is None:
                box_width = self._get_overlay_width()
            if self.tools_box:
                self.tools_box.set_size_request(box_width, self._get_overlay_height())
            for event_box, label in self.tool_labels.values():
                try:
                    event_box.set_size_request(box_width, TOOL_CELL_HEIGHT)
                    label.set_size_request(box_width, TOOL_CELL_HEIGHT)
                except:
                    pass
        except:
            pass

    def _sync_overlay_geometry(self):
        # Keep the invisible container fixed from x=0 to LOCATION_RIGHT.
        # The visible backing box and rows resize together, right-aligned by gtk.Alignment.
        try:
            box_width = self._get_overlay_width()
            overlay_height = self._get_overlay_height()
            if self.overlay_box:
                self.overlay_box.set_size_request(self._get_overlay_container_width(), overlay_height)
            if self.overlay_content_box:
                self.overlay_content_box.set_size_request(box_width, overlay_height)
            self._apply_cell_widths(box_width)
            if self.overlay_parent and self.overlay_box:
                self.overlay_parent.move(self.overlay_box, self._get_overlay_container_x(), LOCATION_Y)
                self.overlay_box.queue_resize()
                self.overlay_box.queue_draw()
                self.overlay_parent.queue_draw()
        except:
            pass

    def _make_tool_cell(self, text, tool_num):
        box_width = self._get_overlay_width()        
        event_box = gtk.EventBox()
        event_box.set_size_request(box_width, TOOL_CELL_HEIGHT)        
        label = gtk.Label()
        label.set_use_markup(True)
        label.set_alignment(0.0, 0.5)
        label.set_justify(gtk.JUSTIFY_LEFT)
        label.set_size_request(box_width, TOOL_CELL_HEIGHT)
        label.set_padding(4, 0)
        try:
            label.modify_font(pango.FontDescription(OVERLAY_FONT_NAME))
        except:
            pass
        event_box.add(label)
        self.tool_labels[tool_num] = (event_box, label)
        return event_box

    def _make_summary_cell(self):
        box_width = self._get_overlay_width()
        event_box = gtk.EventBox()
        event_box.set_size_request(box_width, TOOL_CELL_HEIGHT)
        label = gtk.Label()
        label.set_use_markup(True)
        label.set_alignment(0.0, 0.5)
        label.set_justify(gtk.JUSTIFY_LEFT)
        label.set_size_request(box_width, TOOL_CELL_HEIGHT)
        label.set_padding(4, 0)
        try:
            label.modify_font(pango.FontDescription(OVERLAY_FONT_NAME))
        except:
            pass
        event_box.add(label)
        self.tool_labels[-1] = (event_box, label)
        return event_box  

    def _rebuild_tool_widgets(self):
        try:
            if self.tools_box:
                for child in self.tools_box.get_children():
                    self.tools_box.remove(child)
                    child.destroy()
            self.tool_labels = {}
            if self.overlay_box:
                self.overlay_box.set_size_request(self._get_overlay_container_width(), self._get_overlay_height())
                if self.overlay_content_box:
                    self.overlay_content_box.set_size_request(self._get_overlay_width(), self._get_overlay_height())
                if self.overlay_parent:
                    self.overlay_parent.move(self.overlay_box, self._get_overlay_container_x(), LOCATION_Y)          
            if len(self.loaded_program_tools) > 0:
                for tool_num in self.loaded_program_tools:
                    cell = self._make_tool_cell(self.get_tool_display_text(tool_num), tool_num)
                    self.tools_box.pack_start(cell, False, False, 0)
                cell = self._make_summary_cell()
                self.tools_box.pack_start(cell, False, False, 0)
            elif SHOW_WHEN_NO_PROGRAM:
                cell = self._make_tool_cell(EMPTY_TEXT, 0)
                self.tools_box.pack_start(cell, False, False, 0)
            self.apply_tool_highlight()
            self._sync_overlay_geometry()            
            self._apply_overlay_visibility()
            if self.overlay_box:
                self.overlay_box.show_all()
                self.overlay_box.queue_resize()
                self.overlay_box.queue_draw()
            if self.overlay_parent:
                self.overlay_parent.queue_draw()
        except Exception as e:
            self.error_handler.write("[%s] Rebuild overlay error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def _set_cell_style(self, tool_num, text, bg_color, fg_color, bold):
        try:
            event_box, label = self.tool_labels[tool_num]
            event_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse(bg_color))
            if bold:
                label.set_markup('<span foreground="%s"><b>%s</b></span>' % (fg_color, text))
            else:
                label.set_markup('<span foreground="%s">%s</span>' % (fg_color, text))
        except:
            pass

    def apply_tool_highlight(self):
        try:
            if 0 in self.tool_labels:
                self._set_cell_style(0, EMPTY_TEXT, NORMAL_BG, NORMAL_FG, False)
            if -1 in self.tool_labels:
                summary_text = self.timing_summary_text
                if not summary_text:
                    summary_text = BUILDING_ESTIMATES_TEXT
                self._set_cell_style(-1, summary_text, NORMAL_BG, NORMAL_FG, False)
            for tool_num in self.loaded_program_tools:
                display_text = self.get_tool_display_text(tool_num)
                if self.program_running and tool_num == self.current_tool:
                    self._set_cell_style(tool_num, display_text, CURRENT_BG, CURRENT_FG, False)
                elif tool_num in self.atc_missing_tools_cache:
                    self._set_cell_style(tool_num, display_text, MISSING_BG, MISSING_FG, True)
                else:
                    self._set_cell_style(tool_num, display_text, NORMAL_BG, NORMAL_FG, False)
        except Exception as e:
            self.error_handler.write("[%s] Highlight error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def update_overlay(self):
        try:
            if not self.overlay_box:
                self.ensure_program_tools_overlay()
            new_current_tool = self.get_current_tool_num()
            new_program_running = self.is_program_running()
            state_changed = new_current_tool != self.current_tool or new_program_running != self.program_running
            self.current_tool = new_current_tool
            self.program_running = new_program_running
            atc_missing_changed = False
            if self._periodic_atc_refresh_due():
                atc_missing_changed = self.refresh_atc_missing_tools("periodic")
            estimates_changed = self.load_tool_estimate_labels()
            if estimates_changed:
                self._rebuild_tool_widgets()
            elif state_changed or atc_missing_changed:
                self.apply_tool_highlight()
            self._sync_overlay_geometry()
            self._apply_overlay_visibility()
        except Exception as e:
            self.error_handler.write("[%s] Update error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return True