From 5701f6c22a82441a601792dcf9ffeee3453796ff Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 23 Jul 2026 13:21:40 +0545 Subject: [PATCH] feat: add BlockProgressBar widget with animation support --- src/shared/widgets/block_progress_bar.py | 284 +++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/shared/widgets/block_progress_bar.py diff --git a/src/shared/widgets/block_progress_bar.py b/src/shared/widgets/block_progress_bar.py new file mode 100644 index 0000000..b85f83a --- /dev/null +++ b/src/shared/widgets/block_progress_bar.py @@ -0,0 +1,284 @@ +import math +from functools import partial +from typing import Iterable, Literal + +from fabric.utils import Gdk, GObject, Gtk, cairo +from fabric.widgets.widget import Widget + +from shared.widgets.animator import Animator, cubic_bezier + + +def _draw_rounded_rect(cr, x, y, w, h, r): + r = min(r, w / 2, h / 2) + if r <= 0: + cr.rectangle(x, y, w, h) + return + cr.new_sub_path() + cr.move_to(x + r, y) + cr.line_to(x + w - r, y) + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.line_to(x + w, y + h - r) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.line_to(x + r, y + h) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.line_to(x, y + r) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +class BlockProgressBar(Gtk.DrawingArea, Widget): + __gsignals__ = { + "value-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), + } + + def __init__( + self, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 100.0, + block_count: int = 20, + block_spacing: float = 2.0, + block_height: float = 10.0, + block_radius: float = 3.0, + animation_duration: float = 0.8, + orientation: Literal["horizontal", "vertical"] + | Gtk.Orientation = Gtk.Orientation.HORIZONTAL, + name: str | None = None, + visible: bool = True, + all_visible: bool = False, + style: str | None = None, + style_classes: Iterable[str] | str | None = None, + h_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + v_align: Literal["fill", "start", "end", "center", "baseline"] + | Gtk.Align + | None = None, + h_expand: bool = False, + v_expand: bool = False, + size: Iterable[int] | int | None = None, + **kwargs, + ): + Gtk.DrawingArea.__init__(self) + Widget.__init__( + self, + name=name, + visible=visible, + all_visible=all_visible, + style=style, + style_classes=style_classes, + h_align=h_align, + v_align=v_align, + h_expand=h_expand, + v_expand=v_expand, + size=size, + **kwargs, + ) + + self._value = max(min_value, min(value, max_value)) + self._min_value = min_value + self._max_value = max_value + self._block_count = block_count + self._block_spacing = block_spacing + self._block_height = block_height + self._block_radius = block_radius + self._animation_duration = animation_duration + + if isinstance(orientation, str): + self._orientation = ( + Gtk.Orientation.HORIZONTAL + if orientation == "horizontal" + else Gtk.Orientation.VERTICAL + ) + else: + self._orientation = orientation + + self._bg_ctx = self._create_gadget_context("block-bg") + self._fg_ctx = self._create_gadget_context("block-fg") + + self.get_style_context().add_class("block-progress-bar") + + self._animator = Animator( + duration=self._animation_duration, + timing_function=partial(cubic_bezier, 0.34, 1.56, 0.64, 1.0), + min_value=self._min_value, + max_value=self._value, + tick_widget=self, + notify_value=lambda anim: self._on_animator_tick(anim), + ) + self._animator.build().play().unwrap() + + self.connect("realize", self._on_realize) + self.show_all() + + def _create_gadget_context(self, node_name: str) -> Gtk.StyleContext: + ctx = Gtk.StyleContext() + ctx.set_parent(self.get_style_context()) + ctx.set_screen(self.get_screen()) + self._update_gadget_path(ctx, node_name) + ctx.connect("changed", lambda *_: self._update_gadget_path(ctx, node_name)) + return ctx + + def _update_gadget_path(self, context: Gtk.StyleContext, node_name: str) -> None: + parent_ctx = self.get_style_context() + new_path = parent_ctx.get_path().copy() + for cls in list(new_path.iter_list_classes(-1)): + new_path.iter_remove_class(-1, cls) + for cls in parent_ctx.list_classes(): + if not new_path.iter_has_class(-1, cls): + new_path.iter_add_class(-1, cls) + new_path.append_type(GObject.TYPE_NONE) + new_path.iter_set_object_name(-1, node_name) + context.set_path(new_path) + context.set_state(self.get_state_flags()) + + def _on_realize(self, _widget): + self.queue_draw() + + @property + def value(self) -> float: + return self._value + + @value.setter + def value(self, v: float): + self.set_value(v) + + def set_value(self, value: float) -> None: + new = max(self._min_value, min(value, self._max_value)) + if new != self._value: + self._value = new + self.emit("value-changed", self._value) + self.queue_draw() + + def get_value(self) -> float: + return self._value + + def animate_value(self, value: float) -> None: + target = max(self._min_value, min(value, self._max_value)) + if target == self._value: + return + if self._animator: + self._animator.pause() + self._animator.min_value = self._value + self._animator.max_value = target + self._animator.play() + + def _on_animator_tick(self, anim): + self._value = anim.value + self.queue_draw() + + def _is_horizontal(self) -> bool: + return self._orientation == Gtk.Orientation.HORIZONTAL + + def _normalize_value(self) -> float: + value_range = self._max_value - self._min_value + if value_range == 0: + return 0.0 + return max(0.0, min(1.0, (self._value - self._min_value) / value_range)) + + def do_get_request_mode(self): + return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH + + def do_get_preferred_width(self): + if self._is_horizontal(): + min_w = self._block_height + return (min_w, min_w) + else: + return (self._block_height, self._block_height) + + def do_get_preferred_height_for_width(self, width): + return self.do_get_preferred_height() + + def do_get_preferred_height(self): + if self._is_horizontal(): + return (self._block_height, self._block_height) + else: + block_count = self._block_count + total_gaps = max(0, block_count - 1) * self._block_spacing + min_h = block_count * self._block_height + total_gaps + return (min_h, min_h) + + def do_draw(self, cr: cairo.Context) -> bool: + width = self.get_allocated_width() + height = self.get_allocated_height() + if width <= 0 or height <= 0: + return False + + normalized = self._normalize_value() + filled_count = int(normalized * self._block_count) + fractional = (normalized * self._block_count) - filled_count + + state = self.get_state_flags() + bg_color = self._bg_ctx.get_background_color(state) + fg_color = self._fg_ctx.get_background_color(state) + + if self._is_horizontal(): + total_gaps = max(0, self._block_count - 1) * self._block_spacing + block_w = self._block_height + total_bar_w = self._block_count * block_w + total_gaps + x_offset = max(0.0, (width - total_bar_w) / 2.0) + y = (height - self._block_height) / 2.0 + + for i in range(self._block_count): + bx = x_offset + i * (block_w + self._block_spacing) + + if i < filled_count: + Gdk.cairo_set_source_rgba(cr, fg_color) + elif i == filled_count and fractional > 0: + r = fg_color.red + (bg_color.red - fg_color.red) * ( + 1.0 - fractional + ) + g = fg_color.green + (bg_color.green - fg_color.green) * ( + 1.0 - fractional + ) + b = fg_color.blue + (bg_color.blue - fg_color.blue) * ( + 1.0 - fractional + ) + a = fg_color.alpha + (bg_color.alpha - fg_color.alpha) * ( + 1.0 - fractional + ) + cr.set_source_rgba(r, g, b, a) + else: + Gdk.cairo_set_source_rgba(cr, bg_color) + + _draw_rounded_rect( + cr, bx, y, block_w, self._block_height, self._block_radius + ) + cr.fill() + else: + total_gaps = max(0, self._block_count - 1) * self._block_spacing + block_h = max(1.0, (height - total_gaps) / self._block_count) + x = (width - self._block_height) / 2.0 + y = 0.0 + + for i in range(self._block_count): + by = y + i * (block_h + self._block_spacing) + reversed_i = self._block_count - 1 - i + is_filled = reversed_i < filled_count + is_fractional = reversed_i == filled_count and fractional > 0 + + if is_filled: + Gdk.cairo_set_source_rgba(cr, fg_color) + elif is_fractional: + r = fg_color.red + (bg_color.red - fg_color.red) * ( + 1.0 - fractional + ) + g = fg_color.green + (bg_color.green - fg_color.green) * ( + 1.0 - fractional + ) + b = fg_color.blue + (bg_color.blue - fg_color.blue) * ( + 1.0 - fractional + ) + a = fg_color.alpha + (bg_color.alpha - fg_color.alpha) * ( + 1.0 - fractional + ) + cr.set_source_rgba(r, g, b, a) + else: + Gdk.cairo_set_source_rgba(cr, bg_color) + + _draw_rounded_rect( + cr, x, by, self._block_height, block_h, self._block_radius + ) + cr.fill() + + return False