From 58331c79ca1b284e752f0ffe2c196b8d4ab29888 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:35:42 +0800 Subject: [PATCH 1/2] touchy: fit the window to the monitor Touchy has no scrolling, so when content is larger than the display the window grows past the screen edge and controls become unreachable. The worst offender is structural: a GtkNotebook sizes to its largest page, so the small visible page (the buttons) was forced as wide as the hidden Preferences page, and the tall tool-table listing grew the window on reload. - Wrap each notebook page in a scroller, so the notebook sizes to the current page and oversized pages (Preferences, a long tool table) scroll instead of growing the window. This alone keeps the height in check and cuts the width from 1346 to 1094 px. - Wrap the whole window in a scroller and bound it to the monitor work area, so it can never exceed the screen. - Hide the handwheel column on the Preferences tab (you are not jogging there) so the wide settings page gets the full width. - When the content still does not fit a narrow screen, float a non-modal info bar over the content offering to shrink the fonts: Shrink to fit and save / Not now / Never ask again. Accepting scales to the largest fitting size, rounds to whole points, saves them, and updates the pickers to match. It is only shown when it does not fit and never repeated once it fits; Not now is remembered per screen size (fit_skip_size) so it is not offered again until the screen changes, and Never ask again (fit_fonts) disables it entirely. - Add an 'Offer to shrink fonts to fit the screen' checkbox to Preferences / Display Options to re-enable the offer after Never ask again, and document the behaviour. --- docs/src/gui/touchy.adoc | 21 +++ src/emc/usr_intf/touchy/touchy.glade | 23 ++- src/emc/usr_intf/touchy/touchy.py | 231 ++++++++++++++++++++++++++- 3 files changed, 271 insertions(+), 4 deletions(-) diff --git a/docs/src/gui/touchy.adoc b/docs/src/gui/touchy.adoc index 3370252ee6d..de647a00db4 100644 --- a/docs/src/gui/touchy.adoc +++ b/docs/src/gui/touchy.adoc @@ -96,6 +96,27 @@ cut off, reduce the DPI setting. All other font sizes can be changed on the Preferences tab. +=== Fitting the screen + +Touchy bounds its window to the monitor and scrolls any tab whose content +is larger than the screen, so the window never grows past the display +edge (for example after loading a large tool table). The handwheel column +is hidden on the Preferences tab so the settings have the full width. + +If the interface is still too large for a small screen, Touchy offers, +once, to shrink the display fonts to fit: + +* *Shrink to fit and save* scales the fonts to the largest size that fits + and saves them as your preference (the font selectors update to match). +* *Not now* leaves the fonts unchanged and does not ask again for this + screen size; it is offered again only if you move to a smaller screen. +* *Never ask again* disables the offer on every screen. + +To turn the offer back on, tick *Offer to shrink fonts to fit the screen* +in Preferences / Display Options, or set `fit_fonts = ask` in +`~/.touchy_preferences` (the *Never ask again* choice stores +`fit_fonts = never`). + === Macros Touchy can invoke O-word macros using the MDI interface. To configure diff --git a/src/emc/usr_intf/touchy/touchy.glade b/src/emc/usr_intf/touchy/touchy.glade index 41f4b78128a..7c0ce6a7395 100644 --- a/src/emc/usr_intf/touchy/touchy.glade +++ b/src/emc/usr_intf/touchy/touchy.glade @@ -3173,7 +3173,7 @@ F1 S1 True False - 5 + 6 3 @@ -3402,6 +3402,27 @@ F1 S1 10 + + + Offer to shrink fonts to fit the screen + True + False + False + False + True + + + + 0 + 3 + 5 + 6 + GTK_FILL + + 10 + 6 + + diff --git a/src/emc/usr_intf/touchy/touchy.py b/src/emc/usr_intf/touchy/touchy.py index f7e98bab651..7fa82bd5f05 100755 --- a/src/emc/usr_intf/touchy/touchy.py +++ b/src/emc/usr_intf/touchy/touchy.py @@ -119,6 +119,9 @@ def __init__(self, inifile): self.wTree.get_object('MainWindow').set_can_focus(True) self.wTree.get_object('MainWindow').grab_focus() + # Stop the window growing past the screen (see method). + self._constrain_to_monitor() + self.num_mdi_labels = 11 self.num_filechooser_labels = 11 self.num_listing_labels = 20 @@ -148,6 +151,13 @@ def __init__(self, inifile): self.err_textcolor = self.prefs.getpref('err_textcolor', 'default', str) self.window_geometry = self.prefs.getpref('window_geometry', 'default', str) self.window_max = self.prefs.getpref('window_force_max', 'false', bool) + self.fit_fonts = self.prefs.getpref('fit_fonts', 'ask', str) + self.fit_skip_size = self.prefs.getpref('fit_skip_size', '', str) + check = self.wTree.get_object("fitfontscheck") + if check: + self._setting_fit_check = True + check.set_active(self.fit_fonts != 'never') + self._setting_fit_check = False # initial screen setup if os.path.exists(themedir): @@ -347,6 +357,7 @@ def __init__(self, inifile): "on_dro_mm_clicked" : self.dro_mm, "on_errorfontbutton_font_set" : self.change_error_font, "on_listingfontbutton_font_set" : self.change_listing_font, + "on_fitfontscheck_toggled" : self.fit_fonts_toggled, "on_estop_clicked" : self.linuxcnc.estop, "on_estop_reset_clicked" : self.linuxcnc.estop_reset, "on_machine_off_clicked" : self.linuxcnc.machine_off, @@ -439,9 +450,15 @@ def quit(self, unused): def tabselect(self, notebook, b, tab): - # new_tab=notebook.get_nth_page(tab) - # old_tab=notebook.get_nth_page(self.tab) self.tab = tab + # The handwheel is for jogging, not setup: hide it on the + # Preferences tab so the wide settings page gets the full width. + wheel = self.wTree.get_object("wheel") + if wheel is not None and getattr(self, "_prefs_index", -1) >= 0: + if tab == self._prefs_index: + wheel.hide() + else: + wheel.show() # for c in self._dynamic_childs: # if new_tab.__gtype__.name =='GtkSocket': # w= new_tab.get_plug_window() @@ -628,6 +645,21 @@ def change_listing_font(self, fontbutton): self.listing_font = Pango.FontDescription(self.listing_font_name) self.setfont() + def fit_fonts_toggled(self, button): + # Re-enable or disable the offer to shrink the fonts to fit a + # small screen. Checking it also forgets any per-screen decline + # so the offer can appear again. + if getattr(self, "_setting_fit_check", False): + return + if button.get_active(): + self.fit_fonts = 'ask' + self.prefs.putpref('fit_fonts', 'ask', str) + self.fit_skip_size = '' + self.prefs.putpref('fit_skip_size', '', str) + else: + self.fit_fonts = 'never' + self.prefs.putpref('fit_fonts', 'never', str) + def change_theme(self, b): tree_iter = b.get_active_iter() if tree_iter is not None: @@ -639,6 +671,198 @@ def change_theme(self, b): settings = Gtk.Settings.get_default() settings.set_string_property("gtk-theme-name", theme, "") + def _wrap_notebook_pages(self): + # A notebook sizes to its widest/tallest page, so the small visible + # page (e.g. the buttons) is forced as large as the hidden settings + # page. Wrap each page in a scroller so the notebook sizes to the + # current page; oversized pages scroll instead of growing the window. + nb = self.wTree.get_object("notebook1") + if nb is None: + return + self._prefs_index = -1 + for i in range(nb.get_n_pages()): + page = nb.get_nth_page(i) + if isinstance(page, (Gtk.ScrolledWindow, Gtk.Socket)): + continue + if (nb.get_tab_label_text(page) or "").strip() == "Preferences": + self._prefs_index = i + label = nb.get_tab_label(page) + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + scroller.set_min_content_width(0) + scroller.set_min_content_height(0) + nb.remove_page(i) + scroller.add(page) + viewport = scroller.get_child() + if isinstance(viewport, Gtk.Viewport): + viewport.set_shadow_type(Gtk.ShadowType.NONE) + scroller.show_all() + nb.insert_page(scroller, label, i) + + def _constrain_to_monitor(self): + # Wrap the content in a scroller so the window can be bounded to the + # monitor; touchy has no scrolling otherwise and grows past the screen. + try: + self._wrap_notebook_pages() + win = self.wTree.get_object("MainWindow") + child = win.get_child() + if child is None or isinstance(child, Gtk.ScrolledWindow): + return + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.AUTOMATIC, + Gtk.PolicyType.AUTOMATIC) + win.remove(child) + scroller.add(child) + viewport = scroller.get_child() + if isinstance(viewport, Gtk.Viewport): + viewport.set_shadow_type(Gtk.ShadowType.NONE) + # Float the fit offer over the content (no modal pop-up, and it + # does not add to the window's minimum size). + overlay = Gtk.Overlay() + overlay.add(scroller) + self._infobar = self._build_fit_infobar() + self._infobar.set_halign(Gtk.Align.FILL) + self._infobar.set_valign(Gtk.Align.START) + overlay.add_overlay(self._infobar) + win.add(overlay) + overlay.show_all() + self._infobar.hide() + # Fit on map, once the monitor is known. + self._fitted = False + win.connect("map-event", self._fit_to_monitor, scroller, child) + except Exception: + pass + + def _build_fit_infobar(self): + bar = Gtk.InfoBar() + bar.set_message_type(Gtk.MessageType.QUESTION) + label = Gtk.Label(label=_("The interface is larger than this screen.")) + bar.get_content_area().add(label) + bar.add_button(_("Shrink to fit and save"), 3) + bar.add_button(_("Not now"), 1) + bar.add_button(_("Never ask again"), 2) + bar.connect("response", self._fit_infobar_response) + return bar + + def _fit_to_monitor(self, win, event, scroller, child): + if self._fitted: + return False + self._fitted = True + # Bound the window to the work area by sizing the scroller to it + # (no max-size hint, so the window keeps its normal controls). + # Page wrapping keeps the height in check; only the position readout + # plus handwheel can exceed a narrow screen's width, and the scroller + # handles that until (and unless) the user opts to shrink. + try: + display = win.get_display() + gdkwin = win.get_window() + if gdkwin is not None: + monitor = display.get_monitor_at_window(gdkwin) + else: + monitor = display.get_primary_monitor() + if monitor is None: + monitor = display.get_monitor(0) + area = monitor.get_workarea() + scroller.set_min_content_width(area.width) + scroller.set_max_content_width(area.width) + scroller.set_min_content_height(area.height) + scroller.set_max_content_height(area.height) + GLib.idle_add(self._offer_fit, child, area) + except Exception: + pass + return False + + def _offer_fit(self, child, area): + # Offer to shrink only when it does not fit and the user has not + # opted out or already declined for this screen size; a smaller + # screen is a new situation and is offered again. + try: + size = child.get_preferred_size()[0] + if size.width <= area.width and size.height <= area.height: + return False + if self.fit_fonts == 'never': + return False + screen = "%dx%d" % (area.width, area.height) + if self.fit_skip_size == screen: + return False + self._fit_child = child + self._fit_area = area + self._fit_screen = screen + self._infobar.show() + except Exception: + pass + return False + + def _fit_infobar_response(self, infobar, response): + infobar.hide() + try: + if response == 3: + self.prefs.putpref('fit_skip_size', '', str) + self.fit_skip_size = '' + self._fit_floor = max(1, int(self.control_font.get_size() * 0.5)) + GLib.timeout_add(60, self._fit_pass, self._fit_child, self._fit_area) + elif response == 1: + # Not now: do not offer again until the screen changes. + self.prefs.putpref('fit_skip_size', self._fit_screen, str) + self.fit_skip_size = self._fit_screen + elif response == 2: + self.prefs.putpref('fit_fonts', 'never', str) + self.fit_fonts = 'never' + except Exception: + pass + + def _fit_pass(self, child, area): + # One shrink step per relayout; stop on the first fit so the result + # is the largest font that fits, then save the new fonts. + try: + size = child.get_preferred_size()[0] + fits = size.width <= area.width and size.height <= area.height + at_floor = self.control_font.get_size() <= self._fit_floor + if not fits and not at_floor: + factor = max(min(area.width / float(size.width), + area.height / float(size.height)) * 0.99, 0.90) + self._scale_fonts(factor) + child.queue_resize() + GLib.timeout_add(60, self._fit_pass, child, area) + return False + self._persist_fonts() + except Exception: + pass + return False + + def _persist_fonts(self): + # Round down to whole points (keeps the fit), save as the new + # preference, and show the values in the pickers so the displayed + # sizes match what is drawn. + for name, fd, button in ( + ('control_font', self.control_font, 'controlfontbutton'), + ('dro_font', self.dro_font, 'drofontbutton'), + ('error_font', self.error_font, 'errorfontbutton'), + ('listing_font', self.listing_font, 'listingfontbutton')): + points = fd.get_size() // Pango.SCALE + if points > 0: + fd.set_size(points * Pango.SCALE) + text = fd.to_string() + self.prefs.putpref(name, text, str) + widget = self.wTree.get_object(button) + if widget: + widget.set_font(text) + self.control_font_name = self.control_font.to_string() + self.dro_font_name = self.dro_font.to_string() + self.error_font_name = self.error_font.to_string() + self.listing_font_name = self.listing_font.to_string() + self.setfont() + + def _scale_fonts(self, factor): + # Scale every display font in place; the saved prefs are untouched. + for fd in (self.control_font, self.dro_font, + self.error_font, self.listing_font): + size = fd.get_size() + if size > 0: + fd.set_size(max(1, int(size * factor))) + self.setfont() + def setfont(self): # buttons for i in ["1", "2", "3", "4", "5", "6", "7", @@ -657,7 +881,8 @@ def setfont(self): "dro_commanded", "dro_actual", "dro_inch", "dro_mm", "reload_tooltable", "opstop_on", "opstop_off", "blockdel_on", "blockdel_off", "pointer_hide", "pointer_show", - "toolset_workpiece", "toolset_fixture","change_theme"]: + "toolset_workpiece", "toolset_fixture","change_theme", + "fitfontscheck"]: w = self.wTree.get_object(i) if w: w.override_font(self.control_font) From 2c61f67aafb67529c46b01f5e396fbcd34e1d8f4 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:41:41 +0800 Subject: [PATCH 2/2] touchy: fix resize, startup tab, drag-to-scroll, listing click error - Window is now resizable smaller than screen (scroller max-content bounds the top end; min stays 0). - Startup tab restored correctly after page-wrapping loop shifted it. - Each page scroller accepts drag-to-pan (finger or mouse, 8px threshold so button taps are unaffected). - Fix pre-existing TypeError in listing.on_select: button_release_event passes (widget, event) but signature accepted only one argument. --- src/emc/usr_intf/touchy/listing.py | 2 +- src/emc/usr_intf/touchy/touchy.py | 54 ++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/emc/usr_intf/touchy/listing.py b/src/emc/usr_intf/touchy/listing.py index 9510934daa6..a1b5d955b4c 100644 --- a/src/emc/usr_intf/touchy/listing.py +++ b/src/emc/usr_intf/touchy/listing.py @@ -103,7 +103,7 @@ def next(self,b,count=1): break self.show_line(self.start_line) - def on_select(self, b): + def on_select(self, b, *args): pass def clear_startline(self): diff --git a/src/emc/usr_intf/touchy/touchy.py b/src/emc/usr_intf/touchy/touchy.py index 7fa82bd5f05..82a9a4aa173 100755 --- a/src/emc/usr_intf/touchy/touchy.py +++ b/src/emc/usr_intf/touchy/touchy.py @@ -679,6 +679,9 @@ def _wrap_notebook_pages(self): nb = self.wTree.get_object("notebook1") if nb is None: return + # Removing/reinserting pages drifts the selection, so remember the + # startup page and restore it after wrapping. + current = nb.get_current_page() self._prefs_index = -1 for i in range(nb.get_n_pages()): page = nb.get_nth_page(i) @@ -697,8 +700,49 @@ def _wrap_notebook_pages(self): viewport = scroller.get_child() if isinstance(viewport, Gtk.Viewport): viewport.set_shadow_type(Gtk.ShadowType.NONE) + self._enable_drag_scroll(scroller) scroller.show_all() nb.insert_page(scroller, label, i) + if current >= 0: + nb.set_current_page(current) + + def _enable_drag_scroll(self, scroller): + # Touchy is a touchscreen UI, so let the user drag the content to + # scroll it (finger or mouse), not just the wheel/scrollbar. A drag + # gesture pans the adjustments once movement passes a small + # threshold; shorter presses fall through so buttons still click. + try: + gesture = Gtk.GestureDrag.new(scroller) + except Exception: + return + gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE) + if not hasattr(self, "_drag_gestures"): + self._drag_gestures = [] + self._drag_gestures.append(gesture) + start = {} + + def on_begin(g, x, y): + va = scroller.get_vadjustment() + ha = scroller.get_hadjustment() + start["v"] = va.get_value() if va is not None else 0 + start["h"] = ha.get_value() if ha is not None else 0 + start["active"] = False + + def on_update(g, off_x, off_y): + if not start.get("active"): + if abs(off_x) < 8 and abs(off_y) < 8: + return + start["active"] = True + g.set_state(Gtk.EventSequenceState.CLAIMED) + va = scroller.get_vadjustment() + ha = scroller.get_hadjustment() + if va is not None: + va.set_value(start["v"] - off_y) + if ha is not None: + ha.set_value(start["h"] - off_x) + + gesture.connect("drag-begin", on_begin) + gesture.connect("drag-update", on_update) def _constrain_to_monitor(self): # Wrap the content in a scroller so the window can be bounded to the @@ -764,10 +808,16 @@ def _fit_to_monitor(self, win, event, scroller, child): if monitor is None: monitor = display.get_monitor(0) area = monitor.get_workarea() - scroller.set_min_content_width(area.width) + # Bound the scroller (and thus the window) to the work area so + # oversized content scrolls instead of growing off-screen, but + # leave the minimum at 0 so the user can still resize smaller. scroller.set_max_content_width(area.width) - scroller.set_min_content_height(area.height) scroller.set_max_content_height(area.height) + # Open at the content size, capped to the screen; this avoids an + # off-screen window without forcing a screen-sized minimum. + nat = child.get_preferred_size()[1] + win.resize(min(nat.width, area.width), + min(nat.height, area.height)) GLib.idle_add(self._offer_fit, child, area) except Exception: pass