Source code for sugar.graphics.toolbutton
# Copyright (C) 2007, Red Hat, Inc.
# Copyright (C) 2008, One Laptop Per Child
# 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.
#
# SPDX-License-Identifier: LGPL-2.1-or-later
"""
ToolButton
=====================
The toolbutton module provides the ToolButton class, which is a
Gtk.Button styled as a toolbar button with icon and tooltip for Sugar.
Example:
Add a tool button to a window::
from gi.repository import Gtk
from sugar.graphics.toolbutton import ToolButton
def __clicked_cb(button):
print("tool button was clicked")
app = Gtk.Application()
def on_activate(app):
w = Gtk.ApplicationWindow(application=app)
b = ToolButton(icon_name='dialog-ok', tooltip='a tooltip')
b.connect('clicked', __clicked_cb)
w.set_child(b)
w.present()
app.connect('activate', on_activate)
app.run()
STABLE.
"""
import logging
import os
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GObject, Gdk, Gio, Gsk, Graphene
from sugar.graphics.icon import Icon
from sugar.graphics.palette import Palette, ToolInvoker
from gi.repository import Pango
SUGAR_DEBUG = os.environ.get('SUGAR_DEBUG', '0') == '1'
def _add_accelerator(tool_button):
"""Add accelerator to tool button."""
if not tool_button.props.accelerator:
return
root = tool_button.get_root()
if not root:
return
# GTK4: Use application shortcuts instead of AccelGroup
app = root.get_application() if hasattr(root, "get_application") else None
if app and hasattr(app, "set_accels_for_action"):
# Create a unique action name for this button
action_name = f"toolbutton.{id(tool_button)}"
# Add the action to trigger the button click
action = Gio.SimpleAction.new(action_name, None)
action.connect("activate", lambda a, p: tool_button.emit("clicked"))
if hasattr(app, "add_action"):
app.add_action(action)
app.set_accels_for_action(
f"app.{action_name}", [tool_button.props.accelerator]
)
elif hasattr(root, "add_action"):
root.add_action(action)
app.set_accels_for_action(
f"win.{action_name}", [tool_button.props.accelerator]
)
def _hierarchy_changed_cb(tool_button):
_add_accelerator(tool_button)
[docs]
def setup_accelerator(tool_button):
_add_accelerator(tool_button)
# GTK4: Connect to root notify signal since hierarchy-changed doesn't exist
if hasattr(tool_button, "connect"):
tool_button.connect(
"notify::root", lambda *args: _hierarchy_changed_cb(tool_button)
)
[docs]
class ToolButton(Gtk.Button):
"""
The ToolButton class manages a Gtk.Button styled as a toolbar button for Sugar.
In GTK4, this replaces Gtk.ToolButton which was deprecated. The button is styled
to look and behave like a traditional toolbar button.
Args:
icon_name (str, optional): name of themed icon.
accelerator (str, optional): keyboard shortcut to activate this button.
tooltip (str, optional): tooltip displayed on hover.
hide_tooltip_on_click (bool, optional): Whether tooltip is hidden on click.
"""
[docs]
def __init__(self, icon_name=None, **kwargs):
self._accelerator = kwargs.pop("accelerator", None)
self._tooltip = kwargs.pop("tooltip", None)
self._hide_tooltip_on_click = kwargs.pop("hide_tooltip_on_click", True)
super().__init__(**kwargs)
# button styling for toolbar appearance
self.add_css_class("toolbar-button")
self.set_has_frame(False)
self.set_can_focus(True)
self._palette_invoker = ToolInvoker()
self._palette_invoker.attach(self)
self._content_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._content_box.set_halign(Gtk.Align.CENTER)
self._content_box.set_valign(Gtk.Align.CENTER)
self.set_child(self._content_box)
self._icon_widget = None
if icon_name:
self.set_icon_name(icon_name)
if self._tooltip:
self.set_tooltip(self._tooltip)
if self._accelerator:
self.set_accelerator(self._accelerator)
self.connect("destroy", self.__destroy_cb)
self.connect("clicked", self.__clicked_cb)
self._apply_toolbar_button_css()
def _apply_toolbar_button_css(self):
css = """
.toolbar-button {
border-radius: 6px;
margin: 2px;
padding: 6px;
min-width: 32px;
min-height: 32px;
}
.toolbar-button:hover {
background: alpha(@theme_fg_color, 0.1);
}
.toolbar-button:active,
.toolbar-button.active {
background: alpha(@theme_fg_color, 0.2);
border: 1px solid alpha(@theme_fg_color, 0.3);
}
.toolbar-button:focus {
outline: 2px solid @theme_selected_bg_color;
outline-offset: 2px;
}
"""
try:
css_provider = Gtk.CssProvider()
css_provider.load_from_string(css)
self.get_style_context().add_provider(
css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
except Exception as e:
logging.warning(f"Could not apply toolbar button CSS: {e}")
def __destroy_cb(self, widget):
if self._palette_invoker is not None:
self._palette_invoker.detach()
self._palette_invoker = None
def __clicked_cb(self, button):
if SUGAR_DEBUG:
print(f"ToolButton.__clicked_cb: 'clicked' signal received for {button}")
# Hide tooltip if needed
if self._hide_tooltip_on_click and self.get_palette():
palette = self.get_palette()
if palette is not None:
if palette.is_up():
palette.popdown(immediate=True)
# Explicitly trigger palette invoker toggle if present
invoker = self.get_palette_invoker()
if invoker and getattr(invoker, '_toggle_palette', False):
if SUGAR_DEBUG:
print("ToolButton.__clicked_cb: calling invoker.notify_toggle_state()")
invoker.notify_toggle_state()
[docs]
def set_tooltip(self, tooltip: Optional[str]):
"""
Set the tooltip.
Args:
tooltip (string): tooltip to be set.
"""
if tooltip is None:
self._tooltip = None
if hasattr(self, "_palette_invoker") and self._palette_invoker:
self._palette_invoker.set_palette(None)
return
self._tooltip = tooltip
if not self.get_palette():
palette = Palette(tooltip)
self.set_palette(palette)
else:
self.get_palette().set_primary_text(tooltip)
# native tooltip as fallback
self.set_tooltip_text(tooltip)
tooltip = GObject.Property(
type=str,
setter=set_tooltip,
getter=get_tooltip,
nick="Tooltip",
blurb="Tooltip text for the button",
)
[docs]
def get_hide_tooltip_on_click(self) -> bool:
"""
Return True if the tooltip is hidden when a user
clicks on the button, otherwise return False.
"""
return self._hide_tooltip_on_click
[docs]
def set_hide_tooltip_on_click(self, hide_tooltip_on_click: bool):
"""
Set whether or not the tooltip is hidden when a user
clicks on the button.
Args:
hide_tooltip_on_click (bool): True if the tooltip is
hidden on click, and False otherwise.
"""
self._hide_tooltip_on_click = hide_tooltip_on_click
hide_tooltip_on_click = GObject.Property(
type=bool,
default=True,
getter=get_hide_tooltip_on_click,
setter=set_hide_tooltip_on_click,
nick="Hide tooltip on click",
blurb="Whether to hide tooltip when button is clicked",
)
[docs]
def set_accelerator(self, accelerator: Optional[str]):
"""
Set accelerator that activates the button.
Args:
accelerator(string): accelerator to be set.
"""
self._accelerator = accelerator
if accelerator:
setup_accelerator(self)
[docs]
def get_accelerator(self) -> Optional[str]:
"""
Return accelerator that activates the button.
"""
return self._accelerator
accelerator = GObject.Property(
type=str,
setter=set_accelerator,
getter=get_accelerator,
nick="Accelerator",
blurb="Keyboard accelerator for the button",
)
[docs]
def set_icon_name(self, icon_name: Optional[str]):
print(f"[ToolButton] set_icon_name called with: {icon_name}")
if self._icon_widget:
self._content_box.remove(self._icon_widget)
self._icon_widget = None
if icon_name:
import os
if os.path.isabs(icon_name) or icon_name.endswith(('.svg', '.png', '.jpg', '.jpeg')):
print(f"[ToolButton] Icon file exists: {os.path.exists(icon_name)} at {icon_name}")
self._icon_widget = Gtk.Image.new_from_file(icon_name)
else:
print(f"[ToolButton] Using icon name: {icon_name}")
self._icon_widget = Gtk.Image.new_from_icon_name(icon_name)
self._content_box.prepend(self._icon_widget)
[docs]
def get_icon_name(self) -> Optional[str]:
"""Get the icon name.
Returns:
The icon name or None if no icon is set.
"""
if self._icon_widget and isinstance(self._icon_widget, Gtk.Image):
return self._icon_widget.get_icon_name()
return None
icon_name = GObject.Property(
type=str,
setter=set_icon_name,
getter=get_icon_name,
nick="Icon name",
blurb="Name of the themed icon",
)
[docs]
def set_icon_widget(self, icon_widget: Optional[Gtk.Widget]):
"""Set a custom icon widget.
Args:
icon_widget: widget to use as icon.
"""
# Remove existing icon
if self._icon_widget:
self._content_box.remove(self._icon_widget)
self._icon_widget = icon_widget
if icon_widget:
self._content_box.prepend(icon_widget)
[docs]
def set_label(self, label: Optional[str]):
child = self._content_box.get_last_child()
if child and isinstance(child, Gtk.Label):
self._content_box.remove(child)
if label:
label_widget = Gtk.Label(label=label)
label_widget.set_ellipsize(Pango.EllipsizeMode.END)
self._content_box.append(label_widget)
[docs]
def get_label(self) -> Optional[str]:
child = self._content_box.get_last_child()
if child and isinstance(child, Gtk.Label):
return child.get_text()
return None
[docs]
def get_palette(self) -> Optional[Palette]:
"""Get the current palette."""
if self._palette_invoker:
return self._palette_invoker.get_palette()
return None
[docs]
def set_palette(self, palette: Optional[Palette]):
if self._palette_invoker:
self._palette_invoker.set_palette(palette)
palette = GObject.Property(
type=object,
setter=set_palette,
getter=get_palette,
nick="Palette",
blurb="Palette for the button",
)
[docs]
def set_palette_invoker(self, palette_invoker: Optional[ToolInvoker]):
if self._palette_invoker:
self._palette_invoker.detach()
self._palette_invoker = palette_invoker
if palette_invoker:
palette_invoker.attach(self)
palette_invoker = GObject.Property(
type=object,
setter=set_palette_invoker,
getter=get_palette_invoker,
nick="Palette invoker",
blurb="Invoker for the palette",
)
[docs]
def do_snapshot(self, snapshot):
"""GTK4 drawing implementation."""
# Call parent implementation first
Gtk.Widget.do_snapshot(self, snapshot)
palette = self.get_palette()
if palette and palette.is_up():
# Get button allocation
width = self.get_width()
height = self.get_height()
if width > 0 and height > 0:
# Draw active state border
color = Gdk.RGBA()
color.red = 0.0
color.green = 0.5
color.blue = 1.0
color.alpha = 0.8
rect = Graphene.Rect()
rect.init(0, 0, width, height)
rounded = Gsk.RoundedRect()
rounded.init_from_rect(rect, 6.0)
# Draw border
snapshot.append_border(
rounded,
[2, 2, 2, 2], # border widths
[color, color, color, color], # border colors
)
[docs]
def set_active(self, active: bool):
if active:
self.add_css_class("active")
else:
self.remove_css_class("active")
[docs]
def get_active(self) -> bool:
"""Get the active state of the button."""
return self.has_css_class("active")
def _apply_module_css():
"""Apply module-level CSS styling."""
css = """
/* Additional toolbar button styles */
.toolbar-button icon {
min-width: 24px;
min-height: 24px;
}
.toolbar-button label {
font-size: 0.9em;
}
/* Ensure proper spacing in horizontal toolbars */
toolbar .toolbar-button {
margin: 1px;
}
/* Active palette indicator */
.toolbar-button.active {
box-shadow: inset 0 0 0 2px @theme_selected_bg_color;
}
"""
try:
css_provider = Gtk.CssProvider()
css_provider.load_from_string(css)
display = Gdk.Display.get_default()
if display:
Gtk.StyleContext.add_provider_for_display(
display, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
except Exception as e:
logging.warning(f"Could not apply module CSS: {e}")
try:
_apply_module_css()
except Exception:
pass