diff --git a/src/window/screencapture/__init__.py b/src/window/screencapture/__init__.py new file mode 100644 index 0000000..235742d --- /dev/null +++ b/src/window/screencapture/__init__.py @@ -0,0 +1,3 @@ +from window.screencapture.main import ScreenCaptureWindow + +__all__ = ["ScreenCaptureWindow"] diff --git a/src/window/screencapture/main.py b/src/window/screencapture/main.py new file mode 100644 index 0000000..67e2c02 --- /dev/null +++ b/src/window/screencapture/main.py @@ -0,0 +1,333 @@ +from fabric.utils import Gdk, GLib +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.wayland import WaylandWindow as Window + +from services.screencapture import screen_capture_service +from window.screencapture.options_menu import OptionsMenu +from window.screencapture.tool_button import ToolButton, ToolGroup + + +class ScreenCaptureWindow(Window): + _TOOLS = [ + ("region.svg", "Screenshot Region", "screenshot-region"), + ("monitor.svg", "Screenshot Screen", "screenshot-screen"), + ("window.svg", "Screenshot Window", "screenshot-window"), + ("region-video.svg", "Record Region", "record-region"), + ("monitor-video.svg", "Record Screen", "record-screen"), + ("window-video.svg", "Record Window", "record-window"), + ] + + def __init__(self, **kwargs): + super().__init__( + name="sc-window", + title="modus-screencapture", + anchor="bottom", + layer="top", + keyboard_mode="on-demand", + margin="0px 0px 120px 0px", + visible=False, + **kwargs, + ) + + self._tool_group = ToolGroup() + self._options_menu: OptionsMenu | None = None + self._delay_timeout_id: int | None = None + + close_lbl = Label(label="✕") + close_btn = Button(name="sc-close-btn", child=close_lbl) + close_btn.connect("clicked", lambda *_: self.hide()) + + tool_boxes = [] + for i, (icon, tip, tid) in enumerate(self._TOOLS): + if i == 3: + tool_boxes.append(Box(name="sc-separator")) + btn = ToolButton( + icon=icon, tooltip=tip, tool_id=tid, group=self._tool_group + ) + self._tool_group.add(btn) + tool_boxes.append(btn) + + self._tool_bar = Box( + name="sc-tool-bar", + orientation="h", + spacing=2, + children=tool_boxes, + ) + + # Options button — opens a Gtk.Menu + self._options_btn = Button(name="sc-options-btn") + opt_inner = Box( + orientation="h", + spacing=2, + children=[ + Label(name="sc-options-label", label="Options"), + Label(name="sc-options-arrow", label="⌄"), + ], + ) + self._options_btn.add(opt_inner) + self._options_btn.connect("clicked", self._toggle_options) + + # Action button + self._action_btn = Button(name="sc-action-btn", label="Capture") + self._action_btn.connect("clicked", self._on_action) + + # Assemble toolbar + toolbar = Box( + name="sc-toolbar", + orientation="h", + spacing=0, + children=[ + close_btn, + self._tool_bar, + self._options_btn, + self._action_btn, + ], + ) + + # Wrap in a transparent container to prevent solid square background issues + container = Box( + name="sc-container", + orientation="v", + h_align="center", + v_align="center", + expand=True, + children=[toolbar], + ) + + self.add(container) + + self._tool_group.select_id("screenshot-screen") + self._tool_group.on_change(self._on_tool_changed) + + screen_capture_service.connect("recording-started", self._on_recording_started) + screen_capture_service.connect("recording-stopped", self._on_recording_stopped) + + self.connect("key-press-event", self._on_key_press) + + def do_focus(self, direction): + """ + Disable GTK's built-in Tab/Shift+Tab focus traversal so those keys + reach our key-press-event handler instead of moving widget focus. + """ + return False + + def _on_key_press(self, _, event: Gdk.EventKey) -> bool: + kv = event.keyval + mods = event.state + + # Escape — close window + if kv == Gdk.KEY_Escape: + self.hide() + return True + + # Enter / Space — trigger action + if kv in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space): + self._on_action() + return True + + # Left / h — previous tool (clamp) + if kv in (Gdk.KEY_Left, Gdk.KEY_h): + idx = self._tool_group.current_index() + self._tool_group.select_index(max(0, idx - 1)) + return True + + # Right / l — next tool (clamp) + if kv in (Gdk.KEY_Right, Gdk.KEY_l): + idx = self._tool_group.current_index() + self._tool_group.select_index(min(len(self._TOOLS) - 1, idx + 1)) + return True + + # Shift+Tab — previous tool (wrap) + if kv in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab) and ( + mods & Gdk.ModifierType.SHIFT_MASK + ): + idx = self._tool_group.current_index() + self._tool_group.select_index((idx - 1) % len(self._TOOLS)) + return True + + # Tab — next tool (wrap) + if kv == Gdk.KEY_Tab: + idx = self._tool_group.current_index() + self._tool_group.select_index((idx + 1) % len(self._TOOLS)) + return True + + # 1–6 — jump directly to tool + if Gdk.KEY_1 <= kv <= Gdk.KEY_6: + self._tool_group.select_index(kv - Gdk.KEY_1) + return True + + return False + + def _toggle_options(self, *_): + if self._options_menu is None: + self._options_menu = OptionsMenu() + self._options_menu.popup_below(self._options_btn) + + def _on_tool_changed(self, tool_id: str): + is_recording_tool = tool_id.startswith("record-") + is_active = screen_capture_service.is_recording + + ctx = self._action_btn.get_style_context() + if is_active: + self._action_btn.set_label("Stop") + ctx.add_class("recording") + elif is_recording_tool: + self._action_btn.set_label("Record") + ctx.remove_class("recording") + else: + self._action_btn.set_label("Capture") + ctx.remove_class("recording") + + def _on_action(self, *_): + # Stop an active recording + if screen_capture_service.is_recording: + screen_capture_service.stop_recording() + return + + delay = 0 + if self._options_menu: + delay = self._options_menu.get_timer_delay() + + self.hide() + + if delay > 0: + self._delay_timeout_id = GLib.timeout_add_seconds(delay, self._do_action) + else: + GLib.timeout_add(80, self._do_action) + + def _do_action(self): + tool = self._tool_group.selected_id + self._delay_timeout_id = None + + # Gather options once + use_audio = ( + self._options_menu.get_mic_enabled() if self._options_menu else False + ) + show_cursor = ( + self._options_menu.get_show_cursor() if self._options_menu else False + ) + + if tool == "screenshot-screen": + self._do_screenshot("active") + elif tool == "screenshot-window": + self._do_screenshot("window") # hyprshot -m window + elif tool == "screenshot-region": + self._do_screenshot("region") + elif tool == "record-screen": + screen_capture_service.record( + "active", use_audio=use_audio, show_cursor=show_cursor + ) + elif tool == "record-window": + screen_capture_service.record( + "selection", use_audio=use_audio, show_cursor=show_cursor + ) + elif tool == "record-region": + screen_capture_service.record( + "selection", use_audio=use_audio, show_cursor=show_cursor + ) + + return False + + def _do_screenshot(self, target: str): + """ + Take a screenshot. + - On success: service emits screenshot_taken(path) → we stay hidden. + - On cancel: service emits screenshot_taken(None) → we re-show. + - If screenshot() itself fails immediately → we re-show. + The handler is always disconnected after one fire to avoid leaks. + """ + # Gather options + output_dir = None + show_cursor = False + if self._options_menu: + output_dir = self._options_menu.get_save_dir() + show_cursor = self._options_menu.get_show_cursor() + + _handler_id = [None] + + def _on_taken(svc, path, *_): + # Disconnect immediately — one-shot + if _handler_id[0] is not None: + try: + svc.disconnect(_handler_id[0]) + except Exception: + pass + _handler_id[0] = None + # Re-show only on cancel (path is None) + if not path: + GLib.timeout_add(120, self.show) + + _handler_id[0] = screen_capture_service.connect("screenshot-taken", _on_taken) + + ok = screen_capture_service.screenshot( + target, + output_dir=output_dir, + show_cursor=show_cursor, + ) + if not ok: + # screenshot() returned False — clean up and re-show immediately + if _handler_id[0] is not None: + try: + screen_capture_service.disconnect(_handler_id[0]) + except Exception: + pass + _handler_id[0] = None + GLib.timeout_add(120, self.show) + + def _on_recording_started(self, *_): + self._action_btn.set_label("Stop") + self._action_btn.get_style_context().add_class("recording") + self.show() + + def _on_recording_stopped(self, *_): + tool = self._tool_group.selected_id or "" + ctx = self._action_btn.get_style_context() + self._action_btn.set_label( + "Record" if tool.startswith("record-") else "Capture" + ) + ctx.remove_class("recording") + + def toggle(self, ss: str = None, sr: str = None): + """ + Toggle the window, or trigger a direct action via CLI. + :param ss: screenshot mode (e.g. 'region', 'fullscreen', 'selectwindow') + :param sr: record mode (e.g. 'region', 'fullscreen', 'selectwindow') + """ + if ss: + target_map = { + "region": "region", + "fullscreen": "active", + "selectwindow": "window", + } + self._do_screenshot(target_map.get(ss, ss)) + return + + if sr: + if screen_capture_service.is_recording: + screen_capture_service.stop_recording() + return + + target_map = { + "region": "selection", + "fullscreen": "active", + "selectwindow": "selection", + } + use_audio = ( + self._options_menu.get_mic_enabled() if self._options_menu else False + ) + show_cursor = ( + self._options_menu.get_show_cursor() if self._options_menu else False + ) + screen_capture_service.record( + target_map.get(sr, sr), + use_audio=use_audio, + show_cursor=show_cursor, + ) + return + + if self.get_visible(): + self.hide() + else: + self.show() diff --git a/src/window/screencapture/options_menu.py b/src/window/screencapture/options_menu.py new file mode 100644 index 0000000..54924f5 --- /dev/null +++ b/src/window/screencapture/options_menu.py @@ -0,0 +1,237 @@ +from fabric.utils import Gdk, Gtk +from shared.widgets.smooth_switch import SmoothSwitch + + +class SwitchMenuItem(Gtk.MenuItem): + """ + A Gtk.MenuItem that contains a label + SmoothSwitch side by side. + Clicking the row toggles the switch. + """ + + def __init__(self, label: str, active: bool = True): + super().__init__() + self.set_name("sc-menu-item") + + self._switch = SmoothSwitch( + active=active, + width=34, + height=18, + style_classes=["sc-switch"], + v_align="center", + ) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + row.set_hexpand(True) + + lbl = Gtk.Label(label=label) + lbl.set_halign(Gtk.Align.START) + lbl.set_hexpand(True) + row.pack_start(lbl, True, True, 0) + row.pack_end(self._switch, False, False, 0) + row.show_all() + + self.add(row) + self.connect("activate", self._on_activate) + + def _on_activate(self, *_): + # Toggle the switch when the menu item is clicked + self._switch._on_click(self._switch, type("e", (), {"button": 1})()) + + def get_active(self) -> bool: + return self._switch.get_active() + + def set_active(self, value: bool): + self._switch.set_active(value) + + +class OptionsMenu: + """ + Gtk.Menu that pops up below the Options button. + Contains: Save to, Timer, Microphone, and toggle options. + State is preserved across opens. + """ + + _TIMER_LABELS = ["None", "5 seconds", "10 seconds"] + _MIC_LABELS = ["None", "Built-in Microphone"] + + def __init__(self): + self._timer_idx = 0 + self._mic_idx = 0 + self._menu = Gtk.Menu() + self._menu.set_name("sc-menu") + + self._add_section("Save to") + self._save_path = "~/Pictures/Screenshots" + save_item = Gtk.MenuItem() + save_item.set_name("sc-menu-item") + save_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._save_lbl = Gtk.Label(label=self._save_path) + self._save_lbl.set_halign(Gtk.Align.START) + self._save_lbl.set_hexpand(True) + browse_lbl = Gtk.Label(label="Other…") + browse_lbl.get_style_context().add_class("sc-menu-hint") + save_box.pack_start(self._save_lbl, True, True, 0) + save_box.pack_end(browse_lbl, False, False, 0) + save_box.show_all() + save_item.add(save_box) + save_item.connect("activate", self._choose_save_dir) + self._menu.append(save_item) + + self._add_separator() + + self._timer_hint_lbl = Gtk.Label(label=self._TIMER_LABELS[0]) + self._timer_hint_lbl.get_style_context().add_class("sc-menu-hint") + timer_parent = self._make_submenu_item("Timer", self._timer_hint_lbl) + timer_sub = Gtk.Menu() + timer_sub.set_name("sc-menu") + self._timer_items: list[Gtk.CheckMenuItem] = [] + for i, lbl in enumerate(self._TIMER_LABELS): + item = Gtk.CheckMenuItem(label=lbl) + item.set_name("sc-menu-item") + item.set_draw_as_radio(True) + item.set_active(i == 0) + item.connect("activate", self._on_timer_select, i) + timer_sub.append(item) + self._timer_items.append(item) + timer_sub.show_all() + timer_parent.set_submenu(timer_sub) + self._menu.append(timer_parent) + + self._add_separator() + + self._mic_hint_lbl = Gtk.Label(label=self._MIC_LABELS[0]) + self._mic_hint_lbl.get_style_context().add_class("sc-menu-hint") + mic_parent = self._make_submenu_item("Microphone", self._mic_hint_lbl) + mic_sub = Gtk.Menu() + mic_sub.set_name("sc-menu") + self._mic_items: list[Gtk.CheckMenuItem] = [] + for i, lbl in enumerate(self._MIC_LABELS): + item = Gtk.CheckMenuItem(label=lbl) + item.set_name("sc-menu-item") + item.set_draw_as_radio(True) + item.set_active(i == 0) + item.connect("activate", self._on_mic_select, i) + mic_sub.append(item) + self._mic_items.append(item) + mic_sub.show_all() + mic_parent.set_submenu(mic_sub) + self._menu.append(mic_parent) + + self._add_separator() + + self._add_section("Options") + self._floating_thumb = SwitchMenuItem("Show Floating Thumbnail", active=True) + self._remember_sel = SwitchMenuItem("Remember Last Selection", active=True) + self._show_mouse = SwitchMenuItem("Show Mouse Pointer", active=True) + self._menu.append(self._floating_thumb) + self._menu.append(self._remember_sel) + self._menu.append(self._show_mouse) + + self._menu.show_all() + + def _make_submenu_item(self, label: str, hint_lbl: Gtk.Label) -> Gtk.MenuItem: + """ + Create a menu item that has a label on the left and a dim hint on the right. + The hint shows the currently selected sub-option. + """ + item = Gtk.MenuItem() + item.set_name("sc-menu-item") + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + row.set_hexpand(True) + + title = Gtk.Label(label=label) + title.set_halign(Gtk.Align.START) + title.set_hexpand(True) + + hint_lbl.set_halign(Gtk.Align.END) + hint_lbl.show() + + row.pack_start(title, True, True, 0) + row.pack_end(hint_lbl, False, False, 0) + row.show_all() + item.add(row) + return item + + def _add_section(self, text: str): + item = Gtk.MenuItem() + item.set_name("sc-menu-item") + item.set_sensitive(False) + lbl = Gtk.Label(label=text.upper()) + lbl.set_name("sc-menu-section") + lbl.set_halign(Gtk.Align.START) + lbl.show() + item.add(lbl) + self._menu.append(item) + + def _add_separator(self): + sep = Gtk.SeparatorMenuItem() + sep.set_name("sc-menu-sep") + self._menu.append(sep) + + def _on_timer_select(self, item, idx: int): + if not item.get_active(): + return + self._timer_idx = idx + self._timer_hint_lbl.set_label(self._TIMER_LABELS[idx]) + for i, t in enumerate(self._timer_items): + if i != idx: + t.set_active(False) + + def _on_mic_select(self, item, idx: int): + if not item.get_active(): + return + self._mic_idx = idx + self._mic_hint_lbl.set_label(self._MIC_LABELS[idx]) + for i, m in enumerate(self._mic_items): + if i != idx: + m.set_active(False) + + def _choose_save_dir(self, *_): + dialog = Gtk.FileChooserDialog( + title="Choose Save Location", + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dialog.add_buttons( + "_Cancel", + Gtk.ResponseType.CANCEL, + "_Select", + Gtk.ResponseType.ACCEPT, + ) + resp = dialog.run() + if resp == Gtk.ResponseType.ACCEPT: + self._save_path = dialog.get_filename() + self._save_lbl.set_label(self._save_path) + dialog.destroy() + + def popup_below(self, widget: Gtk.Widget): + """Pop the menu up, positioned below the given widget.""" + self._menu.popup_at_widget( + widget, + Gdk.Gravity.SOUTH_WEST, + Gdk.Gravity.NORTH_WEST, + None, + ) + + def get_timer_delay(self) -> int: + return [0, 5, 10][self._timer_idx] + + def get_mic_enabled(self) -> bool: + """True when a microphone source (not None) is selected.""" + return self._mic_idx != 0 + + def get_save_dir(self) -> str | None: + """ + Returns the custom save directory if the user changed it, + or None to use the service default. + """ + default = "~/Pictures/Screenshots" + return self._save_path if self._save_path != default else None + + def get_show_cursor(self) -> bool: + return self._show_mouse.get_active() + + def get_show_thumbnail(self) -> bool: + return self._floating_thumb.get_active() + + def get_remember_selection(self) -> bool: + return self._remember_sel.get_active() diff --git a/src/window/screencapture/tool_button.py b/src/window/screencapture/tool_button.py new file mode 100644 index 0000000..c3a3d62 --- /dev/null +++ b/src/window/screencapture/tool_button.py @@ -0,0 +1,74 @@ +from fabric.widgets.button import Button +from utils.utils import svg_file + + +class ToolButton(Button): + """A toggle button representing one capture/record mode.""" + + def __init__(self, icon: str, tooltip: str, tool_id: str, group, **kwargs): + self.tool_id = tool_id + self._group = group + + img = svg_file(f"screencapture/{icon}", size=20) + + super().__init__( + name="sc-tool-btn", + tooltip_text=tooltip, + child=img, + **kwargs, + ) + self.connect("clicked", self._on_clicked) + + def _on_clicked(self, *_): + self._group.select(self) + + def set_active(self, active: bool): + ctx = self.get_style_context() + if active: + ctx.add_class("active") + else: + ctx.remove_class("active") + + +class ToolGroup: + """Manages exclusive selection among ToolButtons.""" + + def __init__(self): + self._buttons: list[ToolButton] = [] + self._selected: ToolButton | None = None + self._callbacks: list = [] + + def add(self, btn: ToolButton): + self._buttons.append(btn) + + def select(self, btn: ToolButton): + if self._selected is btn: + return + if self._selected: + self._selected.set_active(False) + self._selected = btn + btn.set_active(True) + for cb in self._callbacks: + cb(btn.tool_id) + + def select_index(self, idx: int): + if 0 <= idx < len(self._buttons): + self.select(self._buttons[idx]) + + def select_id(self, tool_id: str): + for btn in self._buttons: + if btn.tool_id == tool_id: + self.select(btn) + return + + def current_index(self) -> int: + if self._selected and self._selected in self._buttons: + return self._buttons.index(self._selected) + return 0 + + def on_change(self, cb): + self._callbacks.append(cb) + + @property + def selected_id(self) -> str | None: + return self._selected.tool_id if self._selected else None