Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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. |
Expand Down
7 changes: 7 additions & 0 deletions docking/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
184 changes: 183 additions & 1 deletion docking/ui/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions docking/ui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions tests/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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="",
)

Expand All @@ -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"


Expand Down Expand Up @@ -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",
}
)
)
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/ui/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading