Source code for sugar.graphics.toolbarbox
# Copyright (C) 2009, Aleksey Lim
# 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
"""
ToolbarBox
======================
The ToolbarBox provides a horizontal toolbar container for Sugar activities,
supporting both regular toolbar buttons and expandable toolbar sections.
"""
import math
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GObject", "2.0")
from gi.repository import Gtk, GObject, Gdk, Graphene
import logging
from sugar.graphics.toolbutton import ToolButton
from sugar.graphics.palettewindow import (
PaletteWindow,
ToolInvoker,
_PaletteWindowWidget,
)
from sugar.graphics import palettegroup
from sugar.graphics import style
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
[docs]
class ToolbarButton(ToolButton):
"""
A toolbar button that can expand to show a toolbar page inline.
This is the main difference from regular ToolButton - it can show
an entire toolbar page when clicked, similar to a collapsible section.
"""
__gtype_name__ = "SugarToolbarButton"
[docs]
def __init__(self, page=None, **kwargs):
super().__init__(**kwargs)
self.page_widget = None
self._expanded = False
self.set_page(page)
self.connect("clicked", self._clicked_cb)
self.connect("notify::parent", self._hierarchy_changed_cb)
self.add_css_class("toolbar-expandable-button")
def _clicked_cb(self, widget):
self.set_expanded(not self.is_expanded())
def _hierarchy_changed_cb(self, widget, pspec):
parent = self.get_parent()
if hasattr(parent, "owner"):
if self.page_widget and self.get_root():
self._unparent()
parent.owner.append(self.page_widget)
self.set_expanded(False)
[docs]
def get_toolbar_box(self):
parent = self.get_parent()
if not hasattr(parent, "owner"):
return None
return parent.owner
toolbar_box = property(get_toolbar_box)
[docs]
def get_page(self):
if self.page_widget is None:
return None
return _get_embedded_page(self.page_widget)
[docs]
def set_page(self, page):
if page is None:
self.page_widget = None
return
self.page_widget, alignment_ = _embed_page(_Box(self), page)
self.page_widget.set_size_request(-1, style.GRID_CELL_SIZE)
page.show()
if self.get_palette() is None:
self.set_palette(_ToolbarPalette(invoker=ToolInvoker(self)))
self._move_page_to_palette()
page = GObject.Property(type=object, getter=get_page, setter=set_page)
[docs]
def is_in_palette(self):
palette = self.get_palette()
return (
self.page is not None
and palette is not None
and self.page_widget.get_parent() == palette._widget
)
[docs]
def popdown(self):
palette = self.get_palette()
if palette is not None:
palette.popdown(immediate=True)
[docs]
def set_expanded(self, expanded):
self.popdown()
palettegroup.popdown_all()
if self.page is None or self.is_expanded() == expanded:
return
if not expanded:
self._move_page_to_palette()
self._expanded = False
self.remove_css_class("expanded")
return
box = self.toolbar_box
if box is None:
return
if box.expanded_button is not None:
box.expanded_button.set_expanded(False)
box.expanded_button = self
self._unparent()
_setup_page(self.page_widget, style.COLOR_TOOLBAR_GREY, box.get_padding())
box.append(self.page_widget)
self._expanded = True
self.add_css_class("expanded")
def _move_page_to_palette(self):
"""Move the page widget to the palette."""
if self.is_in_palette():
return
self._unparent()
palette = self.get_palette()
if isinstance(palette, _ToolbarPalette) and palette._widget:
palette._widget.set_child(self.page_widget)
def _unparent(self):
"""Remove the page widget from its current parent."""
if self.page_widget is None:
return
page_parent = self.page_widget.get_parent()
if page_parent is None:
return
if isinstance(page_parent, Gtk.Window):
# For windows (like _PaletteWindowWidget), use set_child(None)
page_parent.set_child(None)
elif hasattr(page_parent, "remove"):
# For containers that have remove method
page_parent.remove(self.page_widget)
else:
# Fallback: try to unparent directly
self.page_widget.unparent()
[docs]
def do_snapshot(self, snapshot):
"""GTK4 drawing implementation with arrow indicator."""
Gtk.Widget.do_snapshot(self, snapshot)
width = self.get_width()
height = self.get_height()
if width > 0 and height > 0:
palette = self.get_palette()
angle = (
math.pi
if (not self.is_expanded() or (palette is not None and palette.is_up()))
else 0
)
self._paint_arrow(snapshot, width, height, angle)
def _paint_arrow(self, snapshot, width, height, angle):
"""Paint the arrow indicator."""
arrow_size = style.TOOLBAR_ARROW_SIZE / 2
y = height - arrow_size
x = (width - arrow_size) / 2
rect = Graphene.Rect()
rect.init(x, y, arrow_size, arrow_size)
color = Gdk.RGBA()
color.red = 0.5
color.green = 0.5
color.blue = 0.5
color.alpha = 1.0
snapshot.append_color(color, rect)
[docs]
class ToolbarBox(Gtk.Box):
"""
A container for toolbars that provides expandable toolbar sections.
The ToolbarBox contains a main horizontal toolbar and can show
expanded toolbar pages below it when ToolbarButtons are activated.
"""
__gtype_name__ = "SugarToolbarBox"
[docs]
def __init__(self, padding=style.TOOLBOX_HORIZONTAL_PADDING):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._expanded_button_index = -1
self._padding = padding
self._toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self._toolbar.owner = self
# GTK4: Box doesn't have a "remove" signal, we'll handle removal differently
self._toolbar_widget, self._toolbar_alignment = _embed_page(
Gtk.Box(orientation=Gtk.Orientation.VERTICAL), self._toolbar
)
self.append(self._toolbar_widget)
self._apply_styling()
def _apply_styling(self):
self.add_css_class("sugar-toolbarbox")
css = f"""
.sugar-toolbarbox {{
background: {style.COLOR_TOOLBAR_GREY.get_css_rgba()};
}}
.toolbar-expandable-button {{
margin: 2px;
border-radius: 4px;
}}
.toolbar-expandable-button.expanded {{
background: alpha(@theme_selected_bg_color, 0.2);
border-bottom: 2px solid @theme_selected_bg_color;
}}
"""
style.apply_css_to_widget(self, css)
toolbar = property(get_toolbar)
[docs]
def get_expanded_button(self):
if self._expanded_button_index == -1:
return None
return self._get_nth_toolbar_item(self._expanded_button_index)
[docs]
def set_expanded_button(self, button):
if button is None:
self._expanded_button_index = -1
return
index = self._get_toolbar_item_index(button)
if index != -1:
self._expanded_button_index = index
else:
self._expanded_button_index = -1
expanded_button = property(get_expanded_button, set_expanded_button)
[docs]
def set_padding(self, pad):
self._padding = pad
if self._toolbar_alignment:
# GTK4: Use margins instead of alignment padding
self._toolbar_alignment.set_margin_start(pad)
self._toolbar_alignment.set_margin_end(pad)
padding = GObject.Property(type=object, getter=get_padding, setter=set_padding)
def _get_nth_toolbar_item(self, index):
"""Get the nth item from the toolbar."""
child = self._toolbar.get_first_child()
current_index = 0
while child and current_index < index:
child = child.get_next_sibling()
current_index += 1
return child
def _get_toolbar_item_index(self, widget):
"""Get the index of a widget in the toolbar."""
child = self._toolbar.get_first_child()
index = 0
while child:
if child == widget:
return index
child = child.get_next_sibling()
index += 1
return -1
def _remove_cb(self, sender, button):
"""Handle removal of toolbar items."""
if not isinstance(button, ToolbarButton):
return
button.popdown()
if button == self.expanded_button:
if button.page_widget and button.page_widget.get_parent() == self:
self.remove(button.page_widget)
self._expanded_button_index = -1
class _ToolbarPalette(PaletteWindow):
"""
A palette window specifically for toolbar buttons.
This palette shows the toolbar page when the button is not expanded inline.
"""
def __init__(self, **kwargs):
# Remove invoker from kwargs before calling super().__init__
invoker = kwargs.pop("invoker", None)
super().__init__(**kwargs)
self._has_focus = False
group = palettegroup.get_group("default")
group.connect("popdown", self._group_popdown_cb)
self.set_group_id("toolbarbox")
self._widget = _PaletteWindowWidget(self)
self._widget.set_margin_start(0)
self._widget.set_margin_end(0)
self._widget.set_margin_top(0)
self._widget.set_margin_bottom(0)
self._setup_widget()
self._widget.connect("realize", self._realize_cb)
if invoker is not None:
self.set_invoker(invoker)
def set_primary_text(self, text):
"""Set primary text for the palette (required by ToolButton)."""
# No-op for toolbar palettes
pass
def set_secondary_text(self, text):
"""Set secondary text for the palette (required by ToolButton)."""
# No-op for toolbar palettes
pass
def get_expanded_button(self):
return self.invoker.parent
expanded_button = property(get_expanded_button)
def on_invoker_enter(self):
super().on_invoker_enter()
self._set_focus(True)
def on_invoker_leave(self):
super().on_invoker_leave()
self._set_focus(False)
def on_enter(self):
super().on_enter()
self._set_focus(True)
def on_leave(self):
super().on_leave()
self._set_focus(False)
def _set_focus(self, new_focus):
self._has_focus = new_focus
if not self._has_focus:
group = palettegroup.get_group("default")
if not group.is_up():
self.popdown()
def _realize_cb(self, widget):
display = widget.get_display()
monitor = display.get_monitor_at_surface(widget.get_surface())
if monitor:
geometry = monitor.get_geometry()
widget.set_size_request(geometry.width, -1)
def popup(self, immediate=False):
"""Show the palette."""
button = self.expanded_button
if button and button.is_expanded():
return
if button and button.toolbar_box:
_setup_page(
button.page_widget, style.COLOR_BLACK, button.toolbar_box.get_padding()
)
super().popup(immediate)
def _group_popdown_cb(self, group):
"""Handle group popdown event."""
if not self._has_focus:
self.popdown(immediate=True)
class _Box(Gtk.Box):
"""
A container box for toolbar pages with custom drawing.
"""
def __init__(self, toolbar_button):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._toolbar_button = toolbar_button
def do_snapshot(self, snapshot):
"""GTK4 drawing implementation."""
Gtk.Widget.do_snapshot(self, snapshot)
button_alloc = self._toolbar_button.get_allocation()
my_width = self.get_width()
if my_width > 0:
color = Gdk.RGBA()
color.red = 0.7
color.green = 0.7
color.blue = 0.7
color.alpha = 1.0
line_width = style.FOCUS_LINE_WIDTH * 2
rect1 = Graphene.Rect()
rect1.init(0, 0, button_alloc.x + style.FOCUS_LINE_WIDTH, line_width)
snapshot.append_color(color, rect1)
rect2 = Graphene.Rect()
rect2.init(
button_alloc.x + button_alloc.width - style.FOCUS_LINE_WIDTH,
0,
my_width
- (button_alloc.x + button_alloc.width - style.FOCUS_LINE_WIDTH),
line_width,
)
snapshot.append_color(color, rect2)
def _setup_page(page_widget, color, hpad):
if not page_widget:
return
# margins instead of padding
child = page_widget.get_first_child()
if child:
child.set_margin_start(hpad)
child.set_margin_end(hpad)
page = _get_embedded_page(page_widget)
if page:
css = f"""
* {{
background: {color.get_css_rgba()};
}}
"""
style.apply_css_to_widget(page, css)
def _embed_page(page_widget, page):
page.show()
# Box instead of Alignment
container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
container.set_hexpand(True)
# Prevent the embedded toolbar/page container from expanding vertically.
# The toolbar should not absorb extra vertical space; keep it compact.
container.set_vexpand(False)
container.append(page)
container.show()
page_widget.append(container)
page_widget.show()
return (page_widget, container)
def _get_embedded_page(page_widget):
if not page_widget:
return None
child = page_widget.get_first_child()
if child:
return child.get_first_child()
return None