# Copyright (C) 2007, Eduardo Silva <edsiper@gmail.com>
# Copyright (C) 2008, One Laptop Per Child
# Copyright (C) 2009, Tomeu Vizoso
# Copyright (C) 2011, Benjamin Berg <benjamin@sipsolutions.net>
# Copyright (C) 2011, Marco Pesenti Gritti <marco@marcopg.org>
# Copyright (C) 2025 MostlyK
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., 59 Temple Place - Suite 330,
# Boston, MA 02111-1307, USA.
import textwrap
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Gdk', '4.0')
from gi.repository import GLib, Gtk, Gdk, GObject, Pango, Graphene
from sugar.graphics import style
from sugar.graphics.icon import Icon
from sugar.graphics.palettewindow import (
PaletteWindow, _PaletteWindowWidget, _PaletteMenuWidget
)
from sugar.graphics.palettemenu import PaletteMenuItem
from sugar.graphics.palettewindow import (
MouseSpeedDetector, Invoker, WidgetInvoker, CursorInvoker,
ToolInvoker, TreeViewInvoker
)
assert MouseSpeedDetector
assert Invoker
assert WidgetInvoker
assert CursorInvoker
assert ToolInvoker
assert TreeViewInvoker
class _HeaderItem(Gtk.Widget):
"""A custom widget for palette headers in GTK4.
Replaces GTK3's MenuItem with a widget.
"""
__gtype_name__ = 'SugarPaletteHeader'
def __init__(self, child_widget):
super().__init__()
self._child_widget = None
self._click_gesture = Gtk.GestureClick.new()
self._click_gesture.set_button(1) # Left click
self.add_controller(self._click_gesture)
if child_widget:
self.set_child(child_widget)
def set_child(self, child_widget):
if self._child_widget:
self._child_widget.unparent()
self._child_widget = child_widget
if child_widget:
child_widget.set_parent(self)
def get_child(self):
return self._child_widget
def do_measure(self, orientation, for_size):
if self._child_widget:
return self._child_widget.measure(orientation, for_size)
return 0, 0, -1, -1
def do_size_allocate(self, width, height, baseline):
if self._child_widget:
self._child_widget.allocate(width, height, baseline, None)
def do_snapshot(self, snapshot):
"""Snapshot implementation for custom drawing."""
# Draw separator line at bottom
width = self.get_width()
height = self.get_height()
if width > 0 and height > 0:
# Create a colored rectangle for the separator
color = Gdk.RGBA()
color.red = color.green = color.blue = 0.5 # Grey
color.alpha = 1.0
line_height = 2
rect = Gdk.Rectangle()
rect.x = 0
rect.y = height - line_height
rect.width = width
rect.height = line_height
snapshot.append_color(color, Graphene.Rect().init(rect.x, rect.y, rect.width, rect.height))
if self._child_widget:
self.snapshot_child(self._child_widget, snapshot)
def do_dispose(self):
if self._child_widget:
self._child_widget.unparent()
self._child_widget = None
# TODO: Dispose here
[docs]
class Palette(PaletteWindow):
"""Floating palette implementation.
This class dynamically switches between one of two encapsulated child
widget types: a _PaletteWindowWidget or a _PaletteMenuWidget.
The window widget, created by default, acts as the container for any
type of widget the user may wish to add. It can optionally display primary
text, secondary text, and an icon at the top of the palette.
If the user attempts to access the 'menu' property, the window widget is
destroyed and the palette is dynamically switched to use a menu widget.
This maintains the same look and feel as a normal palette,
allowing submenus and so on.
"""
__gsignals__ = {
'activate': (GObject.SignalFlags.RUN_FIRST, None, ()),
}
__gtype_name__ = 'SugarPalette'
[docs]
def __init__(self, label=None, accel_path=None,
text_maxlen=style.MENU_WIDTH_CHARS, **kwargs):
# DEPRECATED: label is passed with the primary-text property,
# accel_path is set via the invoker property
self._primary_text = None
self._secondary_text = None
self._icon = None
self._icon_visible = True
# header container
self._primary_event_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self._primary_event_box.set_spacing(style.DEFAULT_SPACING)
click_gesture = Gtk.GestureClick()
click_gesture.connect('released', self.__button_release_event_cb)
self._primary_event_box.add_controller(click_gesture)
# icon container
self._icon_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self._icon_box.set_size_request(style.GRID_CELL_SIZE, -1)
self._primary_event_box.append(self._icon_box)
# labels container
labels_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
labels_box.set_margin_start(style.DEFAULT_SPACING)
labels_box.set_margin_end(style.DEFAULT_SPACING)
labels_box.set_margin_top(style.DEFAULT_SPACING)
labels_box.set_margin_bottom(style.DEFAULT_SPACING)
labels_box.set_hexpand(True)
self._primary_event_box.append(labels_box)
# Primary label
self._label = Gtk.Label()
self._label.set_halign(Gtk.Align.START)
self._label.set_valign(Gtk.Align.CENTER)
if text_maxlen > 0:
self._label.set_max_width_chars(text_maxlen)
self._label.set_ellipsize(style.ELLIPSIZE_MODE_DEFAULT)
labels_box.append(self._label)
# Secondary label
self._secondary_label = Gtk.Label()
self._secondary_label.set_halign(Gtk.Align.START)
self._secondary_label.set_valign(Gtk.Align.CENTER)
labels_box.append(self._secondary_label)
# secondary content container
self._secondary_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
self._secondary_box.append(self._separator)
super().__init__(**kwargs)
self._full_request = [0, 0]
self._content = None
if label is not None:
self.props.primary_text = label
self._add_content()
self.action_bar = PaletteActionBar()
self._secondary_box.append(self.action_bar)
self.connect('notify::invoker', self.__notify_invoker_cb)
# Default to a normal window palette
self._content_widget = None
self.set_content(None)
def _setup_widget(self):
super()._setup_widget()
self._widget.connect('destroy', self.__destroy_cb)
def __destroy_cb(self, palette):
self.popdown(immediate=True)
# Break the reference cycle to help with garbage collection
self._widget = None
def __notify_invoker_cb(self, palette, pspec):
invoker = self.props.invoker
if invoker is not None and hasattr(invoker, 'props') and hasattr(invoker.props, 'widget'):
self._update_accel_widget()
if hasattr(invoker, 'connect'):
invoker.connect('notify::widget', self.__invoker_widget_changed_cb)
def __invoker_widget_changed_cb(self, invoker, spec):
self._update_accel_widget()
[docs]
def get_full_size_request(self):
return self._full_request
[docs]
def popdown(self, immediate=False, state=None):
"""Hide the palette.
Args:
immediate (bool): if True, hide instantly. If False, use animation.
state: deprecated parameter, ignored.
"""
if immediate and self._widget:
if hasattr(self._widget, 'get_preferred_size'):
self._widget.get_preferred_size()
super().popdown(immediate)
[docs]
def on_enter(self):
super().on_enter()
def _add_content(self):
self._content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._secondary_box.append(self._content)
def _update_accel_widget(self):
if (self.props.invoker is not None and
hasattr(self.props.invoker, 'props') and
hasattr(self.props.invoker.props, 'widget')):
# GTK4: Set accelerator widget if the label supports it
if hasattr(self._label, 'set_accel_widget'):
self._label.set_accel_widget(self.props.invoker.props.widget)
[docs]
def set_primary_text(self, label, accel_path=None):
self._primary_text = label
if label is not None:
label = GLib.markup_escape_text(label)
self._label.set_markup(f'<b>{label}</b>')
self._label.set_visible(True)
else:
self._label.set_visible(False)
[docs]
def get_primary_text(self):
return self._primary_text
primary_text = GObject.Property(type=str,
getter=get_primary_text,
setter=set_primary_text)
def __button_release_event_cb(self, gesture, n_press, x, y):
if self.props.invoker is not None and hasattr(self.props.invoker, 'primary_text_clicked'):
self.props.invoker.primary_text_clicked()
[docs]
def set_secondary_text(self, label):
if label is None:
self._secondary_label.set_visible(False)
else:
NO_OF_LINES = 3
ELLIPSIS_LENGTH = 6
label = label.replace('\n', ' ')
label = label.replace('\r', ' ')
if hasattr(self._secondary_label, 'set_lines'):
self._secondary_label.set_max_width_chars(style.MENU_WIDTH_CHARS)
self._secondary_label.set_wrap(True)
self._secondary_label.set_ellipsize(style.ELLIPSIZE_MODE_DEFAULT)
self._secondary_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
self._secondary_label.set_lines(NO_OF_LINES)
self._secondary_label.set_justify(Gtk.Justification.FILL)
else:
# Fallback for older GTK versions
body_width = NO_OF_LINES * style.MENU_WIDTH_CHARS
body_width -= ELLIPSIS_LENGTH
if len(label) > body_width:
label = ' '.join(label[:body_width].split()[:-1]) + '...'
label = textwrap.fill(label, width=style.MENU_WIDTH_CHARS)
self._secondary_text = label
self._secondary_label.set_text(label)
self._secondary_label.set_visible(True)
[docs]
def get_secondary_text(self):
return self._secondary_text
secondary_text = GObject.Property(type=str,
getter=get_secondary_text,
setter=set_secondary_text)
def _show_icon(self):
self._icon_box.set_visible(True)
def _hide_icon(self):
self._icon_box.set_visible(False)
[docs]
def set_icon(self, icon):
if icon is None:
self._icon = None
self._hide_icon()
else:
child = self._icon_box.get_first_child()
while child:
next_child = child.get_next_sibling()
self._icon_box.remove(child)
child = next_child
event_box = Gtk.Box()
click_gesture = Gtk.GestureClick()
click_gesture.connect('released', self.__icon_button_release_event_cb)
event_box.add_controller(click_gesture)
self._icon_box.append(event_box)
self._icon = icon
self._icon.props.pixel_size = style.STANDARD_ICON_SIZE
event_box.append(self._icon)
self._show_icon()
[docs]
def get_icon(self):
return self._icon
icon = GObject.Property(type=object, getter=get_icon, setter=set_icon)
def __icon_button_release_event_cb(self, gesture, n_press, x, y):
self.emit('activate')
[docs]
def set_icon_visible(self, visible):
self._icon_visible = visible
if visible and self._icon is not None:
self._show_icon()
else:
self._hide_icon()
[docs]
def get_icon_visible(self):
return self._icon_visible
icon_visible = GObject.Property(type=bool,
default=True,
getter=get_icon_visible,
setter=set_icon_visible)
[docs]
def set_content(self, widget):
assert self._widget is None or isinstance(self._widget, _PaletteWindowWidget)
if self._widget is None:
self._widget = _PaletteWindowWidget(self)
self._setup_widget()
self._palette_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._palette_box.append(self._primary_event_box)
self._palette_box.append(self._secondary_box)
self._widget.set_child(self._palette_box)
height = style.GRID_CELL_SIZE - 2 * 4 # Approximate border width
self._primary_event_box.set_size_request(-1, height)
# Remove existing content
if self._content is not None:
child = self._content.get_first_child()
while child:
next_child = child.get_next_sibling()
self._content.remove(child)
child = next_child
if widget is not None:
# Set up button release handling
click_gesture = Gtk.GestureClick()
click_gesture.connect('released', self.__widget_button_release_cb)
widget.add_controller(click_gesture)
if self._content is not None:
self._content.append(widget)
self._content.set_visible(True)
else:
if self._content is not None:
self._content.set_visible(False)
self._content_widget = widget
self._update_accept_focus()
self._update_separators()
def __widget_button_release_cb(self, gesture, n_press, x, y):
# Check if the event widget is a PaletteMenuItem
widget = gesture.get_widget()
while widget:
if isinstance(widget, PaletteMenuItem):
self.popdown(immediate=True)
return False
widget = widget.get_parent()
return False
[docs]
def get_label_width(self):
# GTK4: Get preferred width
min_width, nat_width = self._label.get_preferred_size()
accel_width = 0
if hasattr(self._label, 'get_accel_width'):
accel_width = self._label.get_accel_width()
return nat_width.width + accel_width
def _update_separators(self):
# Check if there are content children
if self._content is not None:
visible = self._content.get_first_child() is not None
self._separator.set_visible(visible)
def _update_accept_focus(self):
if self._widget and self._content is not None:
accept_focus = self._content.get_first_child() is not None
self._widget.set_accept_focus(accept_focus)
def _update_full_request(self):
if self._widget is not None:
if hasattr(self._widget, 'get_preferred_size'):
min_size, nat_size = self._widget.get_preferred_size()
self._full_request = [nat_size.width, nat_size.height]
else:
self._full_request = [style.GRID_CELL_SIZE * 3, style.GRID_CELL_SIZE * 2]
menu = GObject.Property(type=object, getter=get_menu)
def _invoker_right_click_cb(self, invoker):
self.popup(immediate=True)
[docs]
class PaletteActionBar(Gtk.Box):
[docs]
def __init__(self):
# initializing with Horizontal box as this was a HButtonBox before
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.set_spacing(style.DEFAULT_SPACING)
self.set_homogeneous(True)
self.add_css_class('palette-action-bar')
[docs]
def add_action(self, label, icon_name=None):
button = Gtk.Button(label=label)
if icon_name:
icon = Icon(icon_name=icon_name, pixel_size=style.SMALL_ICON_SIZE)
# GTK4: Use set_child instead of set_image
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
box.append(icon)
box.append(Gtk.Label(label=label))
button.set_child(box)
self.append(button)
return button