# vim:et sts=4 sw=4 # # ibus-typing-booster - A completion input method for IBus # # Copyright (c) 2025 Mike FABIAN # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see '''GTK 3/4 Compatibility Helpers A collection of helper functions and classes to ease the transition from Gtk3 to Gtk4. This helper module should not call gi.require_version() for both Gtk, Pango, ... Instead, let the main application decide which version to load. This helper module should work with whichever version is loaded. ''' from typing import ( List, Tuple, Iterable, Optional, Callable, Any, cast, overload, TYPE_CHECKING, ) import html import logging import gettext from enum import Enum, auto from gi import require_version require_version('GLib', '2.0') require_version('Gio', '2.0') # pylint: disable=wrong-import-position from gi.repository import GLib # type: ignore from gi.repository import Gio # type: ignore from gi.repository import GdkPixbuf # type: ignore from gi.repository import Pango # type: ignore from itb_gtk import Gdk, Gtk, GTK_MAJOR, GTK_VERSION # type: ignore # pylint: enable=wrong-import-position if TYPE_CHECKING: # These imports are only for type checkers (mypy). They must not be # executed at runtime because itb_gtk controls the Gtk/Gdk versions. # pylint: disable=reimported from gi.repository import Gtk, Gdk # type: ignore # pylint: enable=reimported ResponseCallback = Callable[[], None] ClickCallback = Callable[[Gtk.Widget, int, Any], None] ReleaseCallback = Callable[[Gtk.Widget, int, Any], None] LongPressCallback = Callable[[Gtk.Widget, Any], None] LOGGER = logging.getLogger('ibus-typing-booster') DOMAINNAME = 'ibus-typing-booster' def _(text: str) -> str: '''Gettext translation function.''' return gettext.dgettext(DOMAINNAME, text) def N_(text: str) -> str: # pylint: disable=invalid-name '''Mark string for translation without actually translating. Used by gettext tools to extract strings that need translation. ''' return text def is_wayland() -> bool: '''Test whether Wayland is used''' display = Gdk.Display.get_default() # pylint: disable=no-value-for-parameter return display is not None and "wayland" in display.get_name().lower() def add_child(widget: Gtk.Widget, child: Gtk.Widget) -> None: '''Gtk3/4 compatible child insertion.''' if GTK_MAJOR >=4: # Gtk.Window, Gtk.Button, Gtk.Frame, Gtk.ScrolledWindow, etc. if hasattr(widget, 'set_child'): widget.set_child(child) # Gtk.Box elif hasattr(widget, 'append'): widget.append(child) else: raise TypeError(f'Don’t know how to add child to {type(widget).__name__}') else: # Gtk3 if hasattr(widget, 'add'): widget.add(child) elif hasattr(widget, 'pack_start'): widget.pack_start(child, True, True, 0) else: raise TypeError(f'Don’t know how to add child to {type(widget).__name__}') def set_border_width(widget: Gtk.Widget, width: int) -> None: '''Gtk3/4 compatible border width setting''' if GTK_MAJOR >=4: # Use padding instead of border-width for blank space around the widget: css = f'* {{ padding: {width}px; }}' provider = Gtk.CssProvider() provider.load_from_data(css, len(css)) # Gtk4 wants string widget.get_style_context().add_provider( provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) else: widget.set_border_width(width) def set_label_wrap_mode(label: Gtk.Label, wrap: bool = True, wrap_mode: Pango.WrapMode = Pango.WrapMode.WORD) -> None: '''Set wrapping and wrap mode on a Gtk.Label, Gtk3/ Gtk4 compatible.''' if hasattr(label, 'set_wrap'): # Gtk4 label.set_wrap(wrap) elif hasattr(label, 'set_line_wrap'): # Gtk3 label.set_line_wrap(wrap) else: try: label.set_property('wrap', wrap) except Exception: # pylint: disable=broad-except pass if hasattr(label, 'set_wrap_mode'): # Gtk4 label.set_wrap_mode(wrap_mode) elif hasattr(label, 'set_line_wrap_mode'): # Gtk3 label.set_line_wrap_mode(wrap_mode) else: try: label.set_property('wrap-mode', wrap_mode) except Exception: # pylint: disable=broad-except pass def clear_children(widget: Gtk.Widget) -> None: '''Remove all child widgets from `widget`, in a way that works for both Gtk3 and Gtk4. ''' # Gtk3: containers have .get_children() and .remove() if hasattr(widget, 'get_children') and hasattr(widget, 'remove'): for child in widget.get_children(): widget.remove(child) return # Gtk4: single-child containers use set_child() if hasattr(widget, 'get_child') and hasattr(widget, 'set_child'): child = widget.get_child() if child is not None: widget.set_child(None) return # Gtk4: multi-child containers without get_children() use first/next sibling if hasattr(widget, 'get_first_child') and hasattr(widget, 'get_next_sibling'): child = widget.get_first_child() while child is not None: next_child = child.get_next_sibling() child.unparent() child = next_child return # Fallback — nothing to do return def children_of(widget: Gtk.Widget) -> List[Gtk.Widget]: '''Return iterable of logical children for Gtk3/Gtk4 (internal helper).''' if hasattr(widget, 'get_children'): # Gtk3 containers try: return list(widget.get_children()) except Exception: # pylint: disable=broad-except pass # Gtk4 single-child widgets / scrolled windows if hasattr(widget, 'get_child'): try: child = widget.get_child() # Unwrap viewport if present if child is not None and isinstance(child, Gtk.Viewport): inner = child.get_child() return [inner] if inner is not None else [] return [child] if child is not None else [] except Exception: # pylint: disable=broad-except pass # Gtk4 multi-child sibling API if hasattr(widget, 'get_first_child') and hasattr(widget, 'get_next_sibling'): kids = [] child = widget.get_first_child() while child is not None: kids.append(child) child = child.get_next_sibling() return kids return [] def show_all(widget: Optional[Gtk.Widget]) -> None: '''Make `widget` and all of its children visible (Gtk3-compatible show_all). Works on both Gtk3 and Gtk4. ''' if widget is None: return # If Gtk3 provides show_all, prefer it (fast, well-tested) if hasattr(widget, 'show_all'): try: widget.show_all() return except Exception: # pylint: disable=broad-except # fall through to manual recursion pass # Set this widget visible if possible if hasattr(widget, 'set_visible'): try: widget.set_visible(True) except Exception: # pylint: disable=broad-except pass # Recurse into children for child in children_of(widget): show_all(child) def icon_image_for_name(icon_name: str, icon_size: int = 48) -> Gtk.Image: '''Return a Gtk.Image for icon_name sized approximately `icon_size`. Works on both Gtk3 and Gtk4 by trying multiple fallbacks. ''' # Try to load a pixbuf via IconTheme (Gtk3-friendly). try: theme = Gtk.IconTheme.get_default() # pylint: disable=no-value-for-parameter if theme is not None and hasattr(theme, 'load_icon'): pix = theme.load_icon(icon_name, icon_size, 0) if pix is not None: return Gtk.Image.new_from_pixbuf(pix) except Exception: # pylint: disable=broad-except pass try: # Try Gtk4 style first (no size argument) img = Gtk.Image.new_from_icon_name(icon_name) # Try to set pixel size on the Gtk.Image if available (Gtk4) if hasattr(img, 'set_pixel_size'): try: img.set_pixel_size(icon_size) except Exception: # pylint: disable=broad-except pass return img except Exception: # pylint: disable=broad-except pass try: # Gtk3 style with explicit IconSize constant img = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON) return img except Exception: # pylint: disable=broad-except pass return Gtk.Image() # Last resort: return an empty Image (no icon) def image_from_file_scaled(path: str, size: int) -> Gtk.Widget: '''Load an image from a file and scale it''' pixbuf = GdkPixbuf.Pixbuf.new_from_file(path) if GTK_MAJOR >=4: # GTK4: use GtkPicture, which actually scales, GtkImage does not! texture = Gdk.Texture.new_for_pixbuf(pixbuf) # pylint: disable=c-extension-no-member pic = Gtk.Picture.new_for_paintable(texture) # pylint: disable=c-extension-no-member # or FIT pic.set_content_fit(Gtk.ContentFit.SCALE_DOWN) # pylint: disable=c-extension-no-member pic.set_size_request(size, size) return pic pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR) return Gtk.Image.new_from_pixbuf(pixbuf) def set_scrolled_shadow(widget: Gtk.ScrolledWindow, enable: bool = True) -> None: '''Add or remove css class `scrolled-shadow` to or from a scrolled window''' ctx = widget.get_style_context() if enable: ctx.add_class('scrolled-shadow') else: ctx.remove_class('scrolled-shadow') def combobox_set_active( combobox: Gtk.ComboBox, liststore: Gtk.ListStore, index: Optional[int], ) -> None: '''Set the active index of a combo box with a list store (GTK 3/4 compatible). If `index` is `None`, unset the active item.''' if index is None: # Unset the active item combobox.set_active_iter(None) return if GTK_MAJOR >=4: liststore_iter = liststore.get_iter((index,)) combobox.set_active_iter(liststore_iter) return combobox.set_active(index) def get_toplevel_window(widget: Gtk.Widget) -> Gtk.Window: '''Get the top-level window of a widget (GTK 3/4 compatible)''' if GTK_MAJOR >=4: return widget.get_root() return widget.get_toplevel() def connect_focus_signal(widget: Gtk.Widget, callback: Callable[..., Any]) -> None: ''' Connects a callback to the appropriate focus signal for a widget, automatically handling differences between GTK3 and GTK4. In Gtk4, the 'notify::has-focus' signal is used to detect when a widget receives focus. In Gtk3, the 'grab-focus' signal is used for the same purpose. :param widget: The widget to which the focus signal should be connected. :param callback: The callback function to be called when the widget receives focus. The callback should accept the widget and a GParamSpec parameter in GTK4. :return: None ''' if GTK_MAJOR >= 4: widget.connect('notify::has-focus', callback) else: widget.connect('grab-focus', callback) def grab_focus_without_selecting(widget: Gtk.Widget) -> None: ''' Grabs focus for a widget without selecting its contents. Works for both Gtk.Entry and Gtk.SpinButton in Gtk3 and Gtk4. :param widget: The widget to grab focus (Gtk.Entry or Gtk.SpinButton). ''' if hasattr(widget, 'grab_focus_without_selecting'): # Gtk3 widget.grab_focus_without_selecting() else: # Gtk4 widget.grab_focus() if isinstance(widget, Gtk.Entry): widget.select_region(0, 0) # For Gtk.SpinButton in GTK4, there is no select_region method, # so we don't need to do anything else. def set_entry_text_preserve_cursor(entry: Gtk.Entry, new_text: str) -> None: '''Set the text of a Gtk.Entry while preserving the cursor position (Gtk 3/4 compatible)''' cursor_pos = entry.get_position() entry.set_text(new_text) if cursor_pos <= len(new_text): entry.set_position(cursor_pos) return entry.set_position(-1) def get_window_size(window: Gtk.Window) -> Tuple[int, int]: '''Get the size of a window (Gtk 3/4 compatible)''' if GTK_MAJOR >=4: return (window.get_width(), window.get_height()) return cast(Tuple[int, int], window.get_size()) def get_treeview_context( treeview: Gtk.TreeView, x: int, y: int, keyboard_tip: bool, ) -> Tuple[bool, Optional[Gtk.TreeModel], Optional[Gtk.TreePath], Optional[Gtk.TreeIter]]: ''' Unified GTK3/Gtk4 wrapper around TreeView.get_tooltip_context() Returns: (ok, model, path, iter) Works with: • Gtk3 (returns 6-tuple in PyGObject) • Gtk4 <= 4.10 (returns 5-tuple) • Gtk4 >= 4.12 (returns 4-tuple) ''' result = treeview.get_tooltip_context(x, y, keyboard_tip) if not result: return False, None, None, None if GTK_MAJOR < 4: # Gtk3 → always: is_row, x, y, model, path, iter is_row, _x, _y, model, path, itr = result if not is_row: return False, None, None, None return True, model, path, itr # GTK 4.x messy variants length = len(result) # Variant A: (is_row, x, y, iter) # Seen in some GTK 4.10+ builds if length == 4 and isinstance(result[3], Gtk.TreeIter): is_row, _x, _y, itr = result if not is_row: return False, None, None, None model = treeview.get_model() if model is None: return False, None, None, None path = model.get_path(itr) if path is None: return False, None, None, None return True, model, path, itr # Variant B: (is_row, x, y, path) if length == 4 and isinstance(result[3], Gtk.TreePath): is_row, _x, _y, path = result if not is_row: return False, None, None, None model = treeview.get_model() if model is None or path is None: return False, None, None, None itr = model.get_iter(path) if itr is None: return False, None, None, None return True, model, path, itr # Variant C: (is_row, x, y, model, path) if length == 5: is_row, _x, _y, model, path = result if not is_row or model is None or path is None: return False, None, None, None itr = model.get_iter(path) if itr is None: return False, None, None, None return True, model, path, itr # Unexpected format return False, None, None, None @overload def connect_fast_click( # GTK 3: 'clicked' → callback(button) button: Gtk.Widget, callback: Callable[[Gtk.Button], Any], ) -> None: ... @overload def connect_fast_click( # GTK 4: 'pressed' → callback(gesture, n_press, x, y) button: Gtk.Widget, callback: Callable[['Gtk.GestureClick', int, float, float], Any], # pylint: disable=c-extension-no-member ) -> None: ... def connect_fast_click( button: Gtk.Widget, callback: Callable[..., Any], ) -> None: ''' Connect a 'fast click' handler that preserves Gtk3/Gtk4 signal signatures. Unified implementation, the ovrload above are for precise type checking. Gtk 3: Connects to 'clicked', which emits (button) Gtk 4: Creates a GtkGestureClick, connects to 'pressed', which emits (gesture, n_press, x, y) The callback signature is preserved exactly, and type checkers will infer the correct signature depending on use. ''' if GTK_MAJOR >=4: gesture = Gtk.GestureClick() # pylint: disable=c-extension-no-member gesture.set_button(0) gesture.connect( 'pressed', callback, # type checkers understand the expected signature ) button.add_controller(gesture) else: # The 'clicked' signal passes a Gtk.Button instance button.connect( 'clicked', callback, # type checkers know this expects (button) ) def set_monospace(label: Gtk.Label) -> None: '''Make label use a monospace font (Gtk3 + Gtk4 compatible).''' font = Pango.FontDescription('Monospace') if GTK_MAJOR < 4: label.modify_font(font) else: attr = Pango.attr_font_desc_new(font) attrs = Pango.AttrList() attrs.insert(attr) label.set_attributes(attrs) def _add_details_section(parent_box: Gtk.Box, details_text: str) -> None: ''' Adds an expander containing details_text (monospace, scrollable) and a copy-to-clipboard button. Works in Gtk3 and Gtk4. ''' if not details_text: return # Horizontal box: [▶ Details] [Copy] header_box = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 6) add_child(parent_box, header_box) header_box.set_halign(Gtk.Align.FILL) header_box.set_hexpand(True) expander = Gtk.Expander(label='Details') expander.set_expanded(False) add_child(header_box, expander) spacer = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) add_child(header_box, spacer) spacer.set_hexpand(True) copy_btn = Gtk.Button(label='Copy') copy_btn.set_tooltip_text('Copy details to clipboard') add_child(header_box, copy_btn) def _on_copy_clicked(_: Gtk.Button) -> None: if GTK_MAJOR < 4: clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(details_text, -1) else: display = Gdk.Display.get_default() # pylint: disable=no-value-for-parameter clipboard = display.get_clipboard() clipboard.set_text(details_text) copy_btn.connect('clicked', _on_copy_clicked) scroll = Gtk.ScrolledWindow() scroll.set_hexpand(True) scroll.set_vexpand(True) label = Gtk.Label() label.set_xalign(0) label.set_yalign(0) label.set_selectable(True) label.set_use_markup(False) label.set_text(details_text) label.set_margin_top(4) label.set_margin_bottom(4) label.set_margin_start(4) label.set_margin_end(4) label.set_justify(Gtk.Justification.LEFT) label.set_max_width_chars(80) set_label_wrap_mode(label, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR) set_monospace(label) add_child(scroll, label) add_child(expander, scroll) def get_preferred_width(widget: Gtk.Widget) -> Tuple[int, int]: ''' Return (minimum, natural) width of a widget in a Gtk3/Gtk4 compatible way. ''' if GTK_MAJOR < 4: return tuple(widget.get_preferred_width()) min_size, nat_size, _, _ = widget.measure(Gtk.Orientation.HORIZONTAL, -1) return min_size, nat_size def get_preferred_height(widget: Gtk.Widget) -> Tuple[int, int]: ''' Return (minimum, natural) height of a widget in a Gtk3/Gtk4 compatible way. ''' if GTK_MAJOR < 4: return tuple(widget.get_preferred_height()) min_size, nat_size, _, _ = widget.measure(Gtk.Orientation.VERTICAL, -1) return min_size, nat_size def forward_key_event_to_entry(event_key: 'Gdk.EventKey', entry: 'Gtk.Entry') -> bool: ''' Forward a Gdk.EventKey into a Gtk.Entry (or Gtk.SearchEntry) in a way compatible with both GTK3 and GTK4. Handles: - character insertion - Backspace - Delete - Left/Right/Home/End cursor movement ''' keyval = event_key.keyval text = event_key.string or '' pos = entry.get_position() # Special keys if keyval == Gdk.KEY_BackSpace: if pos > 0: entry.delete_text(pos - 1, pos) entry.set_position(pos - 1) return True if keyval == Gdk.KEY_Delete: entry.delete_text(pos, pos + 1) entry.set_position(pos) return True if keyval == Gdk.KEY_Left: if pos > 0: entry.set_position(pos - 1) return True if keyval == Gdk.KEY_Right: if pos < len(entry.get_text()): entry.set_position(pos + 1) return True if keyval == Gdk.KEY_Home: entry.set_position(0) return True if keyval == Gdk.KEY_End: entry.set_position(len(entry.get_text())) return True if text: # Normal text insertion: entry.insert_text(text, pos) entry.set_position(pos + len(text)) return True return False # Let GTK handle anything else class PopupKind(Enum): '''Enumerate the different possible types of popovers''' EMOJI_SELECTED = auto() SKIN_TONE = auto() EMOJI_INFO = auto() FONT_SELECTION = auto() MAIN_MENU = auto() ADD_DICTIONARY = auto() ADD_INPUT_METHOD = auto() INPUT_METHOD_OPTIONS = auto() ADD_AUTOSETTING = auto() class PopupManager: ''' Manages Gtk.Popover lifetimes safely across GTK3/GTK4. Tracks which kind of popup is currently active. ''' def __init__(self) -> None: self._current: Optional[Gtk.Popover] = None self._kind: Optional[PopupKind] = None def popup(self, popover: Gtk.Popover, kind: PopupKind) -> None: ''' Safely popup a popover after. If an old popover exists, pop it down first. This avoids GTK4 grab violations ('Tried to map a grabbing popup...') and works correctly on Wayland. ''' old = self._current self._current = popover self._kind = kind if old is not None: self._popdown(old) def _popup_later() -> bool: if self._current is not popover: return False if GTK_VERSION >= (3, 22, 0): popover.popup() show_all(popover) return False GLib.idle_add(_popup_later) def popdown_current(self, kind: Optional[PopupKind] = None) -> None: '''Pops down the current popover if it is of the specified kind If kind is None, popdown the current popover no matter what kind it is. ''' if kind is not None and self._kind != kind: return if self._current is not None: self._popdown(self._current) self._current = None self._kind = None def _popdown(self, popover: Gtk.Popover) -> None: # pylint: disable=no-self-use if GTK_VERSION >= (3, 22, 0): popover.popdown() popover.set_visible(False) def active_popup(self) -> Optional[PopupKind]: '''Returns the kind of the currently active popup, or None.''' return self._kind def is_active(self, kind: PopupKind) -> bool: '''Returns True if the given popup kind is currently active.''' return self._kind == kind def create_popover( pointing_to: Gtk.Widget, position: Optional['Gtk.PositionType'] = None, ) -> Gtk.Popover: ''' Create a Gtk.Popover correctly for both GTK3 and GTK4. - Gtk3: uses set_relative_to() - Gtk4: sets toplevel parent + pointing rectangle via compute_bounds() This helper avoids common Gtk4 pitfalls such as: - non-topmost parent warnings - grabbing popup freezes (Wayland) ''' popover = Gtk.Popover() if GTK_MAJOR >= 4: toplevel = pointing_to.get_root() if toplevel is None: raise RuntimeError( 'create_popover(): pointing_to widget has no root yet') popover.set_parent(toplevel) ok, grect = pointing_to.compute_bounds(toplevel) if ok: gdk_rect = Gdk.Rectangle() gdk_rect.x = int(grect.origin.x) gdk_rect.y = int(grect.origin.y) gdk_rect.width = int(grect.size.width) gdk_rect.height = int(grect.size.height) popover.set_pointing_to(gdk_rect) else: popover.set_relative_to(pointing_to) if position is not None: popover.set_position(position) return popover def clickable_event_box_compat_get_gtk_label( event_box: 'ClickableEventBoxCompat') -> Gtk.Label: '''Returns the Gtk.Label child of a ClickableEventBoxCompat''' if GTK_MAJOR < 4: return event_box.get_child() return event_box.get_first_child() def flowbox_get_children(flowbox: Gtk.FlowBox) -> List[Gtk.FlowBoxChild]: '''Returns a list of flowbox children''' if hasattr(flowbox, "get_children"): # GTK3 return list(flowbox.get_children()) return list(children_of(flowbox)) def emoji_flowbox_get_labels(flowbox: Gtk.FlowBox) -> List[Gtk.Label]: '''Returns a list of (emoji)-labels in a Gtk.FlowBox as used in emoji_picker.py Compatible with both Gtk3 and Gtk4. ''' if hasattr(flowbox, 'get_children'): # Gtk3 # FlowBoxChild -> EventBox -> Label return [child.get_child().get_child() for child in flowbox.get_children()] # Gtk4: FlowBoxChild -> Box -> Label return [child.get_child().get_first_child() for child in children_of(flowbox)] class ClickableEventBoxCompat(Gtk.Box if GTK_MAJOR >= 4 else Gtk.EventBox): # type: ignore[misc] ''' A compatibility wrapper that provides EventBox-like clickable behavior in both Gtk3 and Gtk4, including unified signals: - 'clicked' → callback(widget, button) - 'released' → callback(widget, button) - 'long-pressed' → callback(widget) All differences between Gtk3 and Gtk4 event handling are hidden here. ''' def __init__(self) -> None: if GTK_MAJOR < 4: # Gtk3 mode (Gtk.EventBox) super().__init__() self.set_above_child(True) self.set_can_focus(False) self.set_visible_window(True) self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK) else: # Gtk4 mode (Gtk.Box) super().__init__(orientation=Gtk.Orientation.HORIZONTAL) self.set_sensitive(True) self.set_can_focus(False) self.set_focusable(False) # pylint: disable=no-member click = Gtk.GestureClick() # pylint: disable=c-extension-no-member click.set_button(0) # accept all buttons click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) click.connect('pressed', self._on_click_pressed) click.connect('released', self._on_click_released) self.add_controller(click) # pylint: disable=no-member longpress = Gtk.GestureLongPress() # pylint: disable=c-extension-no-member longpress.set_touch_only(False) longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) longpress.connect('pressed', self._on_longpress_gtk4) self.add_controller(longpress) # pylint: disable=no-member # Unified signal slots self._click_handlers: List[Tuple[ClickCallback, Tuple[Any, ...]]] = [] self._release_handlers: List[Tuple[ReleaseCallback, Tuple[Any, ...]]] = [] self._longpress_handlers: List[Tuple[LongPressCallback, Tuple[Any, ...]]] = [] # Internal long-press tracking (Gtk3) self._gtk3_longpress_timer: Optional[int] = None self._gtk3_button_down: bool = False # Unified 'connect' interface def connect( # pylint: disable=arguments-differ self, signal_name: str, callback: Callable[..., Any], *user_data: Any, ) -> int: ''' A unified connect() that works for all 3 events on Gtk3 and Gtk4 equally. ''' if signal_name == 'clicked': self._click_handlers.append((callback, user_data)) return 1 if signal_name == 'released': self._release_handlers.append((callback, user_data)) return 1 if signal_name == 'long-pressed': self._longpress_handlers.append((callback, user_data)) return 1 return cast(int, super().connect(signal_name, callback, *user_data)) if GTK_MAJOR < 4: def do_button_press_event( # pylint: disable=arguments-differ self, event_button: 'Gdk.EventButton') -> bool: '''GTK3 button press → triggers click + schedules long press.''' button: int = event_button.button LOGGER.info('Gtk3 do_button_press_event button=%s', button) self._gtk3_button_down = True # Long press only for button 1 — matches GTK4 behaviour if button == 1: self._gtk3_longpress_timer = GLib.timeout_add( 500, self._gtk3_longpress_trigger) # 500ms else: self._gtk3_longpress_timer = None for cb, user in self._click_handlers: cb(self, button, *user) return False def do_button_release_event( # pylint: disable=arguments-differ self, event_button: 'Gdk.EventButton') -> bool: '''GTK3 button release → triggers release, cancels long press.''' button: int = event_button.button LOGGER.info('Gtk3 do_button_release_event button=%s', button) if self._gtk3_longpress_timer is not None: GLib.source_remove(self._gtk3_longpress_timer) self._gtk3_longpress_timer = None self._gtk3_button_down = False for cb, user in self._release_handlers: cb(self, button, *user) return False def _gtk3_longpress_trigger(self) -> bool: '''Timer callback for GTK3 long press.''' if self._gtk3_button_down: for cb, user in self._longpress_handlers: cb(self, *user) self._gtk3_longpress_timer = None return False # run once else: # Gtk4 event handling def _on_click_pressed( self, gesture: 'Gtk.GestureClick', _n_press: int, _x: float, _y: float, ) -> None: button = gesture.get_current_button() for cb, user in self._click_handlers: cb(self, button, *user) def _on_click_released( self, gesture: 'Gtk.GestureClick', _n_press: int, _x: float, _y: float, ) -> None: button = gesture.get_current_button() for cb, user in self._release_handlers: cb(self, button, *user) def _on_longpress_gtk4( self, _gesture: 'Gtk.GestureLongPress', _x: float, _y: float, ) -> None: for cb, user in self._longpress_handlers: cb(self, *user) class FakeEventKey: # pylint: disable=too-few-public-methods ''' Minimal stand-in for Gdk.EventKey when running under GTK4. GTK3 delivers key-press-event callbacks with a Gdk.EventKey instance, which provides attributes such as .keyval, .hardware_keycode, .state, and .string. GTK4 does NOT provide Gdk.EventKey. Instead, Gtk.EventControllerKey emits (keyval, keycode, state) parameters. This class reconstructs the small subset of a GTK3 GdkEventKey that EmojiPickerUI expects, allowing the existing GTK3 key handling logic to run unchanged. ''' def __init__(self, keyval: int, keycode: int, state: 'Gdk.ModifierType') -> None: self.keyval: int = keyval self.hardware_keycode: int = keycode self.state: 'Gdk.ModifierType' = state uc: int = Gdk.keyval_to_unicode(keyval) self.string: str = chr(uc) if uc else '' self.time: int = 0 self.group: int = 0 self.is_modifier: bool = False class HeaderBarCompat(Gtk.HeaderBar): # type: ignore[misc] ''' A GTK3/GTK4-compatible HeaderBar with working set_title(), set_subtitle() ''' def __init__(self) -> None: super().__init__() if GTK_MAJOR >= 4: self.set_show_title_buttons(True) # pylint: disable=no-member else: self.set_show_close_button(True) self._title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) self._title_box.set_spacing(0 if GTK_MAJOR >= 4 else 1) self._title_box.set_halign(Gtk.Align.CENTER) self._title_box.set_valign(Gtk.Align.CENTER) self._title_box.set_hexpand(True) self._title_label = Gtk.Label() self._subtitle_label = Gtk.Label() for label in (self._title_label, self._subtitle_label): label.set_ellipsize(Pango.EllipsizeMode.END) label.set_hexpand(False) label.set_halign(Gtk.Align.CENTER) label.set_xalign(0.5) if GTK_MAJOR >= 4: self._title_box.append(label) else: self._title_box.pack_start(label, False, False, 0) if GTK_MAJOR >= 4: self.set_title_widget(self._title_box) # pylint: disable=no-member # Better do NOT use set_custom_title() for GTK3, because # native implementation is superior def set_title(self, text: str) -> None: # pylint: disable=arguments-differ ''' Set the header bar title (rendered bold). Empty string clears the title. Escaped using GLib.markup_escape_text(). ''' if GTK_MAJOR < 4: super().set_title(text) return escaped = GLib.markup_escape_text(text) if text else '' if escaped: self._title_label.set_markup(f'{escaped}') return self._title_label.set_text('') def set_subtitle(self, text: str) -> None: # pylint: disable=arguments-differ ''' Set the header bar subtitle (smaller text). Empty string clears it. Escaped using GLib.markup_escape_text(). ''' if GTK_MAJOR < 4: super().set_subtitle(text) return escaped = GLib.markup_escape_text(text) if text else '' if escaped: self._subtitle_label.set_markup(f'{escaped}') return self._subtitle_label.set_text('') class MessageDialogCompat: # pylint: disable=too-few-public-methods ''' Gtk3/Gtk4-compatible informational / warning / error dialog. Usage: MessageDialogCompat( parent=self, message='Operation completed successfully!', message_type=Gtk.MessageType.INFO, details='Stack trace or additional error details...', details_label='Details', title='Information', button_label='OK', on_close: close_function, ).show() ''' ICONS = { Gtk.MessageType.INFO: 'dialog-information', Gtk.MessageType.WARNING: 'dialog-warning', Gtk.MessageType.ERROR: 'dialog-error', Gtk.MessageType.QUESTION: 'dialog-question', Gtk.MessageType.OTHER: 'dialog-information', } def __init__( self, *, parent: Optional[Gtk.Widget] = None, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO, details: Optional[str] = None, details_label: str = 'Details', title: Optional[str] = None, button_label: str = '_OK', on_close: Optional[Callable[[], None]] = None, ) -> None: self.parent = parent self.message = message self.message_type = message_type self.details = details self.details_label = details_label self.title = title or '' self.button_label = button_label self.on_close = on_close self.dialog = Gtk.Dialog( title=self.title, transient_for=self.parent, modal=True, ) self.dialog.add_button(self.button_label, Gtk.ResponseType.OK) self._build_content() def _build_content(self) -> None: '''Builds the dialog UI for Gtk3 + Gtk4.''' content_area = self.dialog.get_content_area() outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) outer.set_margin_top(12) outer.set_margin_bottom(12) outer.set_margin_start(12) outer.set_margin_end(12) icon_name = self.ICONS.get(self.message_type, 'dialog-information') if GTK_MAJOR >= 4: image = Gtk.Image.new_from_icon_name(icon_name) # add some reasonable sizing — Gtk4 icons are symbolic image.set_pixel_size(48) else: image = Gtk.Image.new_from_icon_name( icon_name, Gtk.IconSize.DIALOG) add_child(outer, image) text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) label = Gtk.Label() label.set_markup(f'{html.escape(self.message)}') label.set_xalign(0) label.set_max_width_chars(40) set_label_wrap_mode(label, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR) add_child(text_box, label) if self.details: _add_details_section(text_box, self.details) add_child(outer, text_box) add_child(content_area, outer) show_all(content_area) def _on_response(self, dialog: Gtk.Dialog, _response: Gtk.ResponseType) -> None: dialog.destroy() if self.on_close: self.on_close() def show(self) -> None: ''' Gtk3: Uses dialog.run() (blocks) Gtk4: Emulates run() by spinning a temporary GLib main loop until a response is received. ''' if GTK_MAJOR < 4: response = self.dialog.run() self._on_response(self.dialog, response) return # Gtk4: emulate a blocking run() loop = GLib.MainLoop() def on_response(dialog: Gtk.Dialog, _response: Gtk.ResponseType) -> None: dialog.destroy() if self.on_close: self.on_close() loop.quit() self.dialog.connect("response", on_response) self.dialog.present() # Block until on_response calls loop.quit() loop.run() class ConfirmDialogCompat: # pylint: disable=too-few-public-methods ''' A Gtk3/Gtk4 compatible confirmation dialog with asynchronous callbacks. Usage: dlg = ConfirmDialogCompat( parent=self, question='Delete everything?', title='Confirm', ok_label='_OK', cancel_label='_Cancel', on_ok=callback, on_cancel=cancel_callback, ) dlg.show() ''' def __init__( self, *, parent: Gtk.Widget, question: str, title: str = 'Are you sure?', ok_label: str = '_OK', cancel_label: str = '_Cancel', on_ok: Optional[ResponseCallback] = None, on_cancel: Optional[ResponseCallback] = None, ) -> None: self.parent = parent self.question = question self.title = title self.ok_label = ok_label self.cancel_label = cancel_label self.on_ok = on_ok self.on_cancel = on_cancel self.dialog = Gtk.Dialog( title=self.title, transient_for=self.parent, modal=True, ) self.dialog.add_button(self.cancel_label, Gtk.ResponseType.CANCEL) self.dialog.add_button(self.ok_label, Gtk.ResponseType.OK) self._add_message_label() def _add_message_label(self) -> None: '''Internal: add message label to content area, with wrapping + markup.''' box = self.dialog.get_content_area() label = Gtk.Label() label.set_markup( f'{html.escape(self.question)}' ) label.set_max_width_chars(40) label.set_xalign(0) set_label_wrap_mode( label, wrap=True, wrap_mode=Pango.WrapMode.WORD_CHAR) margin = 10 label.set_margin_start(margin) label.set_margin_end(margin) label.set_margin_top(margin) label.set_margin_bottom(margin) add_child(box, label) show_all(box) def _on_response(self, dialog: Gtk.Dialog, response_id: Gtk.ResponseType) -> None: '''Internal: Handles both Gtk3 and Gtk4 dialog results identically.''' dialog.destroy() if response_id == Gtk.ResponseType.OK: if self.on_ok: self.on_ok() return if self.on_cancel: self.on_cancel() def show(self) -> None: ''' Gtk3: Blocks internally (dialog.run()) Then triggers callbacks Gtk4: Fully asynchronous ''' if GTK_MAJOR < 4: response = self.dialog.run() # Blocking self._on_response(self.dialog, response) return self.dialog.connect('response', self._on_response) self.dialog.present() # Non-blocking class CompatButton(Gtk.Button): # type: ignore[misc] ''' A Gtk.Button subclass that behaves like Gtk4's Gtk.Button even on Gtk3. Supports: - label= - child= - icon_name= - tooltip_text= - Gtk4 construction semantics - set_label() to update the label later If the button gets a label, use_markup is always set to True for the label. ''' def __init__( self, label: Optional[str] = None, child: Optional[Gtk.Widget] = None, icon_name: Optional[str] = None, tooltip_text: Optional[str] = None, use_underline: Optional[bool] = None, **kwargs: Any, ) -> None: # Call base constructor with only safe kwargs # Gtk4 will accept css_classes, css_name etc. super().__init__(**kwargs) self._label_widget: Optional[Gtk.Label] = None self._box: Optional[Gtk.Box] = None if child is not None: add_child(self, child) else: if label is not None or icon_name is not None: self._setup_common(label, icon_name, use_underline) if tooltip_text: self.set_tooltip_text(tooltip_text) def _setup_common( self, label: Optional[str], icon_name: Optional[str], use_underline: Optional[bool], ) -> None: self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) if icon_name is not None: if GTK_MAJOR >= 4: image = Gtk.Image.new_from_icon_name(icon_name) image.set_pixel_size(16) # Default size else: image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON) add_child(self._box, image) if label is not None: self._label_widget = Gtk.Label(label=label, use_markup=True) if use_underline is not None: self._label_widget.set_text_with_mnemonic(label) add_child(self._box, self._label_widget) add_child(self, self._box) def set_label(self, text: str) -> None: # pylint: disable=arguments-differ '''Set or update the label of the button.''' if self._label_widget is not None: self._label_widget.set_label(text) def choose_file_open( *, parent: Gtk.Widget, title: Optional[str] = None, initial_folder: Optional[str] = None, ) -> Optional[str]: ''' Cross-GTK3/Gtk4 file-open dialog. Blocks in Gtk3, async but wrapped to block in Gtk4. Returns the selected filename or None. ''' if title is None: title = _('Open File ...') if GTK_MAJOR < 4: dialog = Gtk.FileChooserDialog( title=title, parent=parent, action=Gtk.FileChooserAction.OPEN, ) dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL) dialog.add_button("_OK", Gtk.ResponseType.OK) if initial_folder: try: dialog.set_current_folder(initial_folder) except Exception: # pylint: disable=broad-exception-caught pass # Ignore invalid paths response = dialog.run() filename = None if response == Gtk.ResponseType.OK: filename = dialog.get_filename() dialog.destroy() return filename # Gtk4: Use new async Gtk.FileDialog file_dialog = Gtk.FileDialog() # pylint: disable=c-extension-no-member file_dialog.set_title(title) if initial_folder: # NOTE: Gtk.FileDialog uses GFile for folders gfile = Gio.File.new_for_path(initial_folder) file_dialog.set_initial_folder(gfile) loop = GLib.MainLoop() result: Optional[str] = None def on_finish( dialog: Gtk.FileDialog, # pylint: disable=c-extension-no-member task: Gio.AsyncResult, ) -> None: nonlocal result try: gfile = dialog.open_finish(task) if gfile: result = gfile.get_path() except GLib.Error: result = None finally: loop.quit() file_dialog.open(parent, None, on_finish) loop.run() return result def liststore_append(store: Gtk.ListStore, values: Iterable[Any]) -> Gtk.TreeIter: ''' Append a row to a Gtk.ListStore in a way that is compatible with all versions of GTK and PyGObject (GTK3, GTK4 on Fedora/Debian/Ubuntu, and GTK4 on RHEL9). Why this function is needed: PyGObject normally allows: store.append([value0, value1, ...]) but this depends on the underlying GTK method `Gtk.ListStore.insert_with_valuesv()`. Some distributions (notably RHEL9 with GTK 4.12.3) ship incomplete GIR/typelib files that *omit* this method. When Python tries to call append([...]), it fails with: AttributeError: 'ListStore' object has no attribute 'insert_with_valuesv' To avoid this, we append an empty row first and then fill in the values using `store.set()`, which is guaranteed to exist in all GTK versions. This makes the function portable across all supported platforms. Args: store: A Gtk.ListStore instance. values: An iterable of column values for the newly inserted row. Returns: Gtk.TreeIter: The iter pointing to the newly created row. ''' iter_: Gtk.TreeIter = store.append() for column, value in enumerate(values): store.set(iter_, column, value) return iter_