diff --git a/src/utils/constants.py b/src/utils/constants.py index a877eff..bee82f4 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -11,6 +11,7 @@ DEFAULT = { }, "dock": { "enabled": True, + "position": "bottom", "auto_hide": True, "always_occluded": False, "icon_size": 52, @@ -62,6 +63,8 @@ def generate_default_toml() -> str: dock = _table() dock.add("enabled", True) + dock.add("position", "bottom") + dock["position"].comment("'bottom' | 'left' | 'right'") dock.add("auto_hide", True) dock.add("always_occluded", False) dock.add("icon_size", 52) diff --git a/src/window/dock/canvas.py b/src/window/dock/canvas.py index f21c1fa..97acfbe 100644 --- a/src/window/dock/canvas.py +++ b/src/window/dock/canvas.py @@ -45,6 +45,8 @@ from .constants import ( PINNED_APPS_FILE, SEPARATOR_COLOR, SEPARATOR_WIDTH, + dock_position, + is_vertical, max_scale, ) from .items import DockHitTest, DockItem, DockModel @@ -140,13 +142,13 @@ class DockCanvas(Gtk.DrawingArea): self._desktop_apps = [] return self._desktop_apps - def _canvas_height(self) -> int: + def _canvas_edge_thickness(self) -> int: size = self._base_icon_size() return int( size * max_scale() + CANVAS_TOP_PAD + 2 * BG_PADDING_V + INDICATOR_H + 4 ) - def _canvas_max_width(self) -> int: + def _canvas_edge_length(self) -> int: n = len(self.model.items) if n == 0: return 200 @@ -155,6 +157,16 @@ class DockCanvas(Gtk.DrawingArea): n * size * max_scale() + max(n - 1, 0) * ICON_GAP + 2 * BG_PADDING_H + 40 ) + def _canvas_height(self) -> int: + if is_vertical(): + return self._canvas_edge_length() + return self._canvas_edge_thickness() + + def _canvas_max_width(self) -> int: + if is_vertical(): + return self._canvas_edge_thickness() + return self._canvas_edge_length() + def _canvas_min_width(self) -> int: return self._canvas_max_width() @@ -545,7 +557,7 @@ class DockCanvas(Gtk.DrawingArea): self._draw_background(cr, bg_x, bg_y, bg_w, bg_h) if self.model.has_pinned() and self.model.has_running_only(): - self._draw_separator(cr, items, bg_y, bg_h, base_size) + self._draw_separator(cr, items, bg_x, bg_y, bg_w, bg_h) for item in items: self._draw_item(cr, item, base_size, h) @@ -592,29 +604,39 @@ class DockCanvas(Gtk.DrawingArea): self, cr: cairo.Context, items: List[DockItem], + bg_x: float, bg_y: float, + bg_w: float, bg_h: float, - base_size: int, ) -> None: - last_pinned_x: Optional[float] = None - first_running_x: Optional[float] = None + vertical = is_vertical() + last_pinned_c: Optional[float] = None + first_running_c: Optional[float] = None for item in items: if item.is_pinned: - last_pinned_x = item.render_x + item.render_w - elif first_running_x is None: - first_running_x = item.render_x + last_pinned_c = ( + item.render_y + item.render_h + if vertical + else item.render_x + item.render_w + ) + elif first_running_c is None: + first_running_c = item.render_y if vertical else item.render_x - if last_pinned_x is None or first_running_x is None: + if last_pinned_c is None or first_running_c is None: return - sep_x = (last_pinned_x + first_running_x) / 2.0 + sep_c = (last_pinned_c + first_running_c) / 2.0 sep_margin = BG_PADDING_V + 4 cr.save() cr.set_source_rgba(*SEPARATOR_COLOR) cr.set_line_width(SEPARATOR_WIDTH) - cr.move_to(sep_x, bg_y + sep_margin) - cr.line_to(sep_x, bg_y + bg_h - sep_margin) + if vertical: + cr.move_to(bg_x + sep_margin, sep_c) + cr.line_to(bg_x + bg_w - sep_margin, sep_c) + else: + cr.move_to(sep_c, bg_y + sep_margin) + cr.line_to(sep_c, bg_y + bg_h - sep_margin) cr.stroke() cr.restore() @@ -664,10 +686,14 @@ class DockCanvas(Gtk.DrawingArea): cr.restore() if item.instance_address and item.instance_address == self._focused_address: - dot_cx = ix + iw / 2.0 - dot_cy = iy + ih + 4 cr.save() cr.set_source_rgba(*INDICATOR_COLOR) + if is_vertical(): + dot_cx = ix + iw + 4 if dock_position() == "right" else ix - 4 + dot_cy = iy + ih / 2.0 + else: + dot_cx = ix + iw / 2.0 + dot_cy = iy + ih + 4 cr.arc(dot_cx, dot_cy, INDICATOR_RADIUS, 0, 2 * math.pi) cr.fill() cr.restore() @@ -715,16 +741,36 @@ class DockCanvas(Gtk.DrawingArea): n = len(items) total_base = n * size + max(n - 1, 0) * ICON_GAP w = self.get_allocated_width() - base_start_x = (w - total_base) / 2.0 h = self.get_allocated_height() - bg_h = size + 2 * BG_PADDING_V - bg_y = h - INDICATOR_H - bg_h - baseline_y = bg_y + BG_PADDING_V + size - icon_top_y = baseline_y - size * max_scale() - self._mouse_inside = ( - base_start_x <= event.x <= base_start_x + total_base - and icon_top_y <= event.y <= baseline_y - ) + + if is_vertical(): + base_start_y = (h - total_base) / 2.0 + bg_w = size + 2 * BG_PADDING_V + if dock_position() == "right": + bg_x = w - INDICATOR_H - bg_w + baseline_x = bg_x + BG_PADDING_V + size + icon_left_x = baseline_x - size * max_scale() + self._mouse_inside = ( + icon_left_x <= event.x <= baseline_x + and base_start_y <= event.y <= base_start_y + total_base + ) + else: + bg_x = INDICATOR_H + baseline_x = bg_x + BG_PADDING_V + size + self._mouse_inside = ( + bg_x <= event.x <= baseline_x + size * max_scale() + and base_start_y <= event.y <= base_start_y + total_base + ) + else: + base_start_x = (w - total_base) / 2.0 + bg_h = size + 2 * BG_PADDING_V + bg_y = h - INDICATOR_H - bg_h + baseline_y = bg_y + BG_PADDING_V + size + icon_top_y = baseline_y - size * max_scale() + self._mouse_inside = ( + base_start_x <= event.x <= base_start_x + total_base + and icon_top_y <= event.y <= baseline_y + ) else: self._mouse_inside = False diff --git a/src/window/dock/constants.py b/src/window/dock/constants.py index f0eab6d..9d9feb0 100644 --- a/src/window/dock/constants.py +++ b/src/window/dock/constants.py @@ -3,6 +3,18 @@ from utils.gtk_utils import toml_file PINNED_APPS_FILE = toml_file("dock.toml") +_POSITIONS = ("bottom", "left", "right") + + +def dock_position() -> str: + position = str(config().get("dock.position", "bottom")).lower() + return position if position in _POSITIONS else "bottom" + + +def is_vertical() -> bool: + return dock_position() in ("left", "right") + + LERP_FACTOR = 0.20 IDLE_THRESHOLD = 0.0005 ANIM_FPS = 60 diff --git a/src/window/dock/layout.py b/src/window/dock/layout.py index d7729b9..1ff36a1 100644 --- a/src/window/dock/layout.py +++ b/src/window/dock/layout.py @@ -7,6 +7,8 @@ from .constants import ( INDICATOR_H, MIN_SCALE, SIGMA_FACTOR, + dock_position, + is_vertical, max_scale, ) from .items import DockItem @@ -17,7 +19,7 @@ class DockLayout: def compute( items: list[DockItem], mouse_x: float, - _mouse_y: float, + mouse_y: float, base_icon_size: int, canvas_w: int, canvas_h: int, @@ -26,19 +28,24 @@ class DockLayout: if not items: return + vertical = is_vertical() sigma = base_icon_size * SIGMA_FACTOR amplitude = max_scale() - MIN_SCALE n = len(items) - base_w = base_icon_size + ICON_GAP + base_unit = base_icon_size + ICON_GAP total_base = n * base_icon_size + max(n - 1, 0) * ICON_GAP - start_x = (canvas_w - total_base) / 2.0 + start = ( + ((canvas_h - total_base) / 2.0) + if vertical + else ((canvas_w - total_base) / 2.0) + ) for i, item in enumerate(items): - est_cx = start_x + i * base_w + base_icon_size / 2.0 + est_c = start + i * base_unit + base_icon_size / 2.0 if mouse_inside: - dist = abs(est_cx - mouse_x) + dist = abs(est_c - (mouse_y if vertical else mouse_x)) target = MIN_SCALE + amplitude * math.exp( -(dist * dist) / (2.0 * sigma * sigma) ) @@ -47,6 +54,16 @@ class DockLayout: item.target_scale = target + if vertical: + DockLayout._layout_vertical(items, base_icon_size, canvas_w, canvas_h) + else: + DockLayout._layout_horizontal(items, base_icon_size, canvas_w, canvas_h) + + @staticmethod + def _layout_horizontal( + items: list[DockItem], base_icon_size: int, canvas_w: int, canvas_h: int + ) -> None: + n = len(items) bg_h = base_icon_size + 2 * BG_PADDING_V bg_y = canvas_h - INDICATOR_H - bg_h baseline_y = bg_y + BG_PADDING_V + base_icon_size @@ -67,6 +84,37 @@ class DockLayout: item.drag_index = i cursor_x += iw + ICON_GAP + @staticmethod + def _layout_vertical( + items: list[DockItem], base_icon_size: int, canvas_w: int, canvas_h: int + ) -> None: + n = len(items) + bg_w = base_icon_size + 2 * BG_PADDING_V + position = dock_position() + + rendered_heights = [base_icon_size * item.current_scale for item in items] + total_h = sum(rendered_heights) + max(n - 1, 0) * ICON_GAP + cursor_y = (canvas_h - total_h) / 2.0 + + if position == "right": + bg_x = canvas_w - INDICATOR_H - bg_w + anchor_x = bg_x + BG_PADDING_V + base_icon_size + else: + bg_x = INDICATOR_H + anchor_x = bg_x + BG_PADDING_V + + for i, item in enumerate(items): + ih = rendered_heights[i] + iw = base_icon_size * item.current_scale + iy = cursor_y + ix = anchor_x - iw if position == "right" else anchor_x + item.render_x = ix + item.render_y = iy + item.render_w = iw + item.render_h = ih + item.drag_index = i + cursor_y += ih + ICON_GAP + @staticmethod def background_rect( items: list[DockItem], @@ -74,6 +122,25 @@ class DockLayout: canvas_w: int, canvas_h: int, ) -> tuple[float, float, float, float]: + if is_vertical(): + bg_w = base_icon_size + 2 * BG_PADDING_V + bg_x = ( + canvas_w - INDICATOR_H - bg_w + if dock_position() == "right" + else INDICATOR_H + ) + + if items: + min_y = min(item.render_y for item in items) + max_y = max(item.render_y + item.render_h for item in items) + bg_y = min_y - BG_PADDING_H + bg_h = (max_y - min_y) + 2 * BG_PADDING_H + else: + bg_y = BG_PADDING_H + bg_h = canvas_h - 2 * BG_PADDING_H + + return bg_x, bg_y, bg_w, bg_h + bg_h = base_icon_size + 2 * BG_PADDING_V bg_y = canvas_h - INDICATOR_H - bg_h diff --git a/src/window/dock/main.py b/src/window/dock/main.py index eda5621..547e0f6 100644 --- a/src/window/dock/main.py +++ b/src/window/dock/main.py @@ -3,18 +3,20 @@ from fabric.widgets.box import Box from fabric.widgets.eventbox import EventBox from fabric.widgets.revealer import Revealer from fabric.widgets.wayland import WaylandWindow as Window +from gi.repository import Gtk from services.config import config, on_config_change from services.modus import check_occlusion, get_modus_service from .canvas import DockCanvas +from .constants import dock_position, is_vertical class Dock(Window): def __init__(self): super().__init__( layer="top", - anchor="bottom center", + anchor=self._anchor_for_position(), exclusivity="none", title="modus-dock", name="dock", @@ -33,14 +35,15 @@ class Dock(Window): self.revealer = Revealer( child=self.canvas, transition_duration=200, - transition_type="slide-up", + transition_type=self._transition_for_position(), ) hover_strip = Box( name="dock-hover-strip", ) - outer_box = Box( + self.hover_strip = hover_strip + self.outer_box = Box( name="dock-outer-box", orientation="v", children=[self.revealer, hover_strip], @@ -48,12 +51,13 @@ class Dock(Window): self.children = EventBox( events=["enter-notify", "leave-notify"], - child=outer_box, + child=self.outer_box, on_enter_notify_event=lambda *_: self.on_hover_enter(), on_leave_notify_event=lambda *_: self.on_hover_leave(), ) - self.dock_height = 100 + self.dock_thickness = self.canvas._canvas_edge_thickness() + self._apply_position() self.is_hovered = False self.hide_ticket = 0 self._occlusion_timer_id = None @@ -70,6 +74,47 @@ class Dock(Window): self._setup_occlusion_signals() self._check_occlusion_deferred() + @staticmethod + def _anchor_for_position() -> str: + return { + "left": "center left", + "right": "center right", + }.get(dock_position(), "bottom center") + + @staticmethod + def _transition_for_position() -> str: + return { + "left": "slide-right", + "right": "slide-left", + }.get(dock_position(), "slide-up") + + def _occlusion_region(self) -> tuple[str, int]: + position = dock_position() + if position in ("left", "right"): + return position, self.dock_thickness + return "bottom", self.dock_thickness + + def _apply_position(self) -> None: + self.anchor = self._anchor_for_position() + self.revealer.transition_type = self._transition_for_position() + + vertical = is_vertical() + self.outer_box.set_orientation( + Gtk.Orientation.HORIZONTAL if vertical else Gtk.Orientation.VERTICAL + ) + if vertical: + children = ( + [self.revealer, self.hover_strip] + if dock_position() == "right" + else [self.hover_strip, self.revealer] + ) + else: + children = [self.revealer, self.hover_strip] + self.outer_box.children = children + + self.dock_thickness = self.canvas._canvas_edge_thickness() + self.canvas._update_size_request() + def _setup_occlusion_signals(self) -> None: """Subscribe to Hyprland events that affect occlusion.""" hyprland = get_modus_service()._hyprland_connection @@ -103,7 +148,7 @@ class Dock(Window): if not config().get("dock_auto_hide", True): return False is_occ = config().get("dock.always_occluded", False) or check_occlusion( - ("bottom", self.dock_height) + self._occlusion_region() ) if is_occ and not self.is_hovered and self.revealer.get_reveal_child(): self.revealer.set_reveal_child(False) @@ -120,6 +165,10 @@ class Dock(Window): if config().has_changed("dock.enabled", old_config): self._update_visibility() + if config().has_changed("dock.position", old_config): + self._apply_position() + self.canvas.update_icon_size() + if config().has_changed("dock.icon_size", old_config): self.canvas.update_icon_size() @@ -160,7 +209,7 @@ class Dock(Window): if config().get("dock.auto_hide", True): is_occ = config().get( "dock.always_occluded", False - ) or check_occlusion(("bottom", self.dock_height)) + ) or check_occlusion(self._occlusion_region()) if is_occ: self.revealer.set_reveal_child(False) return False