# 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

#############################################
##                                         ##
##       Status Window Search 0.96         ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 0.96 - removed keyboard shortcuts (interferes with oem commands) - 4/21/2026
# 0.95 - fix runaway memory buffer - 4/20/2026
# 0.94 - refactor for safer signal management / status-only hotkeys - 4/20/2026
# 0.93 - public release - 4/19/26

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

CURRENT_VER        = "0.96"
SCRIPT_NAME        = "Status Window Search Box"
DESCRIPTION        = "Status Window Search Box"
ENABLED            = 1
DEV_MACHINE        = 1
DEV_MACHINE_FLAG   = "/home/operator/gcode/python/dev_machine.txt"
WIDTH_X            = 660
HEIGHT_Y           = 350
X_OFFSET           = 5
Y_OFFSET           = 52
SEARCH_BAR_X       = 15
SEARCH_BAR_Y       = 8
STATUS_PAGE_NAME   = "alarms_fixed"
PLACEHOLDER_TEXT   = "Search"
MAX_STATUS_LINES   = 1200   # Keep only the newest N lines in the diagnostics buffer.
PRUNE_TO_LINES     = 1000   # After trimming, leave this many lines.
SEARCH_DEBOUNCE_MS = 180    # Delay before re-running search while typing.

class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, DESCRIPTION)
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled:
            self.init_vars()
            glib.timeout_add(1200, self.delayed_init)
            return
        else:
            if dev_machine_found:
                self.error_handler.write("[%s] Dev machine found. Plugin loaded, but disabled by DEV_MACHINE." % DESCRIPTION, constants.ALARM_LEVEL_QUIET)
            else:
                self.error_handler.write("[%s] Plugin loaded, but disabled." % DESCRIPTION, constants.ALARM_LEVEL_QUIET)
                self.error_handler.write("[%s] To enable, open script, find ENABLED = 0 and change to ENABLED = 1" % DESCRIPTION, constants.ALARM_LEVEL_QUIET)
            return

    def init_vars(self):
        self._search_timer_id = None
        self._prune_busy = False
        self._last_buffer_line_count = 0        
        self._patched_error_handler = False
        self._focus_retry_count = 0
        self._focus_retry_max = 6
        self._placeholder_text = PLACEHOLDER_TEXT
        self._placeholder_active = False
        self._suppress_placeholder_once = False
        self._buffer_dirty = False
        self._installed = False
        self._installing = False
        self._init_complete = False
        self._diag_width = WIDTH_X
        self._diag_height = HEIGHT_Y
        self._diag_x = X_OFFSET
        self._diag_y = Y_OFFSET
        self._status_page = None
        self._diag_sw = None
        self.entry = None
        self.textview = None
        self.buffer = None
        self.match_tag = None
        self.btn_prev = None
        self.btn_next = None
        self.btn_clear = None
        self.check_case = None
        self.current_match = -1
        self.matches = []
        self.case_sensitive = False
        self._buffer_changed_handler_id = None
        self._notebook_switch_handler_id = None
        self._window_keypress_handler_id = None

    def schedule_search(self, reset_index=True):
        if self._search_timer_id is not None:
            try:
                glib.source_remove(self._search_timer_id)
            except Exception:
                pass
            self._search_timer_id = None
        self._search_timer_id = glib.timeout_add(
            SEARCH_DEBOUNCE_MS,
            self._run_scheduled_search,
            reset_index)

    def _run_scheduled_search(self, reset_index):
        self._search_timer_id = None
        self.perform_search(reset_index=reset_index)
        return False

    def prune_status_buffer_if_needed(self):
        if self.buffer is None:
            return
        if self._prune_busy:
            return
        start_iter = self.buffer.get_start_iter()
        end_iter = self.buffer.get_end_iter()
        line_count = end_iter.get_line() + 1
        self._last_buffer_line_count = line_count
        if line_count <= MAX_STATUS_LINES:
            return
        trim_lines = line_count - PRUNE_TO_LINES
        if trim_lines <= 0:
            return
        try:
            self._prune_busy = True
            trim_iter = self.buffer.get_iter_at_line(trim_lines)
            start_iter = self.buffer.get_start_iter()
            self.buffer.delete(start_iter, trim_iter)
            self.clear_highlight_only()
            self._buffer_dirty = True
            self.current_match = -1
            self.matches = []
        except Exception as e:
            self.error_handler.write(
                "[%s] Buffer prune failed: %s" % (DESCRIPTION, str(e)),
                constants.ALARM_LEVEL_LOW)
        finally:
            self._prune_busy = False

    def shutdown(self):
        self.disconnect_signals()

    def disconnect_signals(self):
        self._disconnect_buffer_signal()
        self._disconnect_notebook_signal()
        self._disconnect_window_signal()
        if self._search_timer_id is not None:
            try:
                glib.source_remove(self._search_timer_id)
            except Exception:
                pass
            self._search_timer_id = None        

    def _disconnect_buffer_signal(self):
        if self.buffer is not None and self._buffer_changed_handler_id is not None:
            try:
                self.buffer.disconnect(self._buffer_changed_handler_id)
            except Exception:
                pass
            self._buffer_changed_handler_id = None

    def _disconnect_notebook_signal(self):
        ui = getattr(singletons, "g_Machine", None)
        notebook = getattr(ui, "notebook", None)
        if notebook is not None and self._notebook_switch_handler_id is not None:
            try:
                notebook.disconnect(self._notebook_switch_handler_id)
            except Exception:
                pass
            self._notebook_switch_handler_id = None

    def _disconnect_window_signal(self):
        ui = getattr(singletons, "g_Machine", None)
        window = getattr(ui, "window", None)
        if window is not None and self._window_keypress_handler_id is not None:
            try:
                window.disconnect(self._window_keypress_handler_id)
            except Exception:
                pass
            self._window_keypress_handler_id = None

    def patch_error_handler_grow_diag_window(self, eh):
        if eh is None:
            return
        if self._patched_error_handler:
            return

        def custom_grow_diag_window(error_handler_self):
            error_handler_self.diag_scrolledwindow.set_size_request(WIDTH_X, HEIGHT_Y)
        eh._grow_diag_window = types.MethodType(custom_grow_diag_window, eh)
        self._patched_error_handler = True

    def delayed_init(self):
        try:
            ui = getattr(singletons, "g_Machine", None)
            if ui is None or not hasattr(ui, "error_handler"):
                return True
            eh = ui.error_handler
            self.patch_error_handler_grow_diag_window(eh)
            if not hasattr(eh, "diagnostics_textview") or not eh.diagnostics_textview:
                self.error_handler.write("[%s] diagnostics_textview not ready yet." % DESCRIPTION,constants.ALARM_LEVEL_LOW)
                return True
            self.textview = eh.diagnostics_textview
            self.buffer = self.textview.get_buffer()
            self.prune_status_buffer_if_needed()            
            self._diag_sw = getattr(eh, "diag_scrolledwindow", None)
            self._connect_buffer_changed_once()
            self._find_status_page(ui)
            self._connect_notebook_switch_once(ui)
            if self._status_page is None:
                self.error_handler.write("[%s] Could not find %s page." % (DESCRIPTION, STATUS_PAGE_NAME),constants.ALARM_LEVEL_LOW)
                return False
            current_page_num = ui.notebook.get_current_page()
            current_page = ui.notebook.get_nth_page(current_page_num)
            current_page_id = gtk.Buildable.get_name(current_page)
            if current_page_id == STATUS_PAGE_NAME and not self._installed and not self._installing:
                self._installing = True
                glib.idle_add(self.install_search_bar)
            if not self._init_complete:
                self.error_handler.write("[%s] v%s loaded. Open Status (F1) to activate search bar." % (DESCRIPTION, CURRENT_VER),constants.ALARM_LEVEL_MEDIUM)
                self._init_complete = True
        except Exception as e:
            self.error_handler.write("[%s] Init error: %s" % (DESCRIPTION, str(e)),constants.ALARM_LEVEL_LOW)
        return False

    def _find_status_page(self, ui):
        self._status_page = None
        try:
            for i in range(ui.notebook.get_n_pages()):
                page = ui.notebook.get_nth_page(i)
                page_id = gtk.Buildable.get_name(page)
                if page_id == STATUS_PAGE_NAME:
                    self._status_page = page
                    return
        except Exception:
            self._status_page = None

    def _connect_buffer_changed_once(self):
        if self.buffer is None:
            return
        if self._buffer_changed_handler_id is None:
            self._buffer_changed_handler_id = self.buffer.connect("changed", self.on_buffer_changed)

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

    def _is_status_page_active(self):
        ui = getattr(singletons, "g_Machine", None)
        if ui is None or not hasattr(ui, "notebook"):
            return False
        try:
            page_num = ui.notebook.get_current_page()
            page = ui.notebook.get_nth_page(page_num)
            page_id = gtk.Buildable.get_name(page)
            return page_id == STATUS_PAGE_NAME
        except Exception:
            return False

    def on_switch_page(self, notebook, page, page_num):
        try:
            page = notebook.get_nth_page(page_num)
            page_id = gtk.Buildable.get_name(page)
        except Exception:
            return
        if page_id != STATUS_PAGE_NAME:
            return
        if not self._installed and not self._installing:
            self._installing = True
            glib.idle_add(self.install_search_bar)
            return
        if self._installed:
            self.apply_diag_layout()
            glib.idle_add(self.apply_diag_layout)

    def set_placeholder(self):
        if self.entry is None:
            return
        if self.entry.get_text() == "":
            self._placeholder_active = True
            self.entry.modify_text(gtk.STATE_NORMAL, gtk.gdk.color_parse("#777777"))
            self.entry.set_text(self._placeholder_text)

    def clear_placeholder(self):
        if self.entry is None:
            return
        if self._placeholder_active:
            self._placeholder_active = False
            self.entry.set_text("")
            self.entry.modify_text(gtk.STATE_NORMAL, None)

    def _entry_text_for_search(self):
        if self.entry is None:
            return ""
        if self._placeholder_active:
            return ""
        return self.entry.get_text().strip()

    def focus_search_entry(self):
        if self.entry is None:
            return False
        self._suppress_placeholder_once = True
        self.clear_placeholder()
        self.entry.grab_focus()
        self.entry.set_position(-1)
        self._focus_retry_count = 0
        glib.timeout_add(75, self.focus_search_entry_retry)
        glib.timeout_add(500, self.release_placeholder_suppression)
        return False

    def focus_search_entry_retry(self):
        if self.entry is None:
            return False
        if self.entry.is_focus():
            return False
        self._focus_retry_count += 1
        self.clear_placeholder()
        self.entry.grab_focus()
        self.entry.set_position(-1)
        if self._focus_retry_count >= self._focus_retry_max:
            return False
        return True

    def release_placeholder_suppression(self):
        self._suppress_placeholder_once = False
        if self.entry is not None:
            if self.entry.is_focus():
                return False
            if self.entry.get_text().strip() == "":
                self.set_placeholder()
        return False

    def on_entry_focus_in(self, widget, event):
        self.clear_placeholder()
        return False

    def on_entry_focus_out(self, widget, event):
        if self._suppress_placeholder_once:
            return False
        if self.entry is not None and self.entry.get_text().strip() == "":
            self.set_placeholder()
        return False

    def install_search_bar(self):
        try:
            if self._installed:
                self._installing = False
                return False
            if self.buffer is None or self._status_page is None:
                self._installing = False
                return False
            hbox = gtk.HBox(spacing=5)
            hbox.set_size_request(-1, 34)
            self.entry = gtk.Entry()
            self.entry.set_size_request(260, -1)
            self.entry.set_can_focus(True)
            self.btn_prev = gtk.Button()
            self.btn_next = gtk.Button()
            self.btn_clear = gtk.Button()
            self.check_case = gtk.CheckButton("Aa")
            lbl_prev = gtk.Label("Prev")
            lbl_next = gtk.Label("Next")
            lbl_clear = gtk.Label("Clear")
            self.btn_prev.add(lbl_prev)
            self.btn_next.add(lbl_next)
            self.btn_clear.add(lbl_clear)
            self.check_case.set_tooltip_text("Case sensitive")
            for b in (self.btn_prev, self.btn_next, self.btn_clear, self.check_case):
                b.set_can_focus(True)
            for b in (self.btn_prev, self.btn_next, self.btn_clear):
                b.set_size_request(68, -1)
            self.entry.connect("changed", self.on_entry_changed)
            self.entry.connect("activate", self.on_search_activate)
            self.entry.connect("focus-in-event", self.on_entry_focus_in)
            self.entry.connect("focus-out-event", self.on_entry_focus_out)
            self.btn_prev.connect("clicked", self.on_prev)
            self.btn_next.connect("clicked", self.on_next)
            self.btn_clear.connect("clicked", self.on_clear)            
            self.check_case.connect("toggled", self.on_case_toggled)
            hbox.pack_start(self.entry, False, False, 0)
            hbox.pack_start(self.btn_prev, False, False)
            hbox.pack_start(self.btn_next, False, False)
            hbox.pack_start(self.btn_clear, False, False)
            hbox.pack_start(self.check_case, False, False, 6)
            if self.match_tag is None:
                self.match_tag = self.buffer.create_tag(
                    "search_match",
                    background="#ff9900",
                    foreground="#000000")
            if isinstance(self._status_page, gtk.Fixed):
                self._status_page.put(hbox, SEARCH_BAR_X, SEARCH_BAR_Y)
                self.apply_diag_layout()
                glib.idle_add(self.apply_diag_layout)
            else:
                self.error_handler.write("[%s] Status page is not gtk.Fixed; search bar not placed." % DESCRIPTION,constants.ALARM_LEVEL_LOW)
                self._installing = False
                return False
            hbox.show_all()
            self._installed = True
            self.set_placeholder()
            self.error_handler.write("[%s] Search bar added to Status tab." % DESCRIPTION,constants.ALARM_LEVEL_MEDIUM)
        except Exception as e:
            self.error_handler.write("[%s] Install failed: %s" % (DESCRIPTION, str(e)),constants.ALARM_LEVEL_LOW)
        finally:
            self._installing = False
        return False

    def on_buffer_changed(self, widget):
        if self._prune_busy:
            return
        self._buffer_dirty = True
        self.prune_status_buffer_if_needed()

    def on_case_toggled(self, widget):
        self.case_sensitive = widget.get_active()
        self.schedule_search(reset_index=True)

    def on_entry_changed(self, widget):
        self.schedule_search(reset_index=True)

    def on_search_activate(self, widget):
        self.on_next()

    def perform_search(self, reset_index=False):
        if self.buffer is None or self.entry is None:
            return
        if self._search_timer_id is not None:
            self._search_timer_id = None            
        query = self._entry_text_for_search()
        self.clear_highlight_only()
        if query == "":
            self.matches = []
            self.current_match = -1
            self._buffer_dirty = False
            return
        self.prune_status_buffer_if_needed()
        full_text = self.buffer.get_text(
            self.buffer.get_start_iter(),
            self.buffer.get_end_iter(),
            False)
        if self.case_sensitive:
            search_text = full_text
            search_query = query
        else:
            search_text = full_text.lower()
            search_query = query.lower()
        self.matches = []
        pos = 0
        while True:
            pos = search_text.find(search_query, pos)
            if pos == -1:
                break
            self.matches.append(pos)
            pos += len(search_query)
        if len(self.matches) == 0:
            self.current_match = -1
            self._buffer_dirty = False
            return
        if reset_index:
            self.current_match = 0
        else:
            if self.current_match < 0:
                self.current_match = 0
            elif self.current_match >= len(self.matches):
                self.current_match = len(self.matches) - 1
        self._buffer_dirty = False
        self.highlight_current()

    def clear_highlight_only(self):
        if self.match_tag is not None and self.buffer is not None:
            self.buffer.remove_tag(
                self.match_tag,
                self.buffer.get_start_iter(),
                self.buffer.get_end_iter())

    def clear_highlights(self):
        self.clear_highlight_only()
        self.current_match = -1
        self.matches = []

    def highlight_current(self):
        if self.buffer is None or self.textview is None or self.entry is None:
            return
        if len(self.matches) == 0 or self.current_match < 0:
            return
        query = self._entry_text_for_search()
        if query == "":
            return
        try:
            offset = self.matches[self.current_match]
            length = len(query)
            start = self.buffer.get_iter_at_offset(offset)
            end = self.buffer.get_iter_at_offset(offset + length)
        except Exception:
            self._buffer_dirty = True
            return
        self.clear_highlight_only()
        self.buffer.apply_tag(self.match_tag, start, end)
        self.textview.scroll_to_iter(start, 0.0, True, 0.5, 0.5)

    def on_next(self, widget=None):
        if self.entry is None:
            return
        query = self._entry_text_for_search()
        if query == "":
            return
        if self._buffer_dirty:
            old_index = self.current_match
            self.perform_search(reset_index=True)
            if len(self.matches) == 0:
                return
            if old_index >= 0 and len(self.matches) > 1:
                self.current_match = old_index % len(self.matches)
        elif len(self.matches) == 0:
            self.perform_search(reset_index=True)
            return
        self.current_match = (self.current_match + 1) % len(self.matches)
        self.highlight_current()

    def on_prev(self, widget=None):
        if self.entry is None:
            return
        query = self._entry_text_for_search()
        if query == "":
            return
        if self._buffer_dirty:
            old_index = self.current_match
            self.perform_search(reset_index=True)
            if len(self.matches) == 0:
                return
            if old_index >= 0 and len(self.matches) > 1:
                self.current_match = old_index % len(self.matches)
        elif len(self.matches) == 0:
            self.perform_search(reset_index=True)
            return
        self.current_match = (self.current_match - 1) % len(self.matches)
        self.highlight_current()

    def on_clear(self, widget=None):
        if self.entry is not None:
            self._placeholder_active = False
            self.entry.modify_text(gtk.STATE_NORMAL, None)
            self.entry.set_text("")
        self.clear_highlights()
        self.set_placeholder()

    def apply_diag_layout(self):
        if self._status_page is None:
            return False
        if self._diag_sw is None:
            return False
        if self._diag_sw.get_parent() is self._status_page:
            self._status_page.move(self._diag_sw, self._diag_x, self._diag_y)
        self._diag_sw.set_size_request(self._diag_width, self._diag_height)
        self._diag_sw.queue_resize()
        return False
        
DESCRIPTION_LONG = """Provides a search bar in the status window.</font></p>
    <p>
    <a href="images/search.box.png">
    <img border="2" src="images/search.box_small.png" xthumbnail-orig-image="images/search.box.png" width="200" height="150"></a>"""        