Source code for sugar.graphics.icon

# Copyright (C) 2006-2007 Red Hat, Inc.
# 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

"""
Icons - GTK4
=================

Icons are small pictures that are used to decorate components. In Sugar, icons
are SVG files that are re-coloured with a fill and a stroke colour. Typically,
icons representing the system use a greyscale color palette, whereas icons
representing people take on their selected XoColors.

Classes:
    Icon: Basic icon widget for displaying themed icons
    EventIcon: Icon with mouse event handling (GTK4 GestureClick)
    CanvasIcon: EventIcon with active/prelight states and styleable background
    CellRendererIcon: Icon renderer for use in tree/list views
    get_icon_file_name: Utility function to resolve icon paths
    get_surface: Utility function to get cairo surfaces for icons
    get_icon_state: Utility function to get state-based icon names
"""

import re
import logging
import os
from typing import Optional, Tuple, Dict
from configparser import ConfigParser

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Rsvg", "2.0")

from gi.repository import GLib, GObject, Gtk, Gdk, GdkPixbuf, Rsvg
import cairo

from sugar.graphics.xocolor import XoColor


# Simple LRU cache implementation
class _LRU:
    def __init__(self, size):
        self.size = size
        self.cache = {}
        self.order = []

    def __contains__(self, key):
        return key in self.cache

    def __getitem__(self, key):
        if key in self.cache:
            self.order.remove(key)
            self.order.append(key)
            return self.cache[key]
        raise KeyError(key)

    def __setitem__(self, key, value):
        if key in self.cache:
            self.order.remove(key)
        elif len(self.cache) >= self.size:
            oldest = self.order.pop(0)
            del self.cache[oldest]

        self.cache[key] = value
        self.order.append(key)


_BADGE_SIZE = 0.45
_DEFAULT_ICON_SIZE = 48

# Icon size constants
SMALL_ICON_SIZE = 16
STANDARD_ICON_SIZE = 48
LARGE_ICON_SIZE = 96


class _SVGLoader:
    """Loads and caches SVG icons with entity replacement."""

    def __init__(self):
        self._cache = _LRU(100)

    def load(
        self, file_name: str, entities: Dict[str, str], cache: bool = True
    ) -> Optional[Rsvg.Handle]:
        """Load SVG with entity replacement."""
        if cache and file_name in self._cache:
            icon_data = self._cache[file_name]
        else:
            try:
                with open(file_name, "r", encoding="utf-8") as icon_file:
                    icon_data = icon_file.read()

                if cache:
                    self._cache[file_name] = icon_data
            except (IOError, OSError) as e:
                logging.error("Failed to load icon file %s: %s", file_name, e)
                return None

        # Replace entities
        for entity, value in entities.items():
            if isinstance(value, str):
                xml = f'<!ENTITY {entity} "{value}">'
                icon_data = re.sub(f"<!ENTITY {entity} .*>", xml, icon_data)
            else:
                logging.error("Icon %s, entity %s is invalid.", file_name, entity)

        try:
            return Rsvg.Handle.new_from_data(icon_data.encode("utf-8"))
        except GLib.Error as e:
            logging.error("Failed to create SVG handle for %s: %s", file_name, e)
            return None


class _IconInfo:
    """Information about an icon including attachment points."""

    def __init__(self):
        self.file_name: Optional[str] = None
        self.attach_x: float = 0.0
        self.attach_y: float = 0.0


class _BadgeInfo:
    """Information about badge positioning."""

    def __init__(self):
        self.attach_x: float = 0.0
        self.attach_y: float = 0.0
        self.size: int = 0
        self.icon_padding: int = 0


class _IconBuffer:
    """Manages icon rendering and caching."""

    _surface_cache = _LRU(100)
    _loader = _SVGLoader()

    def __init__(self):
        self.icon_name: Optional[str] = None
        self.file_name: Optional[str] = None
        self.fill_color: Optional[str] = None
        self.stroke_color: Optional[str] = None
        self.background_color: Optional[Gdk.RGBA] = None
        self.badge_name: Optional[str] = None
        self.width: int = _DEFAULT_ICON_SIZE
        self.height: int = _DEFAULT_ICON_SIZE
        self.cache: bool = True
        self.scale: float = 1.0
        self.pixbuf: Optional[GdkPixbuf.Pixbuf] = None
        self.alpha: float = 1.0

    def _get_cache_key(self, sensitive: bool = True) -> tuple:
        """Generate cache key for this icon configuration."""
        bg_color = None
        if self.background_color:
            bg_color = (
                self.background_color.red,
                self.background_color.green,
                self.background_color.blue,
                self.background_color.alpha,
            )

        return (
            self.icon_name,
            self.file_name,
            id(self.pixbuf),
            self.fill_color,
            self.stroke_color,
            self.badge_name,
            self.width,
            self.height,
            bg_color,
            sensitive,
            self.scale,
            self.alpha,
        )

    def _load_svg(self, file_name: str) -> Optional[Rsvg.Handle]:
        """Load SVG with color entities."""
        entities = {}
        if self.fill_color:
            entities["fill_color"] = self.fill_color
        if self.stroke_color:
            entities["stroke_color"] = self.stroke_color

        return self._loader.load(file_name, entities, self.cache)

    def _get_attach_points(self, file_name: str) -> Tuple[float, float]:
        """Get badge attachment points from .icon file."""
        attach_x = attach_y = 0.0

        if not file_name:
            return attach_x, attach_y

        # Try to read from .icon file
        icon_config_file = file_name.replace(".svg", ".icon")
        if icon_config_file != file_name and os.path.exists(icon_config_file):
            try:
                cp = ConfigParser()
                cp.read(icon_config_file)
                attach_points_str = cp.get("Icon Data", "AttachPoints")
                attach_points = attach_points_str.split(",")
                attach_x = float(attach_points[0].strip()) / 1000.0
                attach_y = float(attach_points[1].strip()) / 1000.0
            except Exception as e:
                logging.debug("Could not read icon config %s: %s", icon_config_file, e)

        return attach_x, attach_y

    def _get_icon_info(
        self, file_name: Optional[str], icon_name: Optional[str]
    ) -> _IconInfo:
        """Get icon information from theme or file."""
        icon_info = _IconInfo()

        if file_name:
            icon_info.file_name = file_name
            icon_info.attach_x, icon_info.attach_y = self._get_attach_points(file_name)
        elif icon_name:
            icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())

            # GTK4 uses lookup_icon with size and scale
            icon_paintable = icon_theme.lookup_icon(
                icon_name,
                None,
                self.width,
                1,
                Gtk.TextDirection.NONE,
                Gtk.IconLookupFlags.NONE,
            )

            if icon_paintable:
                file_path = icon_paintable.get_file()
                if file_path:
                    icon_info.file_name = file_path.get_path()
                    icon_info.attach_x, icon_info.attach_y = self._get_attach_points(
                        icon_info.file_name
                    )
            else:
                logging.warning("No icon with name %s found in theme", icon_name)

        return icon_info

    def _get_badge_info(
        self, icon_info: _IconInfo, icon_width: int, icon_height: int
    ) -> _BadgeInfo:
        """Get badge positioning information."""
        info = _BadgeInfo()
        if self.badge_name is None:
            return info

        info.size = int(_BADGE_SIZE * icon_width)
        info.attach_x = int(icon_info.attach_x * icon_width - info.size / 2)
        info.attach_y = int(icon_info.attach_y * icon_height - info.size / 2)

        if info.attach_x < 0 or info.attach_y < 0:
            info.icon_padding = max(-info.attach_x, -info.attach_y)
        elif (
            info.attach_x + info.size > icon_width
            or info.attach_y + info.size > icon_height
        ):
            x_padding = info.attach_x + info.size - icon_width
            y_padding = info.attach_y + info.size - icon_height
            info.icon_padding = max(x_padding, y_padding)

        return info

    def _draw_badge(self, context: cairo.Context, size: int, sensitive: bool):
        """Draw badge icon."""
        if not self.badge_name:
            return

        icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
        badge_paintable = icon_theme.lookup_icon(
            self.badge_name,
            None,
            size,
            1,
            Gtk.TextDirection.NONE,
            Gtk.IconLookupFlags.NONE,
        )

        if not badge_paintable:
            return

        badge_file = badge_paintable.get_file()
        if not badge_file:
            return

        badge_file_name = badge_file.get_path()

        if badge_file_name.endswith(".svg"):
            handle = self._load_svg(badge_file_name)
            if handle:
                # Get SVG dimensions
                svg_rect = handle.get_intrinsic_size_in_pixels()
                if svg_rect[0]:  # has_width
                    icon_width, icon_height = svg_rect[1], svg_rect[2]
                else:
                    icon_width = icon_height = size

                context.scale(float(size) / icon_width, float(size) / icon_height)

                # Create viewport
                viewport = Rsvg.Rectangle()
                viewport.x = 0
                viewport.y = 0
                viewport.width = icon_width
                viewport.height = icon_height

                if sensitive:
                    handle.render_document(context, viewport)
                else:
                    context.push_group()
                    handle.render_document(context, viewport)
                    context.pop_group_to_source()
                    context.paint_with_alpha(0.5)
        else:
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file(badge_file_name)
                icon_width = pixbuf.get_width()
                icon_height = pixbuf.get_height()

                context.scale(float(size) / icon_width, float(size) / icon_height)
                Gdk.cairo_set_source_pixbuf(context, pixbuf, 0, 0)

                if sensitive:
                    context.paint()
                else:
                    context.paint_with_alpha(0.5)
            except GLib.Error as e:
                logging.error("Failed to load badge pixbuf: %s", e)

    def _get_xo_color(self) -> Optional[XoColor]:
        """Get XoColor from stroke and fill colors."""
        if self.stroke_color and self.fill_color:
            return XoColor(f"{self.stroke_color},{self.fill_color}")
        return None

    def _set_xo_color(self, xo_color: Optional[XoColor]):
        """Set stroke and fill colors from XoColor."""
        if xo_color:
            self.stroke_color = xo_color.get_stroke_color()
            self.fill_color = xo_color.get_fill_color()
        else:
            self.stroke_color = None
            self.fill_color = None

    def get_surface(self, sensitive: bool = True) -> Optional[cairo.ImageSurface]:
        """Get cairo surface for this icon."""
        cache_key = self._get_cache_key(sensitive)
        if cache_key in self._surface_cache:
            return self._surface_cache[cache_key]

        # Handle pixbuf directly
        if self.pixbuf:
            surface = self._create_surface_from_pixbuf(self.pixbuf, sensitive)
            if surface:
                self._surface_cache[cache_key] = surface
            return surface

        # Try to load from file or theme, fallback to document-generic
        for file_name, icon_name in [
            (self.file_name, self.icon_name),
            (None, "document-generic"),
        ]:
            icon_info = self._get_icon_info(file_name, icon_name)
            if not icon_info.file_name:
                continue

            surface = self._create_surface_from_file(icon_info, sensitive)
            if surface:
                self._surface_cache[cache_key] = surface
                return surface

        return None

    def _get_size(
        self, icon_width: int, icon_height: int, padding: int
    ) -> Tuple[int, int]:
        """Get final surface size including padding."""
        if self.width is not None and self.height is not None:
            width = self.width + padding
            height = self.height + padding
        else:
            width = icon_width + padding
            height = icon_height + padding
        return width, height

    def _create_surface_from_pixbuf(
        self, pixbuf: GdkPixbuf.Pixbuf, sensitive: bool
    ) -> Optional[cairo.ImageSurface]:
        """Create surface from pixbuf."""
        width, height = self.width, self.height

        surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
        ctx = cairo.Context(surface)

        if self.background_color:
            ctx.set_source_rgba(
                self.background_color.red,
                self.background_color.green,
                self.background_color.blue,
                self.background_color.alpha,
            )
            ctx.paint()

        # Scale pixbuf to fit
        pb_width, pb_height = pixbuf.get_width(), pixbuf.get_height()
        scale_x = width / pb_width
        scale_y = height / pb_height
        scale = min(scale_x, scale_y) * self.scale

        ctx.scale(scale, scale)

        x = (width / scale - pb_width) / 2
        y = (height / scale - pb_height) / 2

        Gdk.cairo_set_source_pixbuf(ctx, pixbuf, x, y)

        if sensitive:
            if self.alpha == 1.0:
                ctx.paint()
            else:
                ctx.paint_with_alpha(self.alpha)
        else:
            ctx.paint_with_alpha(0.5 * self.alpha)

        return surface

    def _create_surface_from_file(
        self, icon_info: _IconInfo, sensitive: bool
    ) -> Optional[cairo.ImageSurface]:
        """Create surface from file."""
        if not icon_info.file_name:
            return None

        if icon_info.file_name.endswith(".svg"):
            return self._create_surface_from_svg(icon_info, sensitive)
        else:
            # Load as pixbuf
            try:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_info.file_name)
                return self._create_surface_from_pixbuf(pixbuf, sensitive)
            except GLib.Error as e:
                logging.error(
                    "Failed to load pixbuf from %s: %s", icon_info.file_name, e
                )
                return None

    def _create_surface_from_svg(
        self, icon_info: _IconInfo, sensitive: bool
    ) -> Optional[cairo.ImageSurface]:
        """Create surface from SVG file."""
        handle = self._load_svg(icon_info.file_name)
        if not handle:
            return None

        # SVG dimensions
        svg_rect = handle.get_intrinsic_size_in_pixels()
        if svg_rect[0]:  # has_width
            icon_width, icon_height = int(svg_rect[1]), int(svg_rect[2])
        else:
            # Fallback dimensions
            icon_width = icon_height = 48

        # badge info and padding
        badge_info = self._get_badge_info(icon_info, icon_width, icon_height)
        padding = badge_info.icon_padding
        width, height = self._get_size(icon_width, icon_height, padding)

        # Create surface
        if self.background_color is None:
            surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, int(width), int(height))
        else:
            surface = cairo.ImageSurface(cairo.FORMAT_RGB24, int(width), int(height))

        ctx = cairo.Context(surface)

        if self.background_color:
            ctx.set_source_rgba(
                self.background_color.red,
                self.background_color.green,
                self.background_color.blue,
                self.background_color.alpha,
            )
            ctx.paint()

        # Scale context for icon
        ctx.scale(
            float(width) / (icon_width + padding * 2),
            float(height) / (icon_height + padding * 2),
        )
        ctx.save()

        # Translate for padding
        ctx.translate(padding, padding)

        # Create viewport
        viewport = Rsvg.Rectangle()
        viewport.x = 0
        viewport.y = 0
        viewport.width = icon_width
        viewport.height = icon_height

        # Render main icon
        if sensitive:
            if self.alpha == 1.0:
                handle.render_document(ctx, viewport)
            else:
                ctx.push_group()
                handle.render_document(ctx, viewport)
                ctx.pop_group_to_source()
                ctx.paint_with_alpha(self.alpha)
        else:
            ctx.push_group()
            handle.render_document(ctx, viewport)
            ctx.pop_group_to_source()
            ctx.paint_with_alpha(0.5 * self.alpha)

        # Draw badge if present
        if self.badge_name:
            ctx.restore()
            ctx.translate(badge_info.attach_x, badge_info.attach_y)
            self._draw_badge(ctx, badge_info.size, sensitive)

        return surface

    xo_color = property(_get_xo_color, _set_xo_color)


[docs] class Icon(Gtk.Widget): """ Basic Sugar icon widget for GTK4. Displays themed icons with Sugar's color customization features. Properties: icon_name (str): Icon name from theme file_name (str): Path to icon file pixel_size (int): Size in pixels fill_color (str): Fill color as hex string stroke_color (str): Stroke color as hex string xo_color (XoColor): Sugar color pair badge_name (str): Badge icon name alpha (float): Icon transparency (0.0-1.0) scale (float): Icon scale factor sensitive (bool): Whether icon appears sensitive """ __gtype_name__ = "SugarIcon"
[docs] def __init__( self, icon_name: Optional[str] = None, file_name: Optional[str] = None, pixel_size: int = STANDARD_ICON_SIZE, **kwargs, ): super().__init__(**kwargs) self._buffer = _IconBuffer() self._buffer.icon_name = icon_name self._buffer.file_name = file_name self._buffer.width = pixel_size self._buffer.height = pixel_size # Set up drawing self.set_size_request(pixel_size, pixel_size)
[docs] def do_snapshot(self, snapshot: Gtk.Snapshot): """GTK4 drawing method.""" surface = self._buffer.get_surface(self.get_sensitive()) if surface: width = self.get_width() height = self.get_height() # Center the icon x = (width - surface.get_width()) / 2 y = (height - surface.get_height()) / 2 snapshot.save() snapshot.translate(Graphene.Point().init(x, y)) # Convert surface to pixbuf then to texture pixbuf = Gdk.pixbuf_get_from_surface( surface, 0, 0, surface.get_width(), surface.get_height() ) if pixbuf: texture = Gdk.Texture.new_for_pixbuf(pixbuf) snapshot.append_texture( texture, Graphene.Rect().init( 0, 0, surface.get_width(), surface.get_height() ), ) snapshot.restore()
[docs] def do_measure( self, orientation: Gtk.Orientation, for_size: int ) -> Tuple[int, int, int, int]: """GTK4 size request method.""" size = max(self._buffer.width, self._buffer.height) return size, size, -1, -1
# Properties
[docs] def get_icon_name(self) -> Optional[str]: return self._buffer.icon_name
[docs] def set_icon_name(self, icon_name: Optional[str]): if self._buffer.icon_name != icon_name: self._buffer.icon_name = icon_name self.queue_draw()
[docs] def get_file_name(self) -> Optional[str]: return self._buffer.file_name
[docs] def set_file_name(self, file_name: Optional[str]): if self._buffer.file_name != file_name: self._buffer.file_name = file_name self.queue_draw()
[docs] def get_pixel_size(self) -> int: return self._buffer.width
[docs] def set_pixel_size(self, size: int): if self._buffer.width != size: self._buffer.width = size self._buffer.height = size self.set_size_request(size, size) self.queue_resize()
[docs] def get_fill_color(self) -> Optional[str]: return self._buffer.fill_color
[docs] def set_fill_color(self, color: Optional[str]): if self._buffer.fill_color != color: self._buffer.fill_color = color self.queue_draw()
[docs] def get_stroke_color(self) -> Optional[str]: return self._buffer.stroke_color
[docs] def set_stroke_color(self, color: Optional[str]): if self._buffer.stroke_color != color: self._buffer.stroke_color = color self.queue_draw()
[docs] def get_xo_color(self) -> Optional[XoColor]: return self._buffer._get_xo_color()
[docs] def set_xo_color(self, xo_color: Optional[XoColor]): if not hasattr(self, '_buffer') or self._buffer is None: self._buffer = _IconBuffer() if self._buffer._get_xo_color() != xo_color: self._buffer._set_xo_color(xo_color) self.queue_draw()
[docs] def get_badge_name(self) -> Optional[str]: return self._buffer.badge_name
[docs] def set_badge_name(self, badge_name: Optional[str]): if self._buffer.badge_name != badge_name: self._buffer.badge_name = badge_name self.queue_resize()
[docs] def get_alpha(self) -> float: return self._buffer.alpha
[docs] def set_alpha(self, alpha: float): if self._buffer.alpha != alpha: self._buffer.alpha = alpha self.queue_draw()
[docs] def get_scale(self) -> float: return self._buffer.scale
[docs] def set_scale(self, scale: float): if self._buffer.scale != scale: self._buffer.scale = scale self.queue_draw()
[docs] def get_badge_size(self) -> int: """Get size of badge icon in pixels.""" return int(_BADGE_SIZE * self.get_pixel_size())
# GObject properties icon_name = GObject.Property( type=str, default=None, getter=get_icon_name, setter=set_icon_name ) file_name = GObject.Property( type=str, default=None, getter=get_file_name, setter=set_file_name ) pixel_size = GObject.Property( type=int, default=STANDARD_ICON_SIZE, getter=get_pixel_size, setter=set_pixel_size, ) fill_color = GObject.Property( type=str, default=None, getter=get_fill_color, setter=set_fill_color ) stroke_color = GObject.Property( type=str, default=None, getter=get_stroke_color, setter=set_stroke_color ) xo_color = GObject.Property( type=object, default=None, getter=get_xo_color, setter=set_xo_color ) badge_name = GObject.Property( type=str, default=None, getter=get_badge_name, setter=set_badge_name ) alpha = GObject.Property( type=float, default=1.0, getter=get_alpha, setter=set_alpha ) scale = GObject.Property( type=float, default=1.0, getter=get_scale, setter=set_scale )
[docs] def get_pixbuf(self) -> Optional[GdkPixbuf.Pixbuf]: """Get pixbuf for this icon.""" return self._buffer.pixbuf
[docs] def set_pixbuf(self, pixbuf: Optional[GdkPixbuf.Pixbuf]): """Set pixbuf for this icon.""" if self._buffer.pixbuf != pixbuf: self._buffer.pixbuf = pixbuf self.queue_draw()
[docs] def get_gtk_image(self) -> Gtk.Image: """ Create a Gtk.Image from this icon for compatibility. Returns: Gtk.Image: Image widget with icon content """ surface = self._buffer.get_surface(self.get_sensitive()) if surface: # Convert surface to pixbuf then to texture pixbuf = Gdk.pixbuf_get_from_surface( surface, 0, 0, surface.get_width(), surface.get_height() ) if pixbuf: texture = Gdk.Texture.new_for_pixbuf(pixbuf) image = Gtk.Image.new_from_paintable(texture) return image return Gtk.Image.new_from_icon_name("image-missing")
[docs] class EventIcon(Icon): """ Icon widget with mouse event handling using GTK4 gestures. Signals: clicked: Emitted when icon is clicked pressed: Emitted when icon is pressed released: Emitted when icon is released activate: Emitted when icon is activated """ __gtype_name__ = "SugarEventIcon" __gsignals__ = { "clicked": (GObject.SignalFlags.RUN_LAST, None, ()), "pressed": (GObject.SignalFlags.RUN_LAST, None, (float, float)), "released": (GObject.SignalFlags.RUN_LAST, None, (float, float)), "activate": (GObject.SignalFlags.RUN_FIRST, None, ()), }
[docs] def __init__(self, **kwargs): super().__init__(**kwargs) # Set up gesture handling self._setup_gestures()
def _setup_gestures(self): """Set up GTK4 gesture controllers.""" # Click gesture click_gesture = Gtk.GestureClick() click_gesture.connect("pressed", self._on_pressed) click_gesture.connect("released", self._on_released) self.add_controller(click_gesture) def _on_pressed(self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float): """Handle press events.""" self.emit("pressed", x, y) def _on_released(self, gesture: Gtk.GestureClick, n_press: int, x: float, y: float): """Handle release events.""" self.emit("released", x, y) if n_press == 1: # Single click # Check if release is within widget bounds width = self.get_width() height = self.get_height() if 0 <= x <= width and 0 <= y <= height: self.emit("clicked") self.emit("activate")
[docs] def get_background_color(self) -> Optional[Gdk.RGBA]: """Get background color.""" return self._buffer.background_color
[docs] def set_background_color(self, color: Optional[Gdk.RGBA]): """Set background color.""" if self._buffer.background_color != color: self._buffer.background_color = color self.queue_draw()
[docs] def get_cache(self) -> bool: """Get cache setting.""" return self._buffer.cache
[docs] def set_cache(self, cache: bool): """Set cache setting.""" self._buffer.cache = cache
# Additional properties for EventIcon background_color = GObject.Property( type=object, default=None, getter=get_background_color, setter=set_background_color, ) cache = GObject.Property( type=bool, default=True, getter=get_cache, setter=set_cache )
[docs] class CanvasIcon(EventIcon): """ An EventIcon with active and prelight states, and a styleable background. This icon responds to mouse hover and press states with visual feedback. """ __gtype_name__ = "SugarCanvasIcon"
[docs] def __init__(self, **kwargs): super().__init__(**kwargs) self._button_down = False # Set up hover and focus controllers self._setup_state_controllers()
def _setup_state_controllers(self): """Set up state change controllers.""" # Motion controller for hover effects motion_controller = Gtk.EventControllerMotion() motion_controller.connect("enter", self._on_enter) motion_controller.connect("leave", self._on_leave) self.add_controller(motion_controller) # Override click gesture to handle states click_gesture = Gtk.GestureClick() click_gesture.connect("pressed", self._on_canvas_pressed) click_gesture.connect("released", self._on_canvas_released) self.add_controller(click_gesture) def _on_enter(self, controller, x, y): """Handle mouse enter.""" self.set_state_flags(Gtk.StateFlags.PRELIGHT, False) if self._button_down: self.set_state_flags(Gtk.StateFlags.ACTIVE, False) def _on_leave(self, controller): """Handle mouse leave.""" # Don't change state if palette is up (would need palette integration) self.unset_state_flags(Gtk.StateFlags.PRELIGHT | Gtk.StateFlags.ACTIVE) def _on_canvas_pressed(self, gesture, n_press, x, y): """Handle canvas press.""" self._button_down = True self.set_state_flags(Gtk.StateFlags.ACTIVE, False) self.emit("pressed", x, y) def _on_canvas_released(self, gesture, n_press, x, y): """Handle canvas release.""" self.unset_state_flags(Gtk.StateFlags.ACTIVE) self._button_down = False self.emit("released", x, y) if n_press == 1: width = self.get_width() height = self.get_height() if 0 <= x <= width and 0 <= y <= height: self.emit("clicked") self.emit("activate")
[docs] def do_snapshot(self, snapshot): """Override to render background based on state.""" # Get allocation and style context width = self.get_width() height = self.get_height() # Render background based on state style_context = self.get_style_context() style_context.save() # Add CSS class for styling style_context.add_class("canvas-icon") # Render background snapshot.render_background(style_context, 0, 0, width, height) style_context.restore() # Call parent to render icon super().do_snapshot(snapshot)
# Cell renderer for GTK4 (simplified - GTK4 uses different approach)
[docs] class CellRendererIcon: """ Icon renderer for use in list/tree views. Note: GTK4 uses a different approach for cell rendering. This is provided for compatibility but may need adaptation. """
[docs] def __init__(self): self._buffer = _IconBuffer() self._buffer.cache = True self._xo_color = None self._prelit_fill_color = None self._prelit_stroke_color = None
[docs] def set_icon_name(self, icon_name: str): self._buffer.icon_name = icon_name
[docs] def set_file_name(self, file_name: str): self._buffer.file_name = file_name
[docs] def set_xo_color(self, xo_color: XoColor): self._xo_color = xo_color
[docs] def set_fill_color(self, color: str): self._buffer.fill_color = color
[docs] def set_stroke_color(self, color: str): self._buffer.stroke_color = color
[docs] def set_size(self, size: int): self._buffer.width = size self._buffer.height = size
[docs] def get_surface(self, sensitive: bool = True) -> Optional[cairo.ImageSurface]: """Get rendered surface.""" if self._xo_color: self._buffer.fill_color = self._xo_color.get_fill_color() self._buffer.stroke_color = self._xo_color.get_stroke_color() return self._buffer.get_surface(sensitive)
# Utility functions
[docs] def get_icon_file_name(icon_name: str) -> Optional[str]: """ Resolve icon name to file path using GTK4 icon theme. Args: icon_name: Name of icon to resolve Returns: Path to icon file or None if not found """ icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) icon_paintable = icon_theme.lookup_icon( icon_name, None, STANDARD_ICON_SIZE, 1, Gtk.TextDirection.NONE, Gtk.IconLookupFlags.NONE, ) if icon_paintable: file_obj = icon_paintable.get_file() if file_obj: return file_obj.get_path() return None
[docs] def get_icon_state(base_name: str, perc: float, step: int = 5) -> Optional[str]: """ Get the closest icon name for a given state in percent. Args: base_name: Base icon name (e.g., 'network-wireless') perc: Desired percentage between 0 and 100 step: Step increment to find possible icons Returns: Icon name that represents given state, or None if not found """ strength = round(perc / step) * step icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) while 0 <= strength <= 100: icon_name = f"{base_name}-{strength:03d}" if icon_theme.has_icon(icon_name): return icon_name strength += step return None
[docs] def get_surface( icon_name: Optional[str] = None, file_name: Optional[str] = None, fill_color: Optional[str] = None, stroke_color: Optional[str] = None, pixel_size: int = STANDARD_ICON_SIZE, **kwargs, ) -> Optional[cairo.ImageSurface]: """ Get cairo surface for an icon with specified properties. Args: icon_name: Icon name from theme file_name: Path to icon file fill_color: Fill color as hex string stroke_color: Stroke color as hex string pixel_size: Size in pixels **kwargs: Additional properties Returns: Cairo surface or None if icon not found """ buffer = _IconBuffer() buffer.icon_name = icon_name buffer.file_name = file_name buffer.fill_color = fill_color buffer.stroke_color = stroke_color buffer.width = pixel_size buffer.height = pixel_size for key, value in kwargs.items(): if hasattr(buffer, key): setattr(buffer, key, value) return buffer.get_surface()
# Import Graphene for GTK4 snapshot operations try: gi.require_version("Graphene", "1.0") from gi.repository import Graphene except (ImportError, ValueError): logging.warning("Graphene not available, icon rendering may be limited") # Provide fallback class _GraphenePoint: def init(self, x, y): self.x, self.y = x, y return self class _GrapheneRect: def init(self, x, y, width, height): self.x, self.y, self.width, self.height = x, y, width, height return self class _GrapheneMock: Point = _GraphenePoint Rect = _GrapheneRect Graphene = _GrapheneMock() if hasattr(CanvasIcon, "set_css_name"): CanvasIcon.set_css_name("canvas-icon")