# 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

####################################
##                                ##
##      Resize 3D Viewer 1.27     ##
##       www.tormachtips.com      ##
##                                ##
####################################

# 1.27 - Fixes the full-screen blue message mirror appearing blank on normal->full transitions;
#        the bar now stays hidden unless the OEM message line is actually visible and non-empty. - 9/21/2026
# 1.26 - Mirrors PathPilot's blue M00/M01/manual-tool-change message bar into Full-Screen Focus Mode
#        and adds a tactile pressed/released animation to the full-screen Cycle Start control. - 9/21/2026

import os
import re
import sys
import time
import types
import inspect
import json
import gtk
import glib
import constants
import singletons
import btn
import ui_misc
from ui_hooks import plugin

CURRENT_VER                      = "1.27"
SCRIPT_NAME                      = "G-Code / Gremlin Resizable Split"
ENABLED                          = 1

DEV_MACHINE                      = 1
DEV_MACHINE_FLAG                 = "/home/operator/gcode/python/dev_machine.txt"

START_DELAY_MS                   = 2500
BOOTSTRAP_RETRY_MS               = 1000
BOOTSTRAP_LIMIT                  = 30

PERSIST_REDIS_HASH               = "machine_prefs"
LEGACY_REDIS_HASH                = "uistate"
OEM_UISTATE_HASH                 = "uistate"

STATE_PATH                       = "/home/operator/gcode/python/resize_3d_viewer_state.json"

REDIS_SHIFT_KEY                  = "main_tab_gcodewindow_drag_shift"
REDIS_LAST_KEY                   = "main_tab_gcodewindow_drag_last_nonzero_shift"
REDIS_OEM_SHIFT_KEY              = "main_tab_gcodewindow_oem_expand_shift"
REDIS_VIEW_MODE_KEY              = "main_tab_gcodewindow_view_mode"
REDIS_TT_GREMLIN_MODE_KEY        = "tormachtips_gremlin_view_mode"

VIEW_MODE_SPLIT                  = "split"
VIEW_MODE_GCODE_ONLY             = "gcode_only"
VIEW_MODE_GREMLIN_ONLY           = "gremlin_only"

GREMLIN_MENU_CONTRIBUTOR_KEY     = "resizable_split"
GREMLIN_CENTER_MENU_KEY          = "resizable_split_center_here"
GREMLIN_CENTER_MENU_GROUP        = 1000
GREMLIN_MENU_HOOK_DELAY_MS       = 3500

GREMLIN_HALFSCREEN_EDGE_FRACTION = 0.25
GREMLIN_HALFSCREEN_EDGE_MIN_PX   = 120
GREMLIN_HALFSCREEN_EDGE_MAX_PX   = 200

MIN_GCODE_WIDTH                  = 100
MIN_GREMLIN_WIDTH                = 100

SNAP_TO_NORMAL_PIXELS            = 8
DRAG_THRESHOLD_PIXELS            = 3
RESIZE_INTERVAL_SECONDS          = 0.025

DEFAULT_GCODE_WIDTH_PX           = 377

FULLSCREEN_CYCLE_START_X         = 8
FULLSCREEN_CYCLE_START_Y         = 717
FULLSCREEN_CYCLE_START_W         = 160
FULLSCREEN_CYCLE_START_H         = 40

FULLSCREEN_MESSAGE_X             = 220
FULLSCREEN_MESSAGE_Y             = 717
FULLSCREEN_MESSAGE_W             = 790
FULLSCREEN_MESSAGE_H             = 40

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.error_handler.write("[%s] Plugin loaded, but disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            return
        self.ui = None
        self.installed = False
        self.bootstrap_count = 0
        self.original_toggle = None
        self.original_update_size = None
        self.current_shift = 0
        self.last_nonzero_shift = 0
        self.default_gcode_width_px = int(DEFAULT_GCODE_WIDTH_PX)
        self.oem_expand_shift = 0
        self.min_shift = 0
        self.max_shift = 0
        self.press_root_x = 0.0
        self.press_shift = 0
        self.drag_candidate = False
        self.drag_active = False
        self.pending_shift = None
        self.last_live_resize_time = 0.0
        self.view_mode = VIEW_MODE_SPLIT
        self.gremlin = None
        self._saved_gremlin_visibility = {}
        # BEAGLE_RESIZE_3D_VIEWER_GREMLIN_FOCUS_MODE
        self.gremlin_focus_mode = False
        self._gremlin_focus_snapshot = None
        self._gremlin_focus_event_handler_id = None
        self._gremlin_focus_warning = None
        self._gremlin_focus_warning_eventbox = None
        self._fullscreen_cycle_start_button = None
        self._fullscreen_cycle_start_frame = None
        self._fullscreen_cycle_start_label = None
        self._fullscreen_message_bar = None
        self._fullscreen_message_label = None
        self._message_line_original_set = None
        self._message_line_original_clear = None
        self._gremlin_context_x = None
        self._gremlin_context_y = None
        glib.timeout_add(START_DELAY_MS, self._bootstrap)
        glib.timeout_add(GREMLIN_MENU_HOOK_DELAY_MS, self._install_gremlin_context_menu)

    # ------------------------------------------------------------------
    # Logging / compatibility helpers
    # ------------------------------------------------------------------

    def _status(self, text, level=None):
        try:
            if level is None:
                level = constants.ALARM_LEVEL_QUIET
            self.error_handler.write("[%s] %s" % (SCRIPT_NAME, text), level)
        except Exception:
            pass

    def _bind_instance_method(self, func):
        # Python 2 MethodType requires the class argument; Python 3 does not.
        if sys.version_info[0] < 3:
            return types.MethodType(func, self.ui, self.ui.__class__)
        return types.MethodType(func, self.ui)

    def _redis_get_persisted(self, key):
        # BEAGLE_RESIZE_3D_VIEWER_PERSISTENT_SPLIT_PREFS
        # machine_prefs survives PathPilot/machine restarts. Older builds used
        # uistate, so migrate a found legacy value once instead of discarding it.
        try:
            value = self.ui.redis.hget(PERSIST_REDIS_HASH, key)
            if value is not None:
                return value
        except Exception:
            pass
        try:
            legacy_value = self.ui.redis.hget(LEGACY_REDIS_HASH, key)
            if legacy_value is not None:
                try:
                    self.ui.redis.hset(PERSIST_REDIS_HASH, key, str(legacy_value))
                except Exception:
                    pass
                return legacy_value
        except Exception:
            pass
        return None

    def _redis_get_int(self, key, default=None):
        try:
            value = self._redis_get_persisted(key)
            if value is None:
                return default
            return int(float(value))
        except Exception:
            return default

    def _redis_set_int(self, key, value):
        try:
            self.ui.redis.hset(PERSIST_REDIS_HASH, key, str(int(value)))
        except Exception:
            pass

    def _redis_get_text(self, key, default=""):
        try:
            value = self._redis_get_persisted(key)
            if value is None:
                return default
            return str(value).strip()
        except Exception:
            return default

    def _redis_set_text(self, key, value):
        try:
            self.ui.redis.hset(PERSIST_REDIS_HASH, key, str(value))
        except Exception:
            pass

    def _publish_tormachtips_gremlin_mode(self, mode):
        try:
            self._redis_set_text(REDIS_TT_GREMLIN_MODE_KEY, str(mode))
        except Exception:
            pass

    def _state_read(self):
        # BEAGLE_RESIZE_3D_VIEWER_FILE_PERSISTENCE
        try:
            handle = open(STATE_PATH, "r")
            try:
                data = json.load(handle)
            finally:
                handle.close()
            if isinstance(data, dict):
                return data
        except Exception:
            pass
        return {}

    def _state_width_to_shift(self, width, fallback_shift=0):
        try:
            width = int(round(float(width)))
            base = int(self.ui.origsize_scrolled_window.cx)
            return self._clamp_shift(width - base)
        except Exception:
            return self._clamp_shift(fallback_shift)

    def _default_split_shift(self):
        return self._state_width_to_shift(self.default_gcode_width_px, self._width_to_shift(DEFAULT_GCODE_WIDTH_PX))

    def _state_write(self):
        # One authoritative split width. Temporary chevron drags do not change
        # this value; the context-menu command explicitly commits a new default.
        try:
            state = {
                "version": 2,
                "view_mode": str(self.view_mode),
                "default_gcode_width_px": int(self.default_gcode_width_px),}
            temp_path = "%s.%d.tmp" % (STATE_PATH, os.getpid())
            handle = open(temp_path, "w")
            try:
                json.dump(state, handle, indent=2, sort_keys=True)
                handle.write("\n")
                handle.flush()
                try:
                    os.fsync(handle.fileno())
                except Exception:
                    pass
            finally:
                handle.close()
            os.rename(temp_path, STATE_PATH)
        except Exception as exc:
            self._status("Could not save persistent split state: %s" % str(exc), constants.ALARM_LEVEL_LOW)

    def _load_saved_layout(self):
        # JSON remembers only the display mode and one default G-code width.
        # Old current/custom width keys are intentionally ignored so stale
        # positions cannot win after reboot or a mode transition.
        state = self._state_read()
        file_has_layout = False
        self.default_gcode_width_px = int(DEFAULT_GCODE_WIDTH_PX)
        if isinstance(state, dict) and state:
            saved_mode = str(state.get("view_mode", "") or "").strip()
            if saved_mode in (
                    VIEW_MODE_SPLIT,
                    VIEW_MODE_GCODE_ONLY,
                    VIEW_MODE_GREMLIN_ONLY):
                self.view_mode = saved_mode
                file_has_layout = True
            try:
                saved_default = int(round(float(state.get("default_gcode_width_px", DEFAULT_GCODE_WIDTH_PX))))
                self.default_gcode_width_px = saved_default
                file_has_layout = True
            except Exception:
                self.default_gcode_width_px = int(DEFAULT_GCODE_WIDTH_PX)
        # Clamp through the actual machine geometry once it is initialized.
        default_shift = self._default_split_shift()
        self.default_gcode_width_px = int(self.ui.origsize_scrolled_window.cx + default_shift)
        self.current_shift = default_shift
        return file_has_layout

    def _reapply_saved_layout_once(self):
        # PathPilot can do another geometry refresh after plugins install.
        # Reassert the one canonical split width after startup settles.
        try:
            if self.view_mode == VIEW_MODE_GCODE_ONLY:
                self._apply_gcode_only(persist=False)
            elif self.view_mode == VIEW_MODE_GREMLIN_ONLY:
                self._apply_gremlin_only(persist=False)
            else:
                self._apply_shift(self._default_split_shift(), persist=False, final=True)
        except Exception as exc:
            self._status("Delayed default split reapply failed: %s" % str(exc), constants.ALARM_LEVEL_QUIET)
        return False

    def _set_oem_state(self, shift):
        try:
            state = "Normal" if int(shift) == 0 else "Expanded"
            self.ui.redis.hset(OEM_UISTATE_HASH, "main_tab_gcodewindow_state", state)
        except Exception:
            pass

    def _infer_oem_click_shift(self):
        # Preserve the machine's existing click-expand preference where possible.
        # This catches stock 278 and the user's earlier 150-pixel 2.9.1 patch.
        try:
            source = inspect.getsource(self.ui.__class__._update_size_of_gremlin)
            active = []
            for line in source.splitlines():
                stripped = line.strip()
                if not stripped or stripped.startswith("#"):
                    continue
                match = re.search(r"\bSHIFT_CX\s*=\s*(-?\d+)", stripped)
                if match:
                    active.append(int(match.group(1)))
            if active:
                return active[-1]
        except Exception:
            pass
        # 2.14.x stock uses 278. If source inspection is unavailable this is a
        # reasonable click target; drag sizing remains unrestricted.
        return 278

    def _is_modern_layout(self):
        return (
            hasattr(self.ui, "gremlin_fixed") and
            hasattr(self.ui, "resize_gremlin_fixed") and
            getattr(self.ui, "gremlin_fixed", None) is not None)

    def _layout_supported(self):
        try:
            layout = getattr(self.ui, "layout", "")
            if layout and layout != "1024x768-landscape":
                return False
        except Exception:
            pass
        return True

    # ------------------------------------------------------------------
    # Bootstrap
    # ------------------------------------------------------------------

    def _bootstrap(self):
        if self.installed:
            return False
        self.bootstrap_count += 1
        try:
            self.ui = singletons.g_Machine
            if self.ui is None:
                if self.bootstrap_count < BOOTSTRAP_LIMIT:
                    glib.timeout_add(BOOTSTRAP_RETRY_MS, self._bootstrap)
                return False
            self._install_message_line_hooks()
            if not self._layout_supported():
                self._status(
                    "Current layout is not the supported 1024x768 landscape layout.",
                    constants.ALARM_LEVEL_QUIET)
                return False
            # Only validate widgets that exist from normal UI construction.
            # The origsize/origpos geometry fields are created lazily by
            # PathPilot's own _update_size_of_gremlin() the first time it runs.
            required = (
                "gremlin",
                "scrolled_window",
                "loaded_gcode_filename_evtbox",
                "mdi_line",
                "gcode_options_notebook",
                "gcode_options_button",
                "expandview_button",
                "gremlin_options",
                "origsize_gremlin",)
            for name in required:
                if not hasattr(self.ui, name):
                    raise RuntimeError("PathPilot UI is missing %s" % name)
            # PathPilot creates several geometry attributes lazily inside its
            # OEM _update_size_of_gremlin().  In 2.9.1, for example,
            # origpos_gcode_options_button is not created in __init__ at all.
            # Run the OEM method once before we validate or replace it.
            lazy_geometry = (
                "origsize_scrolled_window",
                "origsize_mdi_line",
                "origsize_loaded_gcode_filename_evtbox",
                "origpos_expandview_button",
                "origpos_gcode_options_button",
                "origsize_gcode_options_notebook",)
            need_oem_init = self.ui.origsize_gremlin is None
            if not need_oem_init:
                for name in lazy_geometry:
                    if not hasattr(self.ui, name) or getattr(self.ui, name) is None:
                        need_oem_init = True
                        break
            if self._is_modern_layout():
                if not hasattr(self.ui, "origpos_gremlin_fixed") or getattr(self.ui, "origpos_gremlin_fixed", None) is None:
                    need_oem_init = True
            else:
                if not hasattr(self.ui, "origpos_gremlin") or getattr(self.ui, "origpos_gremlin", None) is None:
                    need_oem_init = True
                legacy_geometry = (
                    "origsize_preview_image_overlay",
                    "origpos_preview_image_overlay",)
                for name in legacy_geometry:
                    if not hasattr(self.ui, name) or getattr(self.ui, name) is None:
                        need_oem_init = True
                        break
            if need_oem_init:
                self.ui._update_size_of_gremlin()
            missing_geometry = []
            for name in lazy_geometry:
                if not hasattr(self.ui, name) or getattr(self.ui, name) is None:
                    missing_geometry.append(name)
            if self.ui.origsize_gremlin is None or missing_geometry:
                if self.bootstrap_count < BOOTSTRAP_LIMIT:
                    glib.timeout_add(BOOTSTRAP_RETRY_MS, self._bootstrap)
                    return False
                raise RuntimeError("OEM Gremlin geometry did not initialize: %s" % ", ".join(missing_geometry))
            if getattr(self.ui, "_beagle_gcode_gremlin_drag_split_installed", False):
                self._status("Resizable split is already installed.")
                return False
            self._install()
            self.installed = True
            self.ui._beagle_gcode_gremlin_drag_split_installed = True
            mode = "2.14-style gremlin_fixed" if self._is_modern_layout() else "2.9-style direct Gremlin"
            self._status(
                "Installed (%s). Click toggles Normal/Custom; hold + drag resizes; "
                "double-click 3D viewer toggles full-screen Focus Mode." % mode)
            return False
        except Exception as exc:
            self._status("Startup failed: %s" % str(exc), constants.ALARM_LEVEL_LOW)
            return False

    def _install(self):
        self.original_toggle = self.ui.toggle_gcodewindow_size
        self.original_update_size = self.ui._update_size_of_gremlin
        self._compute_limits()
        # Detect the currently displayed shift before replacing anything.
        actual_shift = self._detect_current_shift()
        self.current_shift = self._clamp_shift(actual_shift)
        # JSON is authoritative for the one default split width. If this is the
        # first run of the simplified scheme, preserve only the old view mode;
        # stale Redis/custom widths are deliberately ignored.
        file_has_layout = self._load_saved_layout()
        if not file_has_layout:
            saved_view_mode = self._redis_get_text(REDIS_VIEW_MODE_KEY, VIEW_MODE_SPLIT)
            if saved_view_mode in (
                    VIEW_MODE_SPLIT,
                    VIEW_MODE_GCODE_ONLY,
                    VIEW_MODE_GREMLIN_ONLY):
                self.view_mode = saved_view_mode
            else:
                self.view_mode = VIEW_MODE_SPLIT
            self.default_gcode_width_px = int(DEFAULT_GCODE_WIDTH_PX)
            self.current_shift = self._default_split_shift()
        plugin_self = self

        def patched_toggle(ui_self):
            return plugin_self._toggle_requested()

        def patched_update_size(ui_self):
            return plugin_self._update_requested()

        self.ui.toggle_gcodewindow_size = self._bind_instance_method(patched_toggle)
        self.ui._update_size_of_gremlin = self._bind_instance_method(patched_update_size)
        handle = self.ui.expandview_button
        # PathPilot's OEM button-press handler returns True, which stops later
        # button-press handlers from seeing the event. That is why the earlier
        # direct-drag approach installed successfully but never received drag
        # initiation. Hijack only this chevron's press/release signals instead.
        try:
            handle.disconnect_by_func(self.ui.on_button_press_event)
        except Exception:
            pass
        try:
            handle.disconnect_by_func(self.ui.on_expandview_button_released)
        except Exception:
            pass
        try:
            handle.add_events(
                gtk.gdk.BUTTON_PRESS_MASK |
                gtk.gdk.BUTTON_RELEASE_MASK |
                gtk.gdk.POINTER_MOTION_MASK |
                gtk.gdk.BUTTON1_MOTION_MASK |
                gtk.gdk.ENTER_NOTIFY_MASK |
                gtk.gdk.LEAVE_NOTIFY_MASK)
        except Exception:
            pass
        handle.connect("button-press-event", self._on_chevron_press)
        handle.connect("motion-notify-event", self._on_chevron_motion)
        handle.connect("button-release-event", self._on_chevron_release)
        handle.connect("enter-notify-event", self._on_chevron_enter)
        handle.connect("leave-notify-event", self._on_chevron_leave)
        # Gremlin's own button-press handlers can consume the specific
        # button-press-event signal. Listen on the generic GTK event signal so
        # the second press of a left double-click can toggle Focus Mode without
        # replacing or patching Gremlin's normal mouse controls.
        self._install_gremlin_focus_hook()
        try:
            handle.set_tooltip_text(
                "Click: restore default split.  Hold + drag: temporary resize.  "
                "Right-click: layout/default-width controls.  "
                "Double-click the 3D viewer: full-screen / restore.")
        except Exception:
            pass
        # Remember the OEM click width separately. The user's custom width is
        # stored in LAST_KEY and becomes the normal left-click alternate.
        inferred_oem = self._clamp_nonzero_shift(self._infer_oem_click_shift())
        saved_oem = self._redis_get_int(REDIS_OEM_SHIFT_KEY, None)
        if saved_oem is None:
            self.oem_expand_shift = inferred_oem
            self._redis_set_int(REDIS_OEM_SHIFT_KEY, inferred_oem)
        else:
            self.oem_expand_shift = self._clamp_nonzero_shift(saved_oem)
        # Apply the persisted mode/shift once using the compatibility layer.
        if self.view_mode == VIEW_MODE_GCODE_ONLY:
            self._apply_gcode_only(persist=False)
        elif self.view_mode == VIEW_MODE_GREMLIN_ONLY:
            self._apply_gremlin_only(persist=False)
        else:
            self._apply_shift(self._default_split_shift(), persist=False, final=True)
        # Create/migrate the reboot-safe state file now that geometry exists.
        self._state_write()
        glib.timeout_add(750, self._reapply_saved_layout_once)
        glib.timeout_add(2000, self._reapply_saved_layout_once)

    # ------------------------------------------------------------------
    # Gremlin Focus Mode
    # ------------------------------------------------------------------

    def _install_gremlin_focus_hook(self):
        # BEAGLE_RESIZE_3D_VIEWER_GREMLIN_FOCUS_MODE
        # BEAGLE_RESIZE_3D_VIEWER_GREMLIN_TRUE_FULLSCREEN
        try:
            gremlin = self.ui.gremlin
            if gremlin is None:
                return False
            if self._gremlin_focus_event_handler_id is not None:
                return True
            try:
                gremlin.add_events(gtk.gdk.BUTTON_PRESS_MASK)
            except Exception:
                pass
            self._gremlin_focus_event_handler_id = gremlin.connect("event", self._on_gremlin_focus_event)
            return True
        except Exception as exc:
            self._status("Gremlin Focus Mode hook failed: %s" % str(exc), constants.ALARM_LEVEL_LOW)
            return False

    def _gremlin_half_screen_doubleclick_width(self, widget):
        # Use the currently allocated Gremlin width, not the saved OEM width, so
        # the hit zone remains intuitive after custom split resizing.
        width = 0
        try:
            width = int(widget.get_allocation().width)
        except Exception:
            pass
        if width <= 0:
            try:
                width = int(widget.get_size_request()[0])
            except Exception:
                width = 0
        if width <= 0:
            width = int(getattr(self.ui.origsize_gremlin, "cx", 0) or 0)
        zone = int(round(float(width) * GREMLIN_HALFSCREEN_EDGE_FRACTION))
        zone = max(GREMLIN_HALFSCREEN_EDGE_MIN_PX, zone)
        zone = min(GREMLIN_HALFSCREEN_EDGE_MAX_PX, zone)
        return max(1, zone)

    def _toggle_gremlin_half_screen(self):
        # This is the old "hide the text/G-code pane" behavior.  Restore the
        # exact current/custom split width rather than forcing OEM Normal.
        if self.view_mode == VIEW_MODE_GREMLIN_ONLY:
            self._restore_split(None, persist=True)
        else:
            self._apply_gremlin_only(persist=True)

    def _on_gremlin_focus_event(self, widget, event):
        # Never bind Escape: PathPilot uses it for job cancellation.
        try:
            if event is None:
                return False
            # BEAGLE_RESIZE_3D_VIEWER_CENTER_HERE_V1
            # Remember the exact point used to open Gremlin's right-click menu.
            # Do not consume the event; OEM/shared context-menu behavior continues.
            if (event.type == gtk.gdk.BUTTON_PRESS and
                    int(getattr(event, "button", 0) or 0) == 3):
                self._gremlin_context_x = float(getattr(event, "x", 0.0) or 0.0)
                self._gremlin_context_y = float(getattr(event, "y", 0.0) or 0.0)
                return False
            if event.type != gtk.gdk._2BUTTON_PRESS:
                return False
            if int(getattr(event, "button", 0) or 0) != 1:
                return False
            # Once Focus Mode is active, any double-click exits it.  Do not use
            # zones in full-screen because Gremlin has been moved/resized and an
            # edge-zone action there could leave the user in an unexpected mode.
            if self.gremlin_focus_mode:
                self._exit_gremlin_focus()
                return True
            click_x = float(getattr(event, "x", 0.0) or 0.0)
            edge_width = self._gremlin_half_screen_doubleclick_width(widget)
            if click_x <= float(edge_width):
                self._toggle_gremlin_half_screen()
            else:
                self._enter_gremlin_focus()
            return True
        except Exception as exc:
            self._status("Gremlin double-click action failed: %s" % str(exc), constants.ALARM_LEVEL_QUIET)
            return False

    def _gremlin_focus_target(self):
        # Focus Mode now targets the actual GTK-GL DrawingArea, not one of
        # PathPilot's notebook/fixed wrapper containers.
        return self.ui.gremlin

    def _focus_root(self):
        # ui.fixed is PathPilot's 1024x768 top-level GtkFixed content area.
        return self.ui.fixed

    def _focus_window_size(self):
        # Prefer the root fixed allocation because it is the drawable area we
        # reparent Gremlin into.  Window.get_size() can include decorations.
        try:
            allocation = self._focus_root().get_allocation()
            width = int(allocation.width)
            height = int(allocation.height)
            if width > 0 and height > 0:
                return width, height
        except Exception:
            pass
        try:
            width, height = self.ui.window.get_size()
            width = int(width)
            height = int(height)
            if width > 0 and height > 0:
                return width, height
        except Exception:
            pass
        try:
            return int(gtk.gdk.screen_width()), int(gtk.gdk.screen_height())
        except Exception:
            return 1024, 768

    def _fixed_child_position(self, parent, child):
        try:
            x = int(parent.child_get_property(child, "x"))
            y = int(parent.child_get_property(child, "y"))
            return (x, y)
        except Exception:
            pass
        try:
            allocation = child.get_allocation()
            return (int(allocation.x), int(allocation.y))
        except Exception:
            return (0, 0)

    def _widget_size_request(self, widget):
        try:
            size = widget.get_size_request()
            return (int(size[0]), int(size[1]))
        except Exception:
            return (-1, -1)

    def _capture_child_visibility(self, parent):
        rows = []
        try:
            for child in parent.get_children():
                try:
                    rows.append((child, bool(child.get_visible())))
                except Exception:
                    pass
        except Exception:
            pass
        return rows

    def _restore_child_visibility(self, rows):
        for child, was_visible in rows or []:
            try:
                self._set_widget_visible(child, bool(was_visible))
            except Exception:
                pass

    def _capture_gremlin_focus_snapshot(self):
        gremlin = self.ui.gremlin
        parent = gremlin.get_parent()
        root = self._focus_root()
        return {
            "view_mode": self.view_mode,
            "shift": int(self.current_shift),
            "saved_gremlin_visibility": dict(self._saved_gremlin_visibility or {}),
            "gremlin_parent": parent,
            "gremlin_position": self._fixed_child_position(parent, gremlin),
            "gremlin_size": self._widget_size_request(gremlin),
            "root_visibility": self._capture_child_visibility(root),}

    def _destroy_fullscreen_cycle_start_button(self):
        button = self._fullscreen_cycle_start_button
        self._fullscreen_cycle_start_button = None
        self._fullscreen_cycle_start_frame = None
        self._fullscreen_cycle_start_label = None
        if button is None:
            return
        try:
            parent = button.get_parent()
            if parent is not None:
                parent.remove(button)
        except Exception:
            pass
        try:
            button.destroy()
        except Exception:
            pass

    def _set_fullscreen_cycle_start_pressed(self, pressed):
        control = self._fullscreen_cycle_start_button
        frame = self._fullscreen_cycle_start_frame
        label = self._fullscreen_cycle_start_label
        if control is None:
            return
        try:
            if pressed:
                control.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#347b2d"))
                if frame is not None:
                    frame.set_shadow_type(gtk.SHADOW_IN)
                if label is not None:
                    label.set_padding(12, 12)
            else:
                control.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#4f9f45"))
                if frame is not None:
                    frame.set_shadow_type(gtk.SHADOW_OUT)
                if label is not None:
                    label.set_padding(10, 10)
            control.queue_draw()
        except Exception:
            pass

    def _dispatch_oem_cycle_start(self):
        try:
            ui = self.ui
            if ui is None:
                return False
            oem_button = None
            try:
                oem_button = ui.button_list.get("cycle_start")
            except Exception:
                oem_button = None
            if oem_button is None:
                self._status(
                    "Full-screen Cycle Start could not find the OEM cycle_start button.",
                    constants.ALARM_LEVEL_LOW)
                return False
            ui.enqueue_button_press_release(oem_button)
        except Exception as exc:
            self._status("Full-screen Cycle Start failed: %s" % str(exc), constants.ALARM_LEVEL_LOW)
        return False

    def _on_fullscreen_cycle_start_button_press(self, widget, event):
        try:
            if int(getattr(event, "button", 0) or 0) != 1:
                return False
        except Exception:
            return False
        self._set_fullscreen_cycle_start_pressed(True)
        return True

    def _on_fullscreen_cycle_start_button_release(self, widget, event):
        try:
            if int(getattr(event, "button", 0) or 0) != 1:
                return False
        except Exception:
            return False
        self._set_fullscreen_cycle_start_pressed(False)
        self._hide_fullscreen_message_bar()
        glib.timeout_add(70, self._dispatch_oem_cycle_start)
        return True

    def _show_fullscreen_cycle_start_button(self):
        self._destroy_fullscreen_cycle_start_button()
        if not self.gremlin_focus_mode:
            return
        root = self._focus_root()
        try:
            oem_button = None
            try:
                oem_button = self.ui.button_list.get("cycle_start")
            except Exception:
                oem_button = None
            if oem_button is None:
                return
            control = gtk.EventBox()
            control.set_visible_window(True)
            control.set_name("tormachtips_fullscreen_cycle_start")
            control.set_size_request(int(FULLSCREEN_CYCLE_START_W), int(FULLSCREEN_CYCLE_START_H))
            try:
                control.add_events(gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON_RELEASE_MASK)
            except Exception:
                pass
            try:
                control.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#4f9f45"))
            except Exception:
                pass
            frame = gtk.Frame()
            frame.set_shadow_type(gtk.SHADOW_OUT)
            label = gtk.Label()
            label.set_markup('<span foreground="#ffffff" weight="bold" size="x-large">' 'CYCLE START' '</span>')
            label.set_alignment(0.5, 0.5)
            label.set_padding(10, 10)
            frame.add(label)
            control.add(frame)
            control.connect("button-press-event", self._on_fullscreen_cycle_start_button_press)
            control.connect("button-release-event", self._on_fullscreen_cycle_start_button_release)
            control.show_all()
            root.put(control, int(FULLSCREEN_CYCLE_START_X), int(FULLSCREEN_CYCLE_START_Y))
            self._fullscreen_cycle_start_button = control
            self._fullscreen_cycle_start_frame = frame
            self._fullscreen_cycle_start_label = label
            try:
                root.remove(control)
                root.put(control, int(FULLSCREEN_CYCLE_START_X), int(FULLSCREEN_CYCLE_START_Y))
                control.show_all()
            except Exception:
                pass
        except Exception as exc:
            self._fullscreen_cycle_start_button = None
            self._fullscreen_cycle_start_frame = None
            self._fullscreen_cycle_start_label = None
            self._status("Could not show full-screen Cycle Start button: %s" % str(exc), constants.ALARM_LEVEL_QUIET)

    def _hide_fullscreen_message_bar(self):
        try:
            if self._fullscreen_message_bar is not None:
                self._fullscreen_message_bar.hide()
        except Exception:
            pass

    def _sync_fullscreen_message_bar_from_oem(self):
        if not self.gremlin_focus_mode:
            self._hide_fullscreen_message_bar()
            return
        try:
            oem = getattr(self.ui, "message_line", None)
            if oem is None:
                self._hide_fullscreen_message_bar()
                return
            text_value = oem.get_text() or ""
            if not bool(oem.get_visible()) or not text_value.strip():
                self._hide_fullscreen_message_bar()
                return
            if self._fullscreen_message_label is not None:
                self._fullscreen_message_label.set_text(text_value)
                if len(text_value) >= 50:
                    self._fullscreen_message_label.set_alignment(0.03, 0.5)
                else:
                    self._fullscreen_message_label.set_alignment(0.5, 0.5)
            if self._fullscreen_message_bar is not None:
                self._fullscreen_message_bar.show_all()
                try:
                    parent = self._fullscreen_message_bar.get_parent()
                    if parent is not None:
                        parent.remove(self._fullscreen_message_bar)
                        parent.put(self._fullscreen_message_bar, int(FULLSCREEN_MESSAGE_X), int(FULLSCREEN_MESSAGE_Y))
                        self._fullscreen_message_bar.show_all()
                except Exception:
                    pass
        except Exception:
            self._hide_fullscreen_message_bar()

    def _destroy_fullscreen_message_bar(self):
        bar = self._fullscreen_message_bar
        self._fullscreen_message_bar = None
        self._fullscreen_message_label = None
        if bar is None:
            return
        try:
            parent = bar.get_parent()
            if parent is not None:
                parent.remove(bar)
        except Exception:
            pass
        try:
            bar.destroy()
        except Exception:
            pass

    def _show_fullscreen_message_bar(self):
        self._destroy_fullscreen_message_bar()
        if not self.gremlin_focus_mode:
            return
        root = self._focus_root()
        try:
            bar = gtk.EventBox()
            bar.set_visible_window(True)
            bar.set_size_request(int(FULLSCREEN_MESSAGE_W), int(FULLSCREEN_MESSAGE_H))
            bar.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#4295DD"))
            label = gtk.Label()
            label.set_alignment(0.5, 0.5)
            label.set_padding(10, 4)
            try:
                label.modify_fg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#ffffff"))
            except Exception:
                pass
            bar.add(label)
            label.show()
            root.put(bar, int(FULLSCREEN_MESSAGE_X), int(FULLSCREEN_MESSAGE_Y))
            self._fullscreen_message_bar = bar
            self._fullscreen_message_label = label
            # Start hidden.  Only mirror a real, visible, non-empty OEM
            # message_line.  This prevents an empty blue rectangle when
            # entering Focus Mode with no active M00/M01/tool-change prompt.
            bar.hide()
            self._sync_fullscreen_message_bar_from_oem()
        except Exception as exc:
            self._fullscreen_message_bar = None
            self._fullscreen_message_label = None
            self._status("Could not show full-screen message bar: %s" % str(exc), constants.ALARM_LEVEL_QUIET)

    def _install_message_line_hooks(self):
        # Mirror PathPilot's real blue message line event-by-event; no polling.
        try:
            ui = self.ui
            if ui is None:
                return
            if self._message_line_original_set is None:
                original_set = ui.set_message_line_text
                self._message_line_original_set = original_set

                def set_wrapper(text_value, *args, **kwargs):
                    result = original_set(text_value, *args, **kwargs)
                    self._sync_fullscreen_message_bar_from_oem()
                    return result

                ui.set_message_line_text = set_wrapper
            if self._message_line_original_clear is None:
                original_clear = ui.clear_message_line_text
                self._message_line_original_clear = original_clear

                def clear_wrapper(*args, **kwargs):
                    result = original_clear(*args, **kwargs)
                    self._hide_fullscreen_message_bar()
                    return result

                ui.clear_message_line_text = clear_wrapper
        except Exception as exc:
            self._status("Could not hook PathPilot message line: %s" % str(exc), constants.ALARM_LEVEL_QUIET)

    def _destroy_gremlin_focus_warning(self):
        warning = self._gremlin_focus_warning_eventbox
        self._gremlin_focus_warning = None
        self._gremlin_focus_warning_eventbox = None
        self._gremlin_context_x = None
        self._gremlin_context_y = None
        if warning is None:
            return
        try:
            parent = warning.get_parent()
            if parent is not None:
                parent.remove(warning)
        except Exception:
            pass
        try:
            warning.destroy()
        except Exception:
            pass

    def _on_gremlin_focus_warning_click(self, widget, event):
        # A single click dismisses only the reminder, not Focus Mode.
        try:
            if event is not None:
                event_type = getattr(event, "type", None)
                button = int(getattr(event, "button", 0) or 0)
                if event_type != gtk.gdk.BUTTON_PRESS or button != 1:
                    return False
        except Exception:
            return False
        self._destroy_gremlin_focus_warning()
        return True

    def _show_gremlin_focus_warning(self):
        # BEAGLE_RESIZE_3D_VIEWER_FOCUS_ESC_WARNING
        # Keep this intentionally small and passive.  It is not interactive and
        # does not bind Esc; it only reminds the operator what PathPilot already
        # does with that key during a running cycle.
        self._destroy_gremlin_focus_warning()
        root = self._focus_root()
        try:
            eventbox = gtk.EventBox()
            eventbox.set_visible_window(True)
            try:
                eventbox.add_events(gtk.gdk.BUTTON_PRESS_MASK)
            except Exception:
                pass
            eventbox.connect("button-press-event", self._on_gremlin_focus_warning_click)
            try:
                eventbox.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("#1f1f1f"))
            except Exception:
                pass
            label = gtk.Label()
            label.set_markup(
                '<span foreground="#f0f0f0" size="small">'
                '<b>Double-click to close full screen.</b>  '
                'Do not use Esc — it will cancel the active cycle.'
                '</span>')
            label.set_alignment(0.0, 0.5)
            label.set_padding(8, 4)
            eventbox.add(label)
            eventbox.set_name("gremlin_focus_escape_warning")
            eventbox.show_all()
            root.put(eventbox, 8, 8)
            try:
                eventbox.set_size_request(-1, 28)
            except Exception:
                pass
            self._gremlin_focus_warning = label
            self._gremlin_focus_warning_eventbox = eventbox
            # Raise it above the reparented Gremlin widget in GtkFixed.
            try:
                root.remove(eventbox)
                root.put(eventbox, 8, 8)
                eventbox.show_all()
            except Exception:
                pass
        except Exception as exc:
            self._status("Could not show Focus Mode Esc warning: %s" % str(exc), constants.ALARM_LEVEL_QUIET)

    def _focus_redraw_idle(self):
        if not self.gremlin_focus_mode:
            return False
        try:
            gremlin = self.ui.gremlin
            gremlin.queue_resize()
            gremlin.queue_draw()
            # Gremlin's normal poll/expose machinery does the GL-context-safe
            # redraw.  Do not call expose()/redraw_view() directly here.
        except Exception:
            pass
        return False

    def _apply_gremlin_focus_geometry(self):
        if not self.gremlin_focus_mode:
            return
        gremlin = self.ui.gremlin
        root = self._focus_root()
        width, height = self._focus_window_size()
        # The key difference from 1.11/1.12: move the real GTK-GL DrawingArea
        # to the already-full-screen root GtkFixed.  PathPilot 2.9.1 normally
        # puts Gremlin in notebook_main_fixed (680x410 inside a 1008x442
        # notebook). Enlarging those ancestors produces a blank/gray region
        # rather than a reliable enlarged GL drawable.
        try:
            if gremlin.get_parent() is not root:
                gremlin.reparent(root)
        except Exception as exc:
            self._status("Could not reparent Gremlin for Focus Mode: %s" % str(exc), constants.ALARM_LEVEL_LOW)
            return
        # Because Gremlin is now a direct child of the root fixed container, it
        # remains the actual event target. Double-click and OEM/plugin right-click
        # handlers therefore still work while focused.
        try:
            root.move(gremlin, 0, 0)
        except Exception:
            pass
        try:
            gremlin.set_size_request(int(width), int(height))
        except Exception:
            pass
        self._set_widget_visible(gremlin, True)
        # Add the Focus Mode controls above Gremlin while full-screen.
        self._show_gremlin_focus_warning()
        self._show_fullscreen_cycle_start_button()
        self._show_fullscreen_message_bar()
        # Hide all original top-level PathPilot children behind Gremlin.  Gremlin
        # itself was reparented after these children, which also makes it the
        # newest/topmost child in GtkFixed's otherwise weak z-order model.
        try:
            for child in root.get_children():
                if child is self._fullscreen_message_bar:
                    # Visibility is controlled strictly by the OEM message line.
                    continue
                if (child is gremlin or
                        child is self._gremlin_focus_warning_eventbox or
                        child is self._fullscreen_cycle_start_button):
                    self._set_widget_visible(child, True)
                else:
                    self._set_widget_visible(child, False)
            # Reconcile once after the root visibility sweep in case the OEM
            # message changed while Focus Mode was being assembled.
            self._sync_fullscreen_message_bar_from_oem()
        except Exception:
            pass
        try:
            root.queue_resize()
            gremlin.queue_resize()
            gremlin.queue_draw()
            root.queue_draw()
        except Exception:
            pass
        try:
            glib.idle_add(self._focus_redraw_idle)
        except Exception:
            pass

    def _enter_gremlin_focus(self, menu_item=None):
        if self.gremlin_focus_mode:
            return
        self._gremlin_focus_snapshot = self._capture_gremlin_focus_snapshot()
        self.gremlin_focus_mode = True
        self._publish_tormachtips_gremlin_mode("full")
        self._apply_gremlin_focus_geometry()
        self._status(
            "3D Viewer full-screen Focus Mode on. "
            "Double-click or right-click the viewer to restore/control it.")

    def _restore_focus_structure(self, snapshot):
        if not isinstance(snapshot, dict):
            return
        gremlin = self.ui.gremlin
        root = self._focus_root()
        old_parent = snapshot.get("gremlin_parent")
        # First make PathPilot's root hierarchy visible again.  The Gremlin is
        # still topmost at this instant, so this should not cause a visible flash.
        self._restore_child_visibility(snapshot.get("root_visibility", []))
        if old_parent is not None:
            try:
                if gremlin.get_parent() is not old_parent:
                    gremlin.reparent(old_parent)
            except Exception as exc:
                self._status("Could not restore Gremlin parent: %s" % str(exc), constants.ALARM_LEVEL_LOW)
            try:
                gx, gy = snapshot.get("gremlin_position", (0, 0))
                old_parent.move(gremlin, int(gx), int(gy))
            except Exception:
                pass
        try:
            gw, gh = snapshot.get("gremlin_size", (-1, -1))
            gremlin.set_size_request(int(gw), int(gh))
        except Exception:
            pass
        self._set_widget_visible(gremlin, True)
        try:
            root.queue_resize()
            gremlin.queue_resize()
            gremlin.queue_draw()
        except Exception:
            pass

    def _restack_focus_overlays(self):
        # Reparenting Gremlin back into notebook_main_fixed makes Gremlin the
        # newest/topmost GtkFixed child.  Re-append overlays that are intentionally
        # meant to sit above the normal Gremlin view.  Keep this event-driven:
        # it runs only while leaving Focus Mode, not on a background poll.
        overlay_names = (
            "program_tools_overlay",       # Tools On Deck
            "g53_machine_coord_overlay",   # G53 Machine Coord Overlay
        )
        try:
            parent = self._normal_parent()
            if parent is None:
                return False
            matches = []
            try:
                children = list(parent.get_children())
            except Exception:
                children = []
            # Preserve the overlays' existing relative order while moving the
            # whole group above the newly reparented Gremlin widget.
            for child in children:
                try:
                    if child.get_name() in overlay_names:
                        matches.append((child, bool(child.get_visible()), self._fixed_child_position(parent, child)))
                except Exception:
                    pass
            for child, was_visible, position in matches:
                try:
                    x, y = position
                    parent.remove(child)
                    parent.put(child, int(x), int(y))
                    self._set_widget_visible(child, was_visible)
                    child.queue_resize()
                    child.queue_draw()
                except Exception:
                    pass
            if matches:
                try:
                    parent.queue_draw()
                except Exception:
                    pass
        except Exception as exc:
            self._status("Could not restore Gremlin overlay stacking: %s" % str(exc), constants.ALARM_LEVEL_QUIET)
        return False

    def _schedule_focus_overlay_restack(self):
        # Run once immediately after layout restoration and once just after GTK
        # has processed the reparent/resize allocation.  The second pass protects
        # against PathPilot geometry callbacks that run in the same event turn.
        self._restack_focus_overlays()
        try:
            glib.idle_add(self._restack_focus_overlays)
        except Exception:
            pass
        try:
            glib.timeout_add(75, self._restack_focus_overlays)
        except Exception:
            pass

    def _exit_gremlin_focus(self, menu_item=None):
        if not self.gremlin_focus_mode:
            return
        snapshot = self._gremlin_focus_snapshot or {}
        self.gremlin_focus_mode = False
        self._publish_tormachtips_gremlin_mode("normal")
        self._destroy_gremlin_focus_warning()
        self._destroy_fullscreen_cycle_start_button()
        self._destroy_fullscreen_message_bar()
        self._restore_focus_structure(snapshot)
        try:
            # Full-screen Focus Mode is now a guaranteed "home" path:
            # regardless of whether it was entered from default split or the
            # Gremlin-only wide view, leaving Focus Mode always returns to the
            # one JSON-backed default split width.
            self._show_all_split_widgets()
            self.view_mode = VIEW_MODE_SPLIT
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
            self._apply_shift(self._default_split_shift(), persist=False, final=True)
            self._state_write()
        finally:
            # _apply_* restores normal OEM geometry. Root-level visibility must
            # then be made exact again in case PathPilot had transient controls
            # hidden when Focus Mode started.
            self._restore_child_visibility(snapshot.get("root_visibility", []))
            self._saved_gremlin_visibility = dict(snapshot.get("saved_gremlin_visibility", {}) or {})
            self._gremlin_focus_snapshot = None
            try:
                self.ui.gremlin.queue_resize()
                self.ui.gremlin.queue_draw()
                self.ui.fixed.queue_draw()
            except Exception:
                pass
            # Gremlin was just appended back into notebook_main_fixed, so raise
            # overlays that are intentionally meant to sit above the 3D viewer.
            self._schedule_focus_overlay_restack()
        self._status("3D Viewer full-screen Focus Mode off; default split restored.")

    def _toggle_gremlin_focus(self, menu_item=None):
        if self.gremlin_focus_mode:
            self._exit_gremlin_focus(menu_item)
        else:
            self._enter_gremlin_focus(menu_item)

    # ------------------------------------------------------------------
    # Geometry
    # ------------------------------------------------------------------

    def _compute_limits(self):
        base_left = int(self.ui.origsize_scrolled_window.cx)
        base_gremlin = int(self.ui.origsize_gremlin.cx)
        self.min_shift = MIN_GCODE_WIDTH - base_left
        self.max_shift = base_gremlin - MIN_GREMLIN_WIDTH
        # Gremlin options has a 294px base tool table. Keep it useful when
        # dragging left past the OEM Normal position.
        self.min_shift = max(self.min_shift, -110)
        if self.max_shift < 0:
            self.max_shift = 0

    def _clamp_shift(self, shift):
        try:
            shift = int(round(float(shift)))
        except Exception:
            shift = 0
        if shift < self.min_shift:
            shift = self.min_shift
        if shift > self.max_shift:
            shift = self.max_shift
        return shift

    def _clamp_nonzero_shift(self, shift):
        shift = self._clamp_shift(shift)
        if shift == 0:
            fallback = min(278, self.max_shift)
            if fallback == 0 and self.min_shift < 0:
                fallback = max(-50, self.min_shift)
            shift = fallback
        return shift

    def _detect_current_shift(self):
        # Size is more reliable than position because PathPilot may recreate a
        # fixed parent during startup.
        try:
            current = self.ui.scrolled_window.get_size_request()[0]
            base = self.ui.origsize_scrolled_window.cx
            if current > 0 and base > 0:
                return int(current - base)
        except Exception:
            pass
        try:
            pos = ui_misc.get_xy_pos(self.ui.expandview_button)
            base = self.ui.origpos_expandview_button
            return int(pos.x - base.x)
        except Exception:
            return 0

    def _normal_parent(self):
        if self._is_modern_layout():
            return self.ui.notebook_main_fixed
        if hasattr(self.ui, "get_normal_fixed_object"):
            return self.ui.get_normal_fixed_object("notebook_main_fixed")
        return self.ui.notebook_main_fixed

    def _apply_shift(self, shift, persist=False, final=False):
        shift = self._clamp_shift(shift)
        ui = self.ui
        if self.view_mode != VIEW_MODE_SPLIT:
            self._show_all_split_widgets()
        self.view_mode = VIEW_MODE_SPLIT
        self._publish_tormachtips_gremlin_mode("normal")
        if persist:
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
        gremlin_width = int(ui.origsize_gremlin.cx - shift)
        left_width = int(ui.origsize_scrolled_window.cx + shift)
        if gremlin_width < MIN_GREMLIN_WIDTH or left_width < MIN_GCODE_WIDTH:
            return
        parent = self._normal_parent()
        if self._is_modern_layout():
            # PathPilot 2.14.x keeps Gremlin and its overlays inside gremlin_fixed.
            ui.resize_gremlin_fixed(gremlin_width, ui.origsize_gremlin.cy)
            parent.move(ui.gremlin_fixed, ui.origpos_gremlin_fixed.x + shift, ui.origpos_gremlin_fixed.y)
        else:
            # PathPilot 2.9.1 places Gremlin and overlays directly in the page.
            ui.gremlin.set_size_request(gremlin_width, ui.origsize_gremlin.cy)
            parent.move(ui.gremlin, ui.origpos_gremlin.x + shift, ui.origpos_gremlin.y)
            try:
                ui.message_line.set_size_request(gremlin_width, 40)
                parent.move(ui.message_line, ui.origpos_gremlin.x + shift, 370)
            except Exception:
                pass
            preview_width = int(ui.origsize_preview_image_overlay.cx - shift)
            ui.preview_image_overlay.set_size_request(preview_width, ui.origsize_preview_image_overlay.cy)
            parent.move(
                ui.preview_image_overlay,
                ui.origpos_preview_image_overlay.x + shift,
                ui.origpos_preview_image_overlay.y)
            if getattr(ui, "vlcwidget", None):
                try:
                    ui.vlcwidget.set_size_request(preview_width, ui.origsize_preview_image_overlay.cy)
                    parent.move(
                        ui.vlcwidget,
                        ui.origpos_preview_image_overlay.x + shift,
                        ui.origpos_preview_image_overlay.y)
                except Exception:
                    pass
        ui.scrolled_window.set_size_request(ui.origsize_scrolled_window.cx + shift, ui.origsize_scrolled_window.cy)
        ui.loaded_gcode_filename_evtbox.set_size_request(
            ui.origsize_loaded_gcode_filename_evtbox.cx + shift,
            ui.origsize_loaded_gcode_filename_evtbox.cy)
        btn.ImageButton.move_button(
            ui.gcode_options_button,
            ui.origpos_gcode_options_button.x + shift,
            ui.origpos_gcode_options_button.y)
        btn.ImageButton.move_button(
            ui.expandview_button,
            ui.origpos_expandview_button.x + shift,
            ui.origpos_expandview_button.y)
        ui.mdi_line.set_size_request(ui.origsize_mdi_line.cx + shift, ui.origsize_mdi_line.cy)
        ui.gcode_options_notebook.set_size_request(
            ui.origsize_gcode_options_notebook.cx + shift,
            ui.origsize_gcode_options_notebook.cy)
        state = "Normal" if shift == 0 else "Expanded"
        ui.gremlin_options.set_view_state(state, shift)
        try:
            # Chevron points toward the width a click will restore.
            if shift > 0:
                image_name = "left-chevron.png"
            elif shift < 0:
                image_name = "right-chevron.png"
            elif self.last_nonzero_shift < 0:
                image_name = "left-chevron.png"
            else:
                image_name = "right-chevron.png"
            ui.set_image("expandview_button_image", image_name)
        except Exception:
            pass
        self.current_shift = shift
        self._set_oem_state(shift)
        if persist:
            self._redis_set_int(REDIS_SHIFT_KEY, shift)
            if shift != 0:
                self.last_nonzero_shift = shift
                self._redis_set_int(REDIS_LAST_KEY, shift)
            self._state_write()
        if final:
            self._refresh_preview_overlay_if_needed()

    def _set_widget_visible(self, widget, visible):
        try:
            if visible:
                widget.show()
            else:
                widget.hide()
        except Exception:
            pass

    def _set_gcode_widgets_visible(self, visible):
        for widget_name in (
                "scrolled_window",
                "loaded_gcode_filename_evtbox",
                "mdi_line",
                "gcode_options_notebook",
                "gcode_options_button"):
            try:
                self._set_widget_visible(getattr(self.ui, widget_name), visible)
            except Exception:
                pass

    def _capture_gremlin_visibility(self):
        # Some Gremlin-adjacent widgets are intentionally transient.  In
        # particular PathPilot's M01/message bar is normally hidden until needed.
        # Never restore those by blindly calling show(); remember their actual
        # state before entering a one-pane mode.
        saved = {}
        for widget_name in (
                "gremlin",
                "gremlin_fixed",
                "message_line",
                "preview_image_overlay",
                "vlcwidget"):
            try:
                widget = getattr(self.ui, widget_name, None)
                if widget is not None:
                    saved[widget_name] = bool(widget.get_visible())
            except Exception:
                pass
        self._saved_gremlin_visibility = saved

    def _hide_gremlin_widgets(self):
        ui = self.ui
        if self._is_modern_layout():
            self._set_widget_visible(ui.gremlin_fixed, False)
        else:
            self._set_widget_visible(ui.gremlin, False)
            for widget_name in ("message_line", "preview_image_overlay", "vlcwidget"):
                try:
                    widget = getattr(ui, widget_name, None)
                    if widget is not None:
                        self._set_widget_visible(widget, False)
                except Exception:
                    pass

    def _restore_gremlin_widgets(self):
        ui = self.ui
        saved = self._saved_gremlin_visibility or {}
        if self._is_modern_layout():
            # gremlin_fixed is the container that must be visible for the 3D
            # viewer to return.  Child/transient visibility remains managed by
            # PathPilot inside that container.
            self._set_widget_visible(ui.gremlin_fixed, True)
        else:
            self._set_widget_visible(ui.gremlin, True)
            # Restore transient siblings exactly as they were.  If we never
            # captured a widget, leave it alone instead of forcing it visible.
            for widget_name in ("message_line", "preview_image_overlay", "vlcwidget"):
                if widget_name not in saved:
                    continue
                try:
                    widget = getattr(ui, widget_name, None)
                    if widget is not None:
                        self._set_widget_visible(widget, bool(saved[widget_name]))
                except Exception:
                    pass
        self._saved_gremlin_visibility = {}

    def _show_all_split_widgets(self):
        self._set_gcode_widgets_visible(True)
        self._restore_gremlin_widgets()
        try:
            self.ui.expandview_button.show()
        except Exception:
            pass

    def _combined_content_width(self):
        return int(self.ui.origsize_scrolled_window.cx + self.ui.origsize_gremlin.cx)

    def _left_edge_shift(self):
        return -int(self.ui.origsize_scrolled_window.cx)

    def _right_edge_shift(self):
        return int(self.ui.origsize_gremlin.cx)

    def _position_chevron_for_edge_shift(self, shift):
        try:
            btn.ImageButton.move_button(
                self.ui.expandview_button,
                self.ui.origpos_expandview_button.x + int(shift),
                self.ui.origpos_expandview_button.y)
            self.ui.expandview_button.show()
        except Exception:
            pass

    def _apply_gremlin_only(self, persist=True):
        # Hide the complete G-code/source-side UI and let Gremlin consume the
        # combined horizontal content width. The chevron stays visible at the
        # left edge so Show Both / Normal is always recoverable.
        ui = self.ui
        parent = self._normal_parent()
        total_width = self._combined_content_width()
        edge_shift = self._left_edge_shift()
        self._set_gcode_widgets_visible(False)
        # Do not force transient Gremlin siblings (M01/message bar, preview
        # overlays) visible.  The actual 3D viewer is all this mode promises.
        if self._is_modern_layout():
            self._set_widget_visible(ui.gremlin_fixed, True)
        else:
            self._set_widget_visible(ui.gremlin, True)
        if self._is_modern_layout():
            ui.resize_gremlin_fixed(total_width, ui.origsize_gremlin.cy)
            parent.move(ui.gremlin_fixed, ui.origpos_gremlin_fixed.x + edge_shift, ui.origpos_gremlin_fixed.y)
        else:
            ui.gremlin.set_size_request(total_width, ui.origsize_gremlin.cy)
            parent.move(ui.gremlin, ui.origpos_gremlin.x + edge_shift, ui.origpos_gremlin.y)
            try:
                ui.message_line.set_size_request(total_width, 40)
                parent.move(ui.message_line, ui.origpos_gremlin.x + edge_shift, 370)
            except Exception:
                pass
            try:
                preview_width = int(ui.origsize_preview_image_overlay.cx - edge_shift)
                ui.preview_image_overlay.set_size_request(preview_width, ui.origsize_preview_image_overlay.cy)
                parent.move(
                    ui.preview_image_overlay,
                    ui.origpos_preview_image_overlay.x + edge_shift,
                    ui.origpos_preview_image_overlay.y)
                if getattr(ui, "vlcwidget", None):
                    ui.vlcwidget.set_size_request(preview_width, ui.origsize_preview_image_overlay.cy)
                    parent.move(
                        ui.vlcwidget,
                        ui.origpos_preview_image_overlay.x + edge_shift,
                        ui.origpos_preview_image_overlay.y)
            except Exception:
                pass
        self._position_chevron_for_edge_shift(edge_shift)
        self.view_mode = VIEW_MODE_GREMLIN_ONLY
        self._publish_tormachtips_gremlin_mode("wide")
        if persist:
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
            self._state_write()
        try:
            parent.queue_draw()
        except Exception:
            pass

    def _apply_gcode_only(self, persist=True):
        # Hide the complete Gremlin/3D side and let the G-code/source-side UI
        # consume the combined width. The chevron remains at the right edge.
        ui = self.ui
        total_width = self._combined_content_width()
        edge_shift = self._right_edge_shift()
        self._capture_gremlin_visibility()
        self._hide_gremlin_widgets()
        self._set_gcode_widgets_visible(True)
        ui.scrolled_window.set_size_request(total_width, ui.origsize_scrolled_window.cy)
        ui.loaded_gcode_filename_evtbox.set_size_request(
            int(ui.origsize_loaded_gcode_filename_evtbox.cx + edge_shift),
            ui.origsize_loaded_gcode_filename_evtbox.cy)
        ui.mdi_line.set_size_request(int(ui.origsize_mdi_line.cx + edge_shift), ui.origsize_mdi_line.cy)
        ui.gcode_options_notebook.set_size_request(
            int(ui.origsize_gcode_options_notebook.cx + edge_shift),
            ui.origsize_gcode_options_notebook.cy)
        btn.ImageButton.move_button(
            ui.gcode_options_button,
            ui.origpos_gcode_options_button.x + edge_shift,
            ui.origpos_gcode_options_button.y)
        self._position_chevron_for_edge_shift(edge_shift)
        self.view_mode = VIEW_MODE_GCODE_ONLY
        self._publish_tormachtips_gremlin_mode("gcode_only")
        if persist:
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
            self._state_write()
        try:
            self._normal_parent().queue_draw()
        except Exception:
            pass

    def _restore_split(self, shift=None, persist=True):
        self._show_all_split_widgets()
        self.view_mode = VIEW_MODE_SPLIT
        self._publish_tormachtips_gremlin_mode("normal")
        if persist:
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
        # "Normal" has one meaning now: the JSON-backed default width.
        if shift is None:
            shift = self._default_split_shift()
        self._apply_shift(shift, persist=False, final=True)
        if persist:
            self._state_write()

    def _show_both_normal(self, menu_item=None):
        if self.gremlin_focus_mode:
            self._exit_gremlin_focus()
        self._restore_split(None, persist=True)

    def _show_gremlin_only(self, menu_item=None):
        if self.gremlin_focus_mode:
            self._exit_gremlin_focus()
        self._apply_gremlin_only(persist=True)

    def _show_gcode_only(self, menu_item=None):
        if self.gremlin_focus_mode:
            self._exit_gremlin_focus()
        self._apply_gcode_only(persist=True)

    def _refresh_preview_overlay_if_needed(self):
        try:
            if not self.ui.preview_image_overlay.get_visible():
                return
        except Exception:
            return
        try:
            if hasattr(self.ui, "load_and_set_m01_image"):
                self.ui.load_and_set_m01_image(self.ui.m01image_pixbuf)
            elif hasattr(self.ui, "load_and_set_m1_image"):
                self.ui.load_and_set_m1_image(self.ui.m01image_pixbuf)
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Patched OEM entry points
    # ------------------------------------------------------------------

    def _toggle_requested(self):
        if self.gremlin_focus_mode:
            self._exit_gremlin_focus()
            return
        # The chevron is now a deterministic "return home" control.
        self._show_both_normal()

    def _update_requested(self):
        # PathPilot can refresh Gremlin geometry during loads/tab changes.
        if self.gremlin_focus_mode:
            self._apply_gremlin_focus_geometry()
            return
        if self.view_mode == VIEW_MODE_GCODE_ONLY:
            self._apply_gcode_only(persist=False)
            return
        if self.view_mode == VIEW_MODE_GREMLIN_ONLY:
            self._apply_gremlin_only(persist=False)
            return
        # Any normal split refresh is forced back to the canonical width.
        self._apply_shift(self._default_split_shift(), persist=False, final=True)

    # ------------------------------------------------------------------
    # Chevron control / custom-width UI
    # ------------------------------------------------------------------

    def _current_gcode_width(self):
        try:
            return int(self.ui.origsize_scrolled_window.cx + self.current_shift)
        except Exception:
            return 0

    def _custom_gcode_width(self):
        try:
            return int(self.ui.origsize_scrolled_window.cx + self.last_nonzero_shift)
        except Exception:
            return 0

    def _min_gcode_width(self):
        return int(self.ui.origsize_scrolled_window.cx + self.min_shift)

    def _max_gcode_width(self):
        return int(self.ui.origsize_scrolled_window.cx + self.max_shift)

    def _width_to_shift(self, width):
        return self._clamp_shift(int(width) - int(self.ui.origsize_scrolled_window.cx))

    def _on_chevron_press(self, widget, event):
        button = 1
        if event is not None:
            try:
                button = int(event.button)
            except Exception:
                button = 1
        if button == 3:
            self.drag_candidate = False
            self.drag_active = False
            self._show_width_menu(widget, event)
            return True
        if button != 1:
            return True
        if self.view_mode != VIEW_MODE_SPLIT:
            self._show_both_normal()
        try:
            btn.ImageButton.shift_button(widget)
        except Exception:
            pass
        try:
            self.press_root_x = float(event.x_root)
        except Exception:
            self.press_root_x = 0.0
        self.press_shift = int(self.current_shift)
        self.pending_shift = int(self.current_shift)
        self.drag_candidate = True
        self.drag_active = False
        self.last_live_resize_time = 0.0
        return True

    def _on_chevron_motion(self, widget, event):
        if not self.drag_candidate:
            return True
        # Ignore stray motion events unless button 1 is still down. GTK's
        # implicit pointer grab normally keeps delivering motion/release even
        # after the moving chevron passes out from under the pointer.
        try:
            if not (int(event.state) & int(gtk.gdk.BUTTON1_MASK)):
                return True
        except Exception:
            pass
        try:
            delta = float(event.x_root) - self.press_root_x
        except Exception:
            return True
        if not self.drag_active:
            if abs(delta) < DRAG_THRESHOLD_PIXELS:
                return True
            self.drag_active = True
        target = self._clamp_shift(self.press_shift + delta)
        self.pending_shift = target
        now = time.time()
        if (
            now - self.last_live_resize_time >=
            RESIZE_INTERVAL_SECONDS
        ):
            self.last_live_resize_time = now
            self._apply_shift(target, persist=False, final=False)
        return True

    def _on_chevron_release(self, widget, event):
        button = 1
        if event is not None:
            try:
                button = int(event.button)
            except Exception:
                button = 1
        if button != 1:
            return True
        try:
            btn.ImageButton.unshift_button(widget)
        except Exception:
            pass
        try:
            if not self.ui.is_button_permitted(widget):
                self.drag_candidate = False
                self.drag_active = False
                self.pending_shift = None
                return True
        except Exception:
            pass
        if self.drag_candidate and self.drag_active:
            target = self.pending_shift
            if target is None:
                target = self.current_shift
            target = self._clamp_shift(target)
            # Dragging is only a temporary preview now. It never silently changes
            # the reboot/restore width; use the one right-click command to commit.
            self._apply_shift(target, persist=False, final=True)
            self._status(
                "Temporary G-code width: %d px. Right-click to make it the default." %
                self._current_gcode_width())
        else:
            # A plain click always returns to the canonical default split.
            self._toggle_requested()
        self.drag_candidate = False
        self.drag_active = False
        self.pending_shift = None
        return True

    def _on_chevron_enter(self, widget, event):
        try:
            if widget.window:
                widget.window.set_cursor(gtk.gdk.Cursor(gtk.gdk.SB_H_DOUBLE_ARROW))
        except Exception:
            pass
        return False

    def _on_chevron_leave(self, widget, event):
        # During a drag keep the horizontal-resize cursor; the implicit button
        # grab can continue sending events even if the handle itself moves.
        if self.drag_active:
            return False
        try:
            if widget.window:
                widget.window.set_cursor(None)
        except Exception:
            pass
        return False

    def _show_width_menu(self, widget, event):
        menu = gtk.Menu()
        current_width = self._current_gcode_width()
        current_item = gtk.MenuItem("Current G-code width: %d px" % current_width)
        current_item.set_sensitive(False)
        menu.append(current_item)
        menu.append(gtk.SeparatorMenuItem())
        focus_item = gtk.MenuItem("Full-Screen 3D Viewer")
        focus_item.connect("activate", self._toggle_gremlin_focus)
        menu.append(focus_item)
        menu.append(gtk.SeparatorMenuItem())
        show_both_item = gtk.MenuItem("Show Both")
        show_both_item.connect("activate", self._show_both_normal)
        menu.append(show_both_item)
        if self.view_mode == VIEW_MODE_GREMLIN_ONLY:
            scroller_item = gtk.MenuItem("Show G-Code Scroller")
            scroller_item.connect("activate", self._show_both_normal)
        else:
            scroller_item = gtk.MenuItem("Hide G-Code Scroller")
            scroller_item.connect("activate", self._show_gremlin_only)
        menu.append(scroller_item)
        if self.view_mode == VIEW_MODE_GCODE_ONLY:
            viewer_item = gtk.MenuItem("Show 3D Viewer")
            viewer_item.connect("activate", self._show_both_normal)
        else:
            viewer_item = gtk.MenuItem("Hide 3D Viewer")
            viewer_item.connect("activate", self._show_gcode_only)
        menu.append(viewer_item)
        menu.append(gtk.SeparatorMenuItem())
        set_default_item = gtk.MenuItem("Set Default Width to Current - %d px" % current_width)
        set_default_item.connect("activate", self._on_set_default_width_to_current)
        menu.append(set_default_item)
        menu.show_all()
        try:
            button = 3 if event is None else int(event.button)
            event_time = gtk.get_current_event_time()
            if event is not None:
                event_time = int(event.time)
            menu.popup(None, None, None, button, event_time)
        except Exception:
            menu.popup(None, None, None, 3, gtk.get_current_event_time())

    def _on_set_default_width_to_current(self, menu_item=None):
        try:
            width = int(self._current_gcode_width())
            shift = self._width_to_shift(width)
            self.default_gcode_width_px = int(self.ui.origsize_scrolled_window.cx + shift)
            self.view_mode = VIEW_MODE_SPLIT
            self._redis_set_text(REDIS_VIEW_MODE_KEY, self.view_mode)
            self._state_write()
            self._status("Default G-code width saved to JSON: %d px." % self.default_gcode_width_px)
        except Exception as exc:
            self._status("Could not save default G-code width: %s" % str(exc), constants.ALARM_LEVEL_LOW)

    def _build_split_gremlin_menu_items(self, gremlin_self):
        items = []
        if self.gremlin_focus_mode:
            focus_item = gtk.MenuItem("Exit Full-Screen 3D Viewer")
        else:
            focus_item = gtk.MenuItem("Full-Screen 3D Viewer")
        focus_item.connect("activate", self._toggle_gremlin_focus)
        items.append(focus_item)
        items.append(gtk.SeparatorMenuItem())
        show_both_item = gtk.MenuItem("Show Both")
        show_both_item.connect("activate", self._show_both_normal)
        items.append(show_both_item)
        if self.view_mode == VIEW_MODE_GREMLIN_ONLY:
            scroller_item = gtk.MenuItem("Show G-Code Scroller")
            scroller_item.connect("activate", self._show_both_normal)
        else:
            scroller_item = gtk.MenuItem("Hide G-Code Scroller")
            scroller_item.connect("activate", self._show_gremlin_only)
        items.append(scroller_item)
        if self.view_mode == VIEW_MODE_GCODE_ONLY:
            viewer_item = gtk.MenuItem("Show 3D Viewer")
            viewer_item.connect("activate", self._show_both_normal)
        else:
            viewer_item = gtk.MenuItem("Hide 3D Viewer")
            viewer_item.connect("activate", self._show_gcode_only)
        items.append(viewer_item)
        return items

    def _build_center_here_gremlin_menu_items(self, gremlin_self):
        item = gtk.MenuItem("Center Here")
        have_point = (
            self._gremlin_context_x is not None and
            self._gremlin_context_y is not None)
        item.set_sensitive(bool(have_point))
        item.connect("activate", self._on_gremlin_center_here)
        return [item]

    def _on_gremlin_center_here(self, menu_item=None):
        try:
            gremlin = self.gremlin or getattr(self.ui, "gremlin", None)
            if gremlin is None:
                return
            if self._gremlin_context_x is None or self._gremlin_context_y is None:
                return
            mouse_x = float(self._gremlin_context_x)
            mouse_y = float(self._gremlin_context_y)
            allocation = gremlin.get_allocation()
            center_x = float(allocation.width) * 0.5
            center_y = float(allocation.height) * 0.5
            # Reuse Gremlin's existing screen-to-view pan math. This is the same
            # operation as dragging the right-clicked point to viewport center.
            old_xmouse = float(getattr(gremlin, "xmouse", mouse_x))
            old_ymouse = float(getattr(gremlin, "ymouse", mouse_y))
            gremlin.recordMouse(mouse_x, mouse_y)
            gremlin.translate(center_x, center_y)
            gremlin.recordMouse(old_xmouse, old_ymouse)
            try:
                gremlin.queue_draw()
            except Exception:
                pass
        except Exception as exc:
            self._status("Gremlin Center Here failed: %s" % str(exc), constants.ALARM_LEVEL_QUIET)

    def _register_shared_gremlin_menu_contributor(self, contributor_key, builder_callback, group=10, order=10):
        gremlin = self.gremlin
        if gremlin is None:
            return False
        registry = getattr(gremlin, "_tormachtips_gremlin_menu_contributors", None)
        if not isinstance(registry, dict):
            registry = {}
            gremlin._tormachtips_gremlin_menu_contributors = registry
        registry[contributor_key] = {"group": int(group), "order": int(order), "builder": builder_callback,}
        # Upgrade any older shared dispatcher in place.  The original OEM menu
        # callable was saved by the first dispatcher installer, so replacing the
        # dispatcher does not stack wrappers or depend on plugin load order.
        dispatcher_version = int(getattr(gremlin, "_tormachtips_gremlin_menu_dispatcher_version", 0) or 0)
        if dispatcher_version >= 2:
            return True
        base_menu = getattr(gremlin, "_tormachtips_gremlin_menu_base", None)
        if base_menu is None:
            base_menu = gremlin.make_and_show_context_menu
            gremlin._tormachtips_gremlin_menu_base = base_menu

        def shared_make_and_show_context_menu(gremlin_self):
            menu = gremlin_self._tormachtips_gremlin_menu_base()
            if menu is None:
                return menu
            menu_registry = getattr(gremlin_self, "_tormachtips_gremlin_menu_contributors", {})
            built_groups = {}
            for key in sorted(menu_registry.keys()):
                try:
                    spec = menu_registry[key]
                    if callable(spec):
                        # Backward compatibility with the first shared-menu
                        # contract used by earlier plugin builds.
                        group_value = 50
                        order_value = 50
                        builder = spec
                    else:
                        group_value = int(spec.get("group", 50))
                        order_value = int(spec.get("order", 50))
                        builder = spec.get("builder")
                    if not callable(builder):
                        continue
                    built = builder(gremlin_self)
                    if built is None:
                        continue
                    if not isinstance(built, (list, tuple)):
                        built = [built]
                    for item_index, item in enumerate(built):
                        if item is None:
                            continue
                        built_groups.setdefault(group_value, []).append((order_value, str(key), item_index, item))
                except Exception:
                    pass
            if not built_groups:
                return menu
            # One separator after all OEM Gremlin items, then one separator
            # between logical TormachTips groups.
            first_group = True
            for group_value in sorted(built_groups.keys()):
                separator = gtk.SeparatorMenuItem()
                menu.append(separator)
                separator.show()
                group_items = built_groups[group_value]
                group_items.sort(key=lambda row: (row[0], row[1], row[2]))
                for _order, _key, _item_index, item in group_items:
                    menu.append(item)
                    item.show()
                first_group = False
            return menu

        try:
            gremlin.make_and_show_context_menu = types.MethodType(
                shared_make_and_show_context_menu,
                gremlin,
                gremlin.__class__)
        except TypeError:
            gremlin.make_and_show_context_menu = types.MethodType(shared_make_and_show_context_menu, gremlin)
        gremlin._tormachtips_gremlin_menu_dispatcher_installed = True
        gremlin._tormachtips_gremlin_menu_dispatcher_version = 2
        return True

    def _install_gremlin_context_menu(self):
        try:
            ui = singletons.g_Machine
            if ui is None or not hasattr(ui, "gremlin") or ui.gremlin is None:
                glib.timeout_add(1000, self._install_gremlin_context_menu)
                return False
            self.gremlin = ui.gremlin
            split_added = self._register_shared_gremlin_menu_contributor(
                GREMLIN_MENU_CONTRIBUTOR_KEY,
                self._build_split_gremlin_menu_items,
                group=10,
                order=10)
            center_added = self._register_shared_gremlin_menu_contributor(
                GREMLIN_CENTER_MENU_KEY,
                self._build_center_here_gremlin_menu_items,
                group=GREMLIN_CENTER_MENU_GROUP,
                order=1000)
            if split_added and center_added:
                self._status("Added split controls and bottom-of-menu Center Here to shared Gremlin right-click menu.")
        except Exception as exc:
            self._status("Gremlin split context-menu hook failed: %s" % str(exc), constants.ALARM_LEVEL_LOW)
        return False

    def _on_use_oem_custom_width(self, menu_item):
        shift = self._clamp_nonzero_shift(self.oem_expand_shift)
        self.last_nonzero_shift = shift
        self._redis_set_int(REDIS_LAST_KEY, shift)
        self._apply_shift(shift, persist=True, final=True)

    def _on_set_custom_width(self, menu_item):
        self._show_custom_width_dialog()

    def _show_custom_width_dialog(self):
        dialog = gtk.Dialog(
            "Set G-code / Gremlin Split",
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
             gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_default_size(430, 170)
        dialog.set_border_width(8)
        box = gtk.VBox(False, 8)
        box.set_border_width(8)
        dialog.vbox.pack_start(box, True, True, 0)
        title = gtk.Label()
        title.set_markup("<b>Custom G-code pane width</b>")
        title.set_alignment(0.0, 0.5)
        box.pack_start(title, False, False, 0)
        note = gtk.Label(
            "Left-clicking the chevron will toggle between Normal and this "
            "custom width. Gremlin receives the remaining space.")
        note.set_alignment(0.0, 0.5)
        note.set_line_wrap(True)
        box.pack_start(note, False, False, 0)
        minimum = self._min_gcode_width()
        maximum = self._max_gcode_width()
        current_custom = self._custom_gcode_width()
        adjustment = gtk.Adjustment(current_custom, minimum, maximum, 1, 10, 0)
        scale = gtk.HScale(adjustment)
        scale.set_digits(0)
        scale.set_value_pos(gtk.POS_RIGHT)
        box.pack_start(scale, False, False, 0)
        value_row = gtk.HBox(False, 8)
        box.pack_start(value_row, False, False, 0)
        range_label = gtk.Label("Allowed: %d - %d px" % (minimum, maximum))
        range_label.set_alignment(0.0, 0.5)
        value_row.pack_start(range_label, True, True, 0)
        spin = gtk.SpinButton(adjustment, 1.0, 0)
        spin.set_numeric(True)
        spin.set_width_chars(6)
        value_row.pack_end(spin, False, False, 0)
        preview_state = {
            "original_shift": self.current_shift,
            "last_applied_width": None,}

        def preview_width(adj):
            try:
                width = int(round(adj.get_value()))
            except Exception:
                return
            if preview_state["last_applied_width"] == width:
                return
            preview_state["last_applied_width"] = width
            self._apply_shift(self._width_to_shift(width), persist=False, final=False)

        adjustment.connect("value-changed", preview_width)
        dialog.show_all()
        response = dialog.run()
        chosen_width = int(round(adjustment.get_value()))
        dialog.destroy()
        if response == gtk.RESPONSE_OK:
            shift = self._clamp_nonzero_shift(self._width_to_shift(chosen_width))
            self.last_nonzero_shift = shift
            self._redis_set_int(REDIS_LAST_KEY, shift)
            self._apply_shift(shift, persist=True, final=True)
            self._status("Custom G-code width set to %d px." % int(self.ui.origsize_scrolled_window.cx + shift))
        else:
            self._apply_shift(preview_state["original_shift"], persist=False, final=True)

DESCRIPTION_LONG = """Adds a deterministic split between the Main-tab G-code
source pane and Gremlin preview. The normal split has one JSON-backed default
G-code width (377 px on first use). Returning from full-screen, one-pane modes,
startup refreshes, or the chevron returns to that same default. Dragging the
chevron is temporary; one right-click command can commit the current width as
the new default. Right-click also provides full-screen and pane visibility
controls. The shared Gremlin menu includes those visibility controls plus the
Center Here command. Separate geometry paths support PathPilot 2.9.1 and the
newer 2.14.x gremlin_fixed layout without editing OEM files."""