feat(src/window/screencapture): implement screen capture window with toolbar, tools, and options menu

Added a new window class that integrates with the capture service, provides a toolbar of tool buttons for region, screen, window, and recording modes, includes an options button to toggle settings, and implements save directory selection, delay, cursor, thumbnail, and microphone controls. The implementation follows the existing widget architecture and updates related modules to expose the new functionality.
This commit is contained in:
sankalp committed 2026-07-07 14:02:24 +05:45
1 parent 89d7cdfac3
commit f7662f7f5b
4 files changed
+647

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
from window.screencapture.main import ScreenCaptureWindow
__all__ = ["ScreenCaptureWindow"]
+333
View File
@@ -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()
+237
View File
@@ -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()
+74
View File
@@ -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