diff --git a/README.md b/README.md index edf19716..fc999442 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ Config is stored at `~/.config/docking/dock.json` (auto-created on first run). N "active_display": false, "left_click_action": "toggle", "middle_click_action": "new-window", + "folder_stack_unfold": "hover", + "show_window_count_numbers": false, "theme": "default", "transparency": 1.0, "pinned": [ @@ -201,6 +203,8 @@ Config is stored at `~/.config/docking/dock.json` (auto-created on first run). N | `update_check_interval_hours` | 24 | Minimum hours between automatic update checks | | `left_click_action` | toggle | Running-app left click: `toggle`, `cycle`, or `most-recent` | | `middle_click_action` | new-window | Application middle click: `new-window`, `minimize`, or `close-focused` | +| `folder_stack_unfold` | hover | Folder stack open behavior: `hover` or `click` | +| `show_window_count_numbers` | false | Show numeric window counts inside running indicators | | `theme` | default | Theme name (loads from `~/.config/docking/themes/{name}.json` first, then built-in themes) | | `transparency` | 1.0 | Multiplier applied to theme alpha from `0.15` to `1.0` (`1.0` = full theme opacity) | | `pinned` | [] | Ordered pinned entries for apps, applets, files, and folders. First run seeds a starter set. | diff --git a/docking/core/config.py b/docking/core/config.py index a1bed7db..4f86c99c 100644 --- a/docking/core/config.py +++ b/docking/core/config.py @@ -452,6 +452,7 @@ class FolderStackUnfold(str, Enum): DEFAULT_LEFT_CLICK_ACTION = LeftClickAction.TOGGLE.value DEFAULT_MIDDLE_CLICK_ACTION = MiddleClickAction.NEW_WINDOW.value DEFAULT_FOLDER_STACK_UNFOLD = FolderStackUnfold.HOVER.value +DEFAULT_SHOW_WINDOW_COUNT_NUMBERS = False def _normalize_left_click_action(value: object) -> str: @@ -722,6 +723,8 @@ class Config: middle_click_action: str = DEFAULT_MIDDLE_CLICK_ACTION # How pinned folder stacks open from the dock folder_stack_unfold: str = DEFAULT_FOLDER_STACK_UNFOLD + # Whether running application indicators show a numeric window count + show_window_count_numbers: bool = DEFAULT_SHOW_WINDOW_COUNT_NUMBERS # Theme name (loads from assets/themes/{name}.json) theme: str = DEFAULT_THEME # Multiplier applied to theme alpha values for the dock shelf @@ -842,6 +845,10 @@ def __post_init__(self) -> None: self.folder_stack_unfold = _normalize_folder_stack_unfold( self.folder_stack_unfold, ) + self.show_window_count_numbers = _normalize_bool( + self.show_window_count_numbers, + default=DEFAULT_SHOW_WINDOW_COUNT_NUMBERS, + ) self.theme = _normalize_theme(self.theme) self.transparency = _normalize_transparency(self.transparency) self.pinned = normalize_pinned_entries(list(self.pinned)) diff --git a/docking/ui/renderer.py b/docking/ui/renderer.py index 4bc1dc1b..b119ecfd 100644 --- a/docking/ui/renderer.py +++ b/docking/ui/renderer.py @@ -364,6 +364,147 @@ def _draw_indicator_dashes( cr.fill() +def _window_count_label(count: int) -> str: + return "99+" if count > 99 else str(max(1, count)) + + +def _window_count_bar_height(*, base_size: int, radius: float) -> float: + return max(radius * 3.0, base_size * 0.18) + + +def _window_count_dot_height(*, count: int, base_size: int, radius: float) -> float: + if count <= 1: + return radius * 2.0 + return float(round(max(radius * 4.2, base_size * 0.22))) + + +def _snap_count_indicator_rect( + *, + cx: float, + cy: float, + width: float, + height: float, +) -> tuple[float, float, float, float]: + width = float(max(1, round(width))) + height = float(max(1, round(height))) + return ( + float(round(cx - width / 2.0)), + float(round(cy - height / 2.0)), + width, + height, + ) + + +def _inset_count_indicator_anchor( + *, + cx: float, + cy: float, + cross_extent: float, + radius: float, + pos: Position, +) -> tuple[float, float]: + extra_inset = max(0.0, cross_extent / 2.0 - radius) + if pos == Position.BOTTOM: + cy -= extra_inset + elif pos == Position.TOP: + cy += extra_inset + elif pos == Position.LEFT: + cx += extra_inset + else: # RIGHT + cx -= extra_inset + return cx, cy + + +def _draw_indicator_count_bar( + *, + cr: cairo.Context, + cx: float, + cy: float, + count: int, + base_size: int, + radius: float, +) -> None: + """Draw one running indicator bar, adding a count when there are many windows.""" + label = _window_count_label(count) + show_label = count > 1 + height = _window_count_bar_height(base_size=base_size, radius=radius) + min_width = max(radius * 8.0, base_size * 0.38) + + cr.save() + cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(max(8.0, base_size * 0.16)) + ext = cr.text_extents(label) + padding_x = height * 0.45 + width = max(min_width, ext.width + 2.0 * padding_x) if show_label else min_width + x, y, width, height = _snap_count_indicator_rect( + cx=cx, + cy=cy, + width=width, + height=height, + ) + + rounded_rect(cr, x, y, width, height, height / 2.0) + cr.fill() + + if show_label: + ascent, descent, *_ = cr.font_extents() + center_x = x + width / 2.0 + center_y = y + height / 2.0 + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.set_source_rgba(1.0, 1.0, 1.0, 0.92) + text_x = center_x - (ext.width / 2.0 + ext.x_bearing) + text_y = center_y + (ascent - descent) / 2.0 + cr.move_to(text_x, text_y) + cr.show_text(label) + + cr.restore() + + +def _draw_indicator_count_dots( + *, + cr: cairo.Context, + cx: float, + cy: float, + count: int, + base_size: int, + radius: float, +) -> None: + """Draw a compact numeric dot in the running-indicator position.""" + if count <= 1: + cr.arc(cx, cy, radius, 0, 2 * math.pi) + cr.fill() + return + + label = _window_count_label(count) + cr.save() + cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + cr.set_font_size(max(8.0, base_size * 0.15)) + ext = cr.text_extents(label) + height = _window_count_dot_height(count=count, base_size=base_size, radius=radius) + width = height if count < 10 else max(height, ext.width + height * 0.55) + x, y, width, height = _snap_count_indicator_rect( + cx=cx, + cy=cy, + width=width, + height=height, + ) + + rounded_rect(cr, x, y, width, height, height / 2.0) + cr.fill() + + ascent, descent, *_ = cr.font_extents() + center_x = x + width / 2.0 + center_y = y + height / 2.0 + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.set_source_rgba(1.0, 1.0, 1.0, 0.92) + cr.move_to( + center_x - (ext.width / 2.0 + ext.x_bearing), + center_y + (ascent - descent) / 2.0, + ) + cr.show_text(label) + cr.restore() + + def compute_urgent_glow_opacity( elapsed_us: int, glow_time_ms: int, pulse_ms: int ) -> float: @@ -768,6 +909,7 @@ def _draw_content( cr=cr, item=item, li=li, + show_window_count_numbers=config.show_window_count_numbers, base_size=icon_size, main_pos=icon_offset + slide + drop_shift, cross_size=cross_size, @@ -1188,6 +1330,7 @@ def _draw_indicator( cr: cairo.Context, item: DockItem, li: LayoutItem, + show_window_count_numbers: bool, base_size: int, main_pos: float, cross_size: float, @@ -1220,7 +1363,46 @@ def _draw_indicator( else: # RIGHT cx, cy = cross_size - edge_padding / 2 + hide_cross, main_center - if theme.indicator_style == IndicatorStyle.DASHES: + if show_window_count_numbers and theme.indicator_style == IndicatorStyle.DASHES: + count_cx, count_cy = _inset_count_indicator_anchor( + cx=cx, + cy=cy, + cross_extent=_window_count_bar_height( + base_size=base_size, + radius=radius, + ), + radius=radius, + pos=pos, + ) + _draw_indicator_count_bar( + cr=cr, + cx=count_cx, + cy=count_cy, + count=item.instance_count, + base_size=base_size, + radius=radius, + ) + elif show_window_count_numbers: + count_cx, count_cy = _inset_count_indicator_anchor( + cx=cx, + cy=cy, + cross_extent=_window_count_dot_height( + count=item.instance_count, + base_size=base_size, + radius=radius, + ), + radius=radius, + pos=pos, + ) + _draw_indicator_count_dots( + cr=cr, + cx=count_cx, + cy=count_cy, + count=item.instance_count, + base_size=base_size, + radius=radius, + ) + elif theme.indicator_style == IndicatorStyle.DASHES: _draw_indicator_dashes(cr, cx, cy, radius, spacing, count, horizontal) else: # DOTS for j in range(count): diff --git a/docking/ui/settings.py b/docking/ui/settings.py index 6a2c2ba3..e5e66e9a 100644 --- a/docking/ui/settings.py +++ b/docking/ui/settings.py @@ -144,6 +144,7 @@ def __init__( self._left_click_combo: Any = None self._middle_click_combo: Any = None self._folder_stack_unfold_combo: Any = None + self._window_count_numbers_switch: Any = None self._previews_switch: Any = None self._tooltips_switch: Any = None self._lock_icons_switch: Any = None @@ -270,6 +271,7 @@ def _build_appearance_tab(self) -> Gtk.Widget: self._previews_switch = self._new_switch() self._tooltips_switch = self._new_switch() + self._window_count_numbers_switch = self._new_switch() self._lock_icons_switch = self._new_switch() self._workspace_only_switch = self._new_switch() self._active_display_switch = self._new_switch() @@ -343,6 +345,7 @@ def _build_appearance_tab(self) -> Gtk.Widget: (_("Zoom Percent"), self._zoom_percent_spin), (_("Show Tooltips"), self._tooltips_switch), (_("Window Previews"), self._previews_switch), + ("Show Window Counts", self._window_count_numbers_switch), ], ) self._append_section( @@ -537,6 +540,11 @@ def _register_bindings(self) -> None: config_attr="folder_stack_unfold", widget=self._folder_stack_unfold_combo, ), + self._register_switch_binding( + config_attr="show_window_count_numbers", + widget=self._window_count_numbers_switch, + on_change=lambda _value: self._runtime.queue_draw(), + ), self._register_switch_binding( config_attr="previews_enabled", widget=self._previews_switch, diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 9bf847fb..2802b831 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -38,6 +38,7 @@ def test_defaults(self): assert c.left_click_action == "toggle" assert c.middle_click_action == "new-window" assert c.folder_stack_unfold == "hover" + assert c.show_window_count_numbers is False assert c.theme == "default" assert c.transparency == 1.0 assert isinstance(c.pinned, list) @@ -80,6 +81,7 @@ def test_post_init_normalizes_invalid_runtime_values(self): active_display="no", update_check_enabled="off", update_check_interval_hours="bad", + show_window_count_numbers="on", theme="", ) @@ -97,6 +99,7 @@ def test_post_init_normalizes_invalid_runtime_values(self): assert c.active_display is False assert c.update_check_enabled is False assert c.update_check_interval_hours == 24 + assert c.show_window_count_numbers is True assert c.theme == "default" @@ -258,6 +261,7 @@ def test_load_normalizes_bool_like_values(self, tmp_path): "left_click_action": "cycle", "middle_click_action": "minimize", "folder_stack_unfold": "hover", + "show_window_count_numbers": "true", } ) ) @@ -271,6 +275,7 @@ def test_load_normalizes_bool_like_values(self, tmp_path): assert config.left_click_action == "cycle" assert config.middle_click_action == "minimize" assert config.folder_stack_unfold == "hover" + assert config.show_window_count_numbers is True def test_load_clamps_transparency_to_minimum(self, tmp_path): path = tmp_path / "dock.json" @@ -490,6 +495,15 @@ def test_save_persists_folder_stack_unfold(self, tmp_path): saved = json.loads(path.read_text()) assert saved["folder_stack_unfold"] == "hover" + def test_save_persists_show_window_count_numbers(self, tmp_path): + path = tmp_path / "dock.json" + config = Config(show_window_count_numbers=True) + + config.save(path) + + saved = json.loads(path.read_text()) + assert saved["show_window_count_numbers"] is True + def test_save_persists_transparency(self, tmp_path): path = tmp_path / "dock.json" config = Config(transparency=0.65) diff --git a/tests/ui/test_renderer.py b/tests/ui/test_renderer.py index 390794ca..c4b44d2f 100644 --- a/tests/ui/test_renderer.py +++ b/tests/ui/test_renderer.py @@ -8,6 +8,8 @@ from docking.ui.renderer import ( SHELF_SMOOTH_FACTOR, DockRenderer, + _snap_count_indicator_rect, + _window_count_dot_height, ) # Default theme at 48px for hover lighten tests @@ -16,6 +18,19 @@ _FADE_FRAMES = max(1, _DEFAULT_THEME.active_time_ms // 16) +class TestWindowCountIndicatorGeometry: + def test_count_dot_height_uses_whole_pixels(self): + assert _window_count_dot_height(count=2, base_size=48, radius=2.5) == 11.0 + + def test_count_indicator_rect_snaps_to_whole_pixels(self): + assert _snap_count_indicator_rect( + cx=50.3, + cy=74.6, + width=10.56, + height=10.56, + ) == (45.0, 69.0, 11.0, 11.0) + + class TestSmoothShelfW: def test_initial_value_is_zero(self): # Given / When diff --git a/tests/ui/test_renderer_integration.py b/tests/ui/test_renderer_integration.py index 7e813412..92dd2579 100644 --- a/tests/ui/test_renderer_integration.py +++ b/tests/ui/test_renderer_integration.py @@ -159,7 +159,9 @@ def test_draw_content_runs_icons_indicators_and_urgent_glow(self, monkeypatch): # Given renderer = renderer_mod.DockRenderer() theme = Theme.load("default", 48) - config = SimpleNamespace(pos=Position.BOTTOM, icon_size=48) + config = SimpleNamespace( + pos=Position.BOTTOM, icon_size=48, show_window_count_numbers=False + ) i1 = DockItem( desktop_id="firefox.desktop", is_active=True, @@ -218,7 +220,9 @@ def test_draw_content_runs_icons_indicators_and_urgent_glow(self, monkeypatch): def test_draw_content_uses_frame_background_rect_for_shelf(self, monkeypatch): renderer = renderer_mod.DockRenderer() theme = Theme.load("default", 48) - config = SimpleNamespace(pos=Position.BOTTOM, icon_size=48) + config = SimpleNamespace( + pos=Position.BOTTOM, icon_size=48, show_window_count_numbers=False + ) item = DockItem(desktop_id="firefox.desktop", is_running=True) model = MagicMock() model.visible_items.return_value = [item] @@ -273,7 +277,9 @@ def test_draw_content_uses_frame_background_rect_for_shelf(self, monkeypatch): def test_draw_content_dispatches_badge_and_progress_overlays(self, monkeypatch): renderer = renderer_mod.DockRenderer() theme = Theme.load("default", 48) - config = SimpleNamespace(pos=Position.BOTTOM, icon_size=48) + config = SimpleNamespace( + pos=Position.BOTTOM, icon_size=48, show_window_count_numbers=False + ) item = DockItem( desktop_id="firefox.desktop", is_running=True, @@ -310,7 +316,9 @@ def test_draw_content_dispatches_badge_and_progress_overlays(self, monkeypatch): def test_draw_content_hides_shelf_when_dock_is_hidden_with_gap(self, monkeypatch): renderer = renderer_mod.DockRenderer() theme = replace(Theme.load("default", 48), distance_from_edge=6) - config = SimpleNamespace(pos=Position.BOTTOM, icon_size=48) + config = SimpleNamespace( + pos=Position.BOTTOM, icon_size=48, show_window_count_numbers=False + ) frame = build_geometry_frame( items=[DockItem(desktop_id="firefox.desktop", is_running=True)], config=SimpleNamespace( @@ -398,7 +406,9 @@ def test_draw_content_returns_early_for_empty_items(self): renderer = renderer_mod.DockRenderer() model = MagicMock() model.visible_items.return_value = [] - config = SimpleNamespace(pos=Position.BOTTOM, icon_size=48) + config = SimpleNamespace( + pos=Position.BOTTOM, icon_size=48, show_window_count_numbers=False + ) theme = Theme.load("default", 48) cr = _surface_context() @@ -520,6 +530,7 @@ def test_draw_indicator_handles_all_positions(self, pos): cr=cr, item=item, li=li, + show_window_count_numbers=False, base_size=48, main_pos=5.0, cross_size=80.0, @@ -529,3 +540,87 @@ def test_draw_indicator_handles_all_positions(self, pos): # Then # When ) + + @pytest.mark.parametrize( + "theme", [Theme.load("default", 48), Theme.load("slate", 48)] + ) + def test_draw_indicator_supports_numbered_window_counts(self, theme): + cr = _surface_context() + item = DockItem(desktop_id="x.desktop", instance_count=12, is_active=True) + li = SimpleNamespace(x=10.0, scale=1.0) + + renderer_mod.DockRenderer._draw_indicator( + cr=cr, + item=item, + li=li, + show_window_count_numbers=True, + base_size=48, + main_pos=5.0, + cross_size=80.0, + hide_cross=0.0, + theme=theme, + pos=Position.BOTTOM, + ) + + @pytest.mark.parametrize( + ("pos", "expected_axis", "expected_direction"), + [ + (Position.BOTTOM, "cy", -1.0), + (Position.TOP, "cy", 1.0), + (Position.LEFT, "cx", 1.0), + (Position.RIGHT, "cx", -1.0), + ], + ) + def test_draw_indicator_insets_numbered_dot_from_screen_edge( + self, monkeypatch, pos, expected_axis, expected_direction + ): + # Given + cr = _surface_context() + theme = Theme.load("default", 48) + item = DockItem(desktop_id="x.desktop", instance_count=12, is_active=True) + li = SimpleNamespace(x=10.0, scale=1.0) + calls = [] + original_main_center = 39.0 + original_edge_center = ( + 80.0 - theme.bottom_padding / 2.0 + if pos in (Position.BOTTOM, Position.RIGHT) + else theme.bottom_padding / 2.0 + ) + count_height = renderer_mod._window_count_dot_height( + count=item.instance_count, + base_size=48, + radius=theme.indicator_radius, + ) + count_inset = count_height / 2.0 - theme.indicator_radius + + monkeypatch.setattr( + renderer_mod, + "_draw_indicator_count_dots", + lambda **kwargs: calls.append(kwargs), + ) + + # When + renderer_mod.DockRenderer._draw_indicator( + cr=cr, + item=item, + li=li, + show_window_count_numbers=True, + base_size=48, + main_pos=5.0, + cross_size=80.0, + hide_cross=0.0, + theme=theme, + pos=pos, + ) + + # Then + if expected_axis == "cy": + assert calls[0]["cx"] == pytest.approx(original_main_center) + assert calls[0]["cy"] == pytest.approx( + original_edge_center + count_inset * expected_direction + ) + else: + assert calls[0]["cx"] == pytest.approx( + original_edge_center + count_inset * expected_direction + ) + assert calls[0]["cy"] == pytest.approx(original_main_center) diff --git a/tests/ui/test_settings.py b/tests/ui/test_settings.py index beb79cb7..b07a5b16 100644 --- a/tests/ui/test_settings.py +++ b/tests/ui/test_settings.py @@ -477,6 +477,7 @@ def _config(): left_click_action="toggle", middle_click_action="new-window", folder_stack_unfold="click", + show_window_count_numbers=False, lock_icons=False, current_workspace_only=False, active_display=False, @@ -745,6 +746,33 @@ def test_folder_stack_unfold_binding_updates_config(self, monkeypatch): config.save.assert_called_once() runtime.assert_not_called() + def test_show_window_count_numbers_binding_updates_config_and_redraws( + self, monkeypatch + ): + monkeypatch.setattr(settings_mod, "Gtk", FakeGtk) + monkeypatch.setattr( + settings_mod, + "load_catalog_icon", + lambda applet_id, size: None, + ) + monkeypatch.setattr(settings_mod, "get_applet_catalog", dict) + runtime = MagicMock() + config = _config() + controller = settings_mod.SettingsWindowController( + parent=object(), + runtime=runtime, + model=SimpleNamespace(pinned_items=[], get_applet=lambda _desktop_id: None), + config=config, + ) + + controller.show() + controller._window_count_numbers_switch.set_active(True) + controller._window_count_numbers_switch.emit_notify_active() + + assert config.show_window_count_numbers is True + config.save.assert_called_once() + runtime.queue_draw.assert_called_once() + def test_hide_mode_change_updates_runtime(self, monkeypatch): monkeypatch.setattr(settings_mod, "Gtk", FakeGtk) monkeypatch.setattr( diff --git a/tests/visual/render_cases.py b/tests/visual/render_cases.py index 4132ee49..6278a3a5 100644 --- a/tests/visual/render_cases.py +++ b/tests/visual/render_cases.py @@ -82,6 +82,7 @@ def _renderer_config() -> SimpleNamespace: icon_size=ICON_SIZE, zoom_percent=2.0, zoom_enabled=True, + show_window_count_numbers=False, applet_prefs={}, )