# coding=utf-8
# python 2

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#########################################
##                                     ##
##        Cycle Time Tab 0.96          ##
##        www.tormachtips.com          ##
##                                     ##
#########################################

# 0.96 - public beta - 6/05/2026

import os
import re
import pango
import gtk
import time
import glib
import constants
import subprocess
import singletons
from ui_hooks import plugin, version_list

CURRENT_VER           = "0.96"
SCRIPT_NAME           = "Cycle Time Tab"
DESCRIPTION           = "Displays Cycle Time Monitor estimate and actual timing output in a static PathPilot tab."
ENABLED               = 1
DEV_MACHINE           = 1
DEV_MACHINE_FLAG      = "/home/operator/gcode/python/dev_machine.txt"
PAGE_ID               = "tt_cycle_time_tab_fixed"
CYCLE_TIME_TAB_F_KEY  = "F5"
TAB_LABEL_TEXT        = "Cycle Time (%s)" % CYCLE_TIME_TAB_F_KEY
TAB_TITLE_TEXT        = "Tool & Cycle Time Dashboard %s" % CURRENT_VER
TAB_POSITION          = 9
TAB_WIDTH             = 1002
TAB_HEIGHT            = 409
BACKGROUND_COLOR      = "#2b2b2b"
TEXT_REFRESH_MS       = 1000
CLEAR_SNAPSHOT        = 1
STATUS_FILE           = "/home/operator/gcode/python/cycle_time_tab_status.txt"
HISTORY_STATUS_FILE   = "/home/operator/gcode/python/cycle_time_tab_history_status.txt"
SNAPSHOT_DIR          = "/home/operator/gcode/python/cycle_time_snapshots"
HISTORY_EDITOR_FILE   = "/home/operator/gcode/python/cycle_time_history_editor.py"
HISTORY_REVIEW_FILE   = "/home/operator/gcode/python/cycle_time_history_edits_review.txt"
PROGRESS_GREEN_COLOR  = "#4caf50"
PROGRESS_YELLOW_COLOR = "#ffcc00"
PROGRESS_RED_COLOR    = "#f44336"
PROGRESS_GREEN_MARK   = "[TT_PROGRESS_GREEN]"
PROGRESS_YELLOW_MARK  = "[TT_PROGRESS_YELLOW]"
PROGRESS_RED_MARK     = "[TT_PROGRESS_RED]"
HISTORY_EXCLUDED_MARK = "[TT_HISTORY_EXCLUDED]"
ACTIVE_SEGMENT_MARK   = "[TT_ACTIVE_SEGMENT]"

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

    def clear_snapshot_on_startup(self):
        if CLEAR_SNAPSHOT == 0:
            return
        try:
            if os.path.isfile(STATUS_FILE):
                os.remove(STATUS_FILE)
            if os.path.isfile(HISTORY_STATUS_FILE):
                os.remove(HISTORY_STATUS_FILE)                
            if self._text_buffer is not None:
                self.set_status_buffer_text("No cycle time output yet.\n\nLoad a program with Cycle Time Monitor installed to populate this tab.")
            if self._history_text_buffer is not None:
                self.set_history_buffer_text("No history yet.")
            self._status_mtime = None
            self._history_status_mtime = None
        except Exception as e:
            self.error_handler.write("[%s] Could not clear old snapshot: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def start_process(self):
        try:
            ui = singletons.g_Machine
            if ui:
                self.ensure_page(ui)
                self._cycle_time_tab_keyval = self.get_configured_keyval()
                self.bind_cycle_time_tab_key(ui)
                self.register_always_visible_page(ui)
                self.install_notebook_visibility_patch(ui)
                self._connect_notebook_switch_once(ui)
                self._queue_restore_plugin_pages(ui)
                self.clear_snapshot_on_startup()
                self.refresh_text_from_file(force=True)
                glib.timeout_add(TEXT_REFRESH_MS, self.refresh_text_from_file)
                self.error_handler.write("[%s] Runtime cycle time tab initialized. %s bound to Cycle Time tab." % (SCRIPT_NAME, CYCLE_TIME_TAB_F_KEY), constants.ALARM_LEVEL_MEDIUM)
            else:
                self.error_handler.write("[%s] UI not ready yet." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
        except Exception as e:
            self.error_handler.write("[%s] Startup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return False

    def get_configured_keyval(self):
        try:
            key_name = str(CYCLE_TIME_TAB_F_KEY).strip().upper()
            if key_name == "":
                return None
            return getattr(gtk.keysyms, key_name)
        except Exception:
            return None

    def bind_cycle_time_tab_key(self, ui):
        if self._key_handler_id is not None:
            return
        if self._cycle_time_tab_keyval is None:
            self.error_handler.write("[%s] Invalid CYCLE_TIME_TAB_F_KEY setting: %s" % (SCRIPT_NAME, CYCLE_TIME_TAB_F_KEY), constants.ALARM_LEVEL_LOW)
            return
        if not hasattr(ui, "window") or ui.window is None:
            self.error_handler.write("[%s] UI window not available for %s binding." % (SCRIPT_NAME, CYCLE_TIME_TAB_F_KEY), constants.ALARM_LEVEL_LOW)
            return
        self._key_handler_id = ui.window.connect("key-press-event", self.on_key_press)

    def on_key_press(self, widget, event):
        try:
            if self._cycle_time_tab_keyval is not None and event.keyval == self._cycle_time_tab_keyval:
                self.go_to_cycle_time_tab()
                return True
        except Exception as e:
            try:
                self.error_handler.write("[%s] %s handler error: %s" % (SCRIPT_NAME, CYCLE_TIME_TAB_F_KEY, str(e)), constants.ALARM_LEVEL_LOW)
            except Exception:
                pass
        return False

    def go_to_cycle_time_tab(self):
        ui = singletons.g_Machine
        if ui is None:
            return
        if self._page_widget is None:
            self.error_handler.write("[%s] Cycle Time tab page is not built yet." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
            return
        page_num = ui.notebook.page_num(self._page_widget)
        if page_num == -1:
            self.error_handler.write("[%s] Cycle Time tab is not present in notebook." % SCRIPT_NAME, constants.ALARM_LEVEL_LOW)
            return
        ui.notebook.set_current_page(page_num)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def add_title_area(self):
        title = gtk.Label()
        title.set_use_markup(True)
        title.set_markup('<span weight="bold" foreground="white" size="large">%s</span>' % glib.markup_escape_text(TAB_TITLE_TEXT))
        title.set_alignment(0.0, 0.5)
        self.put(title, 28, 10)

        snapshots_button = gtk.Button("Open Snapshots")
        snapshots_button.set_size_request(132, 26)
        snapshots_button.connect("clicked", self.on_open_snapshots)
        self.put(snapshots_button, 655, 8)

        edit_history_button = gtk.Button("Edit History")
        edit_history_button.set_size_request(118, 26)
        edit_history_button.connect("clicked", self.on_edit_history)
        self.put(edit_history_button, 798, 8)

        help_button = gtk.Button("Help")
        help_button.set_size_request(56, 26)
        help_button.connect("clicked", self.on_expected_method_help)
        self.put(help_button, 925, 8)

    def clean_snapshot_filename(self, filename):
        # Must match the monitor plugin snapshot filename sanitizer.
        text = str(filename or "unknown_program.nc")
        text = text.replace("/", "_")
        text = text.replace("\\", "_")
        text = text.replace(":", "-")
        text = text.replace("|", "_")
        text = text.replace("\t", " ")
        text = text.replace("\r", " ")
        text = text.replace("\n", " ")
        return text.strip()

    def get_current_snapshot_program_path(self):
        # The Cycle Time Monitor writes the loaded program path as line 1.
        try:
            text = self.read_status_file()
            for raw_line in text.splitlines():
                line = raw_line.strip()
                if line and line.startswith("/") and os.path.splitext(line)[1].lower() == ".nc":
                    return line
        except Exception as e:
            self.error_handler.write("[%s] Snapshot program lookup error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return ""

    def get_snapshot_files_for_current_program(self):
        program_path = self.get_current_snapshot_program_path()
        if not program_path:
            return "", []
        program_name = os.path.basename(program_path)
        safe_program_name = self.clean_snapshot_filename(program_name)
        prefix = "Completed_%s.cycle_time_" % safe_program_name
        matches = []
        try:
            if not os.path.isdir(SNAPSHOT_DIR):
                return program_name, []
            for name in os.listdir(SNAPSHOT_DIR):
                if name.startswith(prefix) and name.endswith(".txt"):
                    full_path = os.path.join(SNAPSHOT_DIR, name)
                    if os.path.isfile(full_path):
                        matches.append((os.path.getmtime(full_path), name, full_path))
            matches.sort(reverse=True)
        except Exception as e:
            self.error_handler.write("[%s] Snapshot list error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)
        return program_name, matches

    def open_snapshot_file(self, snapshot_path):
        try:
            subprocess.Popen(["xdg-open", snapshot_path])
        except Exception as e:
            self.error_handler.write("[%s] Snapshot open error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def on_edit_history(self, widget):
        try:
            if os.path.isfile(HISTORY_EDITOR_FILE):
                program_path = self.get_current_snapshot_program_path()
                if program_path:
                    subprocess.Popen(["python", HISTORY_EDITOR_FILE, program_path])
                else:
                    subprocess.Popen(["python", HISTORY_EDITOR_FILE])
            else:
                self.error_handler.write("[%s] History editor not found: %s" % (SCRIPT_NAME, HISTORY_EDITOR_FILE), constants.ALARM_LEVEL_LOW)
        except Exception as e:
            self.error_handler.write("[%s] History editor launch error: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_LOW)

    def on_open_snapshots(self, widget):
        parent = None
        try:
            ui = singletons.g_Machine
            if ui is not None and hasattr(ui, "window"):
                parent = ui.window
        except Exception:
            parent = None
        program_name, snapshot_rows = self.get_snapshot_files_for_current_program()
        dialog = gtk.Dialog(
            "Cycle Time Snapshots",
            parent,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ("Open Selected", 1, "Close", gtk.RESPONSE_CLOSE))
        dialog.set_position(gtk.WIN_POS_CENTER_ON_PARENT if parent is not None else gtk.WIN_POS_CENTER)
        dialog.set_keep_above(True)
        dialog.set_default_size(760, 420)
        outer = gtk.VBox(False, 8)
        outer.set_border_width(12)
        if program_name:
            title_text = "Snapshots for %s" % program_name
        else:
            title_text = "No loaded .nc file found in the Cycle Time status file."
        title = gtk.Label()
        title.set_use_markup(True)
        title.set_markup("<b>%s</b>" % glib.markup_escape_text(title_text))
        title.set_alignment(0.0, 0.5)
        outer.pack_start(title, False, False, 0)
        store = gtk.ListStore(str, str)
        for mtime, name, full_path in snapshot_rows:
            display_text = "%s     %s" % (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime)), name)
            store.append([display_text, full_path])
        tree = gtk.TreeView(store)
        tree.set_headers_visible(False)
        renderer = gtk.CellRendererText()
        column = gtk.TreeViewColumn("Snapshot", renderer, text=0)
        tree.append_column(column)

        def open_selected_snapshot(tree_view):
            selection = tree_view.get_selection()
            model, iter_value = selection.get_selected()
            if iter_value is not None:
                self.open_snapshot_file(model.get_value(iter_value, 1))

        def on_snapshot_row_activated(tree_view, path, column):
            open_selected_snapshot(tree_view)

        tree.connect("row-activated", on_snapshot_row_activated)
        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.add(tree)
        outer.pack_start(scroller, True, True, 0)
        if len(snapshot_rows) == 0:
            empty_label = gtk.Label("No completed snapshots found for this loaded file.")
            empty_label.set_alignment(0.0, 0.5)
            outer.pack_start(empty_label, False, False, 0)
        dialog.vbox.pack_start(outer, True, True, 0)
        dialog.show_all()
        response = dialog.run()
        if response == 1:
            open_selected_snapshot(tree)
        dialog.destroy()

    def on_expected_method_help(self, widget):
        # Custom GTK help dialog for the Expected Method text shown beside Updated.
        parent = None
        try:
            ui = singletons.g_Machine
            if ui is not None and hasattr(ui, "window"):
                parent = ui.window
        except Exception:
            parent = None
        dialog = gtk.Dialog(
            "Expected Methods",
            parent,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            ("Close", gtk.RESPONSE_CLOSE))
        dialog.set_position(gtk.WIN_POS_CENTER_ON_PARENT if parent is not None else gtk.WIN_POS_CENTER)
        dialog.set_keep_above(True)
        dialog.set_resizable(False)
        outer = gtk.VBox(False, 10)
        outer.set_border_width(16)
        header_row = gtk.HBox(False, 10)
        icon = gtk.Image()
        try:
            icon.set_from_stock(gtk.STOCK_DIALOG_INFO, gtk.ICON_SIZE_DIALOG)
        except Exception:
            pass
        header_row.pack_start(icon, False, False, 0)
        title_box = gtk.VBox(False, 2)
        title = gtk.Label()
        title.set_use_markup(True)
        title.set_markup('<span weight="bold" size="large">Expected Methods</span>')
        title.set_alignment(0.0, 0.5)
        subtitle = gtk.Label("How time remaining is calculated.")
        subtitle.set_alignment(0.0, 0.5)
        title_box.pack_start(title, False, False, 0)
        title_box.pack_start(subtitle, False, False, 0)
        header_row.pack_start(title_box, True, True, 0)
        outer.pack_start(header_row, False, False, 0)
        body_rows = [
            ("calculated vs elapsed:", 			        "We can display estimated run time (and tool segment time) either by literally calculating the feed rates and distances in the .nc program, or by measuring the average of already-completed runs.\n\nFor instance, G1 F18 X18 will take one minute if starting from X0.\n\nCalculated runtimes are accurate on relatively simple programs, but break down when there are lots of probing moves, G10 resets, multiple WCS, complicated helicals, etc.\n\nAverage (technically, median) runtimes are highly accurate because they reflect specifically how long the machine took to complete the job. Medians are resistant to outliers like bathroom breaks between tool changes, etc.\n\nYour estimated runtimes become more accurate with an increasing number of completed jobs. They are calculated in order of accuracy as follows:"),
            ("calculated estimate:", 			        "No history of completed runs exists (perhaps first run of job), so the script has no choice but to use the G-code time estimator. It calculates G0, G1, G30, arc, dwell, and tool-change timing as accurately as it can."),
            ("70/30 estimate/median blend:", 		    "One completed sample exists. Expected time is 70% calculated estimate and 30% historical median."),
            ("40/60 estimate/median blend:", 		    "Two completed samples exist. Expected time is 40% calculated estimate and 60% historical median."),
            ("historical median:", 			            "Three or more completed samples exist, so expected time uses the median of completed history. This typically offers the most accurate estimate."),
            ("guarded median - estimate disagrees:", 	"A small sample history exists, but those results vary too significantly from the calculated estimate to effectively use one of the above blends.\n\nThis is usually the result of ops with a lot of probing, G10 resets, multiple WCS, complicated helical moves, etc.\n\nSo, the script trusts the collected samples instead, even though the sample size is otherwise small."),
            ("aborted jobs:", 				            "Aborted jobs (ESC or e-stopped, etc) are completely ignored. Programs must fully complete to be counted."),		
            ("median vs mean:",				            "Median is used because it is more resistant to outliers than a mean average.\n\nA single bad run, long pause, ATC issue, probing oddity, or otherwise interrupted workflow can pull a mean average away from normal.\n\nExample: Four runs of a job that takes 5 minutes and a fifth run that includes a 15 coffee break will poison a mean (8 min mean), but is outlier-resistant (5 min median).\n\nGoogle 'mean vs median' if you've forgotten HS or college stats.")]
        body_table = gtk.Table(len(body_rows) * 2, 1, False)
        body_table.set_col_spacings(0)
        body_table.set_row_spacings(3)
        row_index = 0
        for header_text, description_text in body_rows:
            header = gtk.Label()
            header.set_use_markup(True)
            header.set_markup("<b>%s</b>" % glib.markup_escape_text(header_text))
            header.set_alignment(0.0, 0.0)
            body_table.attach(header, 0, 1, row_index, row_index + 1, gtk.FILL, gtk.FILL, 0, 0)
            row_index += 1
            description = gtk.Label(description_text)
            description.set_alignment(0.0, 0.0)
            description.set_line_wrap(True)
            description.set_size_request(585, -1)
            indent = gtk.Alignment(0.0, 0.0, 1.0, 1.0)
            indent.set_padding(0, 6, 24, 0)
            indent.add(description)
            body_table.attach(indent, 0, 1, row_index, row_index + 1, gtk.FILL, gtk.FILL, 0, 0)
            row_index += 1
        body_pad = gtk.Alignment(0.0, 0.0, 1.0, 1.0)
        body_pad.set_padding(6, 6, 10, 10)
        body_pad.add(body_table)
        body_scroller = gtk.ScrolledWindow()
        body_scroller.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
        body_scroller.set_shadow_type(gtk.SHADOW_NONE)
        body_scroller.set_size_request(640, 430)
        body_scroller.add_with_viewport(body_pad)
        outer.pack_start(body_scroller, True, True, 0)
        dialog.vbox.pack_start(outer, True, True, 0)
        dialog.set_default_response(gtk.RESPONSE_CLOSE)
        dialog.show_all()
        try:
            dialog.run()
        finally:
            dialog.destroy()

    def add_text_area(self):
        self._text_buffer = gtk.TextBuffer()
        self._text_buffer.create_tag("active_segment", background="#ffe066", foreground="#000000")
        self._text_buffer.create_tag("progress_green", foreground=PROGRESS_GREEN_COLOR)
        self._text_buffer.create_tag("progress_yellow", foreground=PROGRESS_YELLOW_COLOR)
        self._text_buffer.create_tag("progress_red", foreground=PROGRESS_RED_COLOR)
        self.set_status_buffer_text("No cycle time output yet.\n\nLoad a program with Cycle Time Monitor installed to populate this tab.")

        text_view = gtk.TextView(self._text_buffer)
        text_view.set_editable(False)
        text_view.set_cursor_visible(False)
        text_view.set_wrap_mode(gtk.WRAP_NONE)
        text_view.set_left_margin(10)
        text_view.set_right_margin(10)
        text_view.set_pixels_above_lines(2)
        text_view.set_pixels_below_lines(2)
        text_view.modify_font(pango.FontDescription("Monospace 10"))

        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.add(text_view)
        scroller.set_size_request(737, 352)
        self.put(scroller, 8, 42)

        self._history_text_buffer = gtk.TextBuffer()
        self._history_text_buffer.create_tag("history_excluded", strikethrough=True)
        self.set_history_buffer_text("No history yet.")

        history_view = gtk.TextView(self._history_text_buffer)
        history_view.set_editable(False)
        history_view.set_cursor_visible(False)
        history_view.set_wrap_mode(gtk.WRAP_NONE)
        history_view.set_justification(gtk.JUSTIFY_RIGHT)
        history_view.set_left_margin(6)
        history_view.set_right_margin(2)
        history_view.set_pixels_above_lines(2)
        history_view.set_pixels_below_lines(2)
        history_view.modify_font(pango.FontDescription("Monospace 10"))

        history_scroller = gtk.ScrolledWindow()
        history_scroller.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
        history_scroller.add(history_view)
        history_scroller.set_size_request(245, 352)
        self.put(history_scroller, 750, 42)

    def set_status_buffer_text(self, text):
        if self._text_buffer is None:
            return
        self._text_buffer.set_text("")
        for raw_line in text.splitlines(True):
            active_segment = False
            line = raw_line
            if ACTIVE_SEGMENT_MARK in line:
                line = line.replace(ACTIVE_SEGMENT_MARK, "")
                active_segment = True
            color_tag = ""
            if PROGRESS_GREEN_MARK in line:
                line = line.replace(PROGRESS_GREEN_MARK, "")
                color_tag = "progress_green"
            elif PROGRESS_YELLOW_MARK in line:
                line = line.replace(PROGRESS_YELLOW_MARK, "")
                color_tag = "progress_yellow"
            elif PROGRESS_RED_MARK in line:
                line = line.replace(PROGRESS_RED_MARK, "")
                color_tag = "progress_red"
            line_start_offset = self._text_buffer.get_char_count()
            self._text_buffer.insert(self._text_buffer.get_end_iter(), line)
            line_end_offset = self._text_buffer.get_char_count()
            if active_segment:
                start_iter = self._text_buffer.get_iter_at_offset(line_start_offset)
                end_iter = self._text_buffer.get_iter_at_offset(line_end_offset)
                self._text_buffer.apply_tag_by_name("active_segment", start_iter, end_iter)
            if color_tag:
                start_iter = self._text_buffer.get_iter_at_offset(line_start_offset)
                end_iter = self._text_buffer.get_iter_at_offset(line_end_offset)
                self._text_buffer.apply_tag_by_name(color_tag, start_iter, end_iter)

    def set_history_buffer_text(self, text):
        # The monitor writes a plain-text marker so the tab can strikethrough excluded runs.
        if self._history_text_buffer is None:
            return
        self._history_text_buffer.set_text("")
        for raw_line in text.splitlines(True):
            excluded = False
            line = raw_line
            if HISTORY_EXCLUDED_MARK in line:
                line = line.replace(HISTORY_EXCLUDED_MARK, "")
                excluded = True
            line_start_offset = self._history_text_buffer.get_char_count()
            self._history_text_buffer.insert(self._history_text_buffer.get_end_iter(), line)
            if excluded:
                start_iter = self._history_text_buffer.get_iter_at_offset(line_start_offset)
                end_iter = self._history_text_buffer.get_iter_at_offset(self._history_text_buffer.get_char_count())
                self._history_text_buffer.apply_tag_by_name("history_excluded", start_iter, end_iter)

    def read_status_file(self):
        if not os.path.isfile(STATUS_FILE):
            return "No cycle time output yet.\n\nCycle Time Monitor has not published a summary file yet."
        f = open(STATUS_FILE, "r")
        try:
            return f.read()
        finally:
            f.close()

    def read_history_status_file(self):
        if not os.path.isfile(HISTORY_STATUS_FILE):
            return "No history yet."
        f = open(HISTORY_STATUS_FILE, "r")
        try:
            return f.read()
        finally:
            f.close()            

    def refresh_text_from_file(self, force=False):
        try:
            try:
                mtime = os.path.getmtime(STATUS_FILE)
            except Exception:
                mtime = None
            try:
                history_mtime = os.path.getmtime(HISTORY_STATUS_FILE)
            except Exception:
                history_mtime = None
            if force == False and mtime == self._status_mtime and history_mtime == self._history_status_mtime:
                return True
            text = self.read_status_file()
            history_text = self.read_history_status_file()
            if self._text_buffer is not None:
                self.set_status_buffer_text(text)
            if self._history_text_buffer is not None:
                self.set_history_buffer_text(history_text)
            self._status_mtime = mtime
            self._history_status_mtime = history_mtime
        except Exception as e:
            try:
                if self._text_buffer is not None:
                    self.set_status_buffer_text("Cycle Time Tab read error:\n%s" % str(e))
            except Exception:
                pass
        return True

DESCRIPTION_LONG = """<font face="Verdana" size="2"><b>CYCLE TIME TAB</b></font>
<p><font face="Verdana" size="2">Displays Cycle Time Monitor estimates and actual timing results in a dedicated PathPilot tab.</font></p>"""