Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__/
/dist/
/result
install-local.sh
67 changes: 61 additions & 6 deletions src/caelestia/subcommands/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,53 @@ def proc_running(self) -> bool:
def intersects(self, a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool:
return a[0] < b[0] + b[2] and a[0] + a[2] > b[0] and a[1] < b[1] + b[3] and a[1] + a[3] > b[1]

def _convert_to_physical_pixels(self, log_x: int, log_y: int, log_w: int, log_h: int, monitors: list) -> str:
"""Convert logical coordinates to physical pixels for gpu-screen-recorder.

This handles fractional scaling by:
1. Finding the minimum physical origin across all monitors
2. Converting logical coordinates to physical coordinates using monitor scale
"""
# Find minimum physical origin (top-left across all monitors)
min_phys_x = 0
min_phys_y = 0
have_any = False

for monitor in monitors:
scale = monitor.get("scale", 1.0)
phys_x = monitor["x"] * scale
phys_y = monitor["y"] * scale

if not have_any:
min_phys_x = phys_x
min_phys_y = phys_y
have_any = True
else:
min_phys_x = min(min_phys_x, phys_x)
min_phys_y = min(min_phys_y, phys_y)

# Find the monitor containing this region to get its scale
region_monitor = None
for monitor in monitors:
mon_x, mon_y = monitor["x"], monitor["y"]
mon_w, mon_h = monitor["width"], monitor["height"]

# Check if region intersects with this monitor
if self.intersects((log_x, log_y, log_w, log_h), (mon_x, mon_y, mon_w, mon_h)):
region_monitor = monitor
break

# Use scale from the monitor containing the region, fallback to 1.0
scale = region_monitor.get("scale", 1.0) if region_monitor else 1.0

# Convert to physical coordinates
phys_x = max(0, round(log_x * scale - min_phys_x))
phys_y = max(0, round(log_y * scale - min_phys_y))
phys_w = max(1, round(log_w * scale))
phys_h = max(1, round(log_h * scale))

return f"{phys_w}x{phys_h}+{phys_x}+{phys_y}"

def start(self) -> None:
args = ["-w"]

Expand All @@ -41,14 +88,21 @@ def start(self) -> None:
region = subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True)
else:
region = self.args.region.strip()
args += ["region", "-region", region]


# Parse region coordinates (logical pixels from area picker)
m = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", region)
if not m:
raise ValueError(f"Invalid region: {region}")

w, h, x, y = map(int, m.groups())
r = x, y, w, h
log_w, log_h, log_x, log_y = map(int, m.groups())

# Convert logical coordinates to physical pixels for gpu-screen-recorder
# This handles fractional scaling correctly
phys_region = self._convert_to_physical_pixels(log_x, log_y, log_w, log_h, monitors)
args += ["region", "-region", phys_region]

# Find refresh rate for the region
r = log_x, log_y, log_w, log_h
max_rr = 0
for monitor in monitors:
if self.intersects((monitor["x"], monitor["y"], monitor["width"], monitor["height"]), r):
Expand Down Expand Up @@ -109,14 +163,15 @@ def stop(self) -> None:
pass

action = notify(
"--action=watch=Watch",
"-t", "0", # No timeout, no close button
"--action=play=Play",
"--action=open=Open",
"--action=delete=Delete",
"Recording stopped",
f"Recording saved in {new_path}",
)

if action == "watch":
if action == "play":
subprocess.Popen(["app2unit", "-O", new_path], start_new_session=True)
elif action == "open":
p = subprocess.run(
Expand Down
97 changes: 83 additions & 14 deletions src/caelestia/subcommands/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,110 @@ def run(self) -> None:
else:
self.fullscreen()

def _convert_geometry_to_grim_format(self, geometry: str) -> str:
"""Convert X11 geometry format (WIDTHxHEIGHT+X+Y) to grim format (X,Y WIDTHxHEIGHT)"""
import re
# Match X11 geometry format: WIDTHxHEIGHT+X+Y
match = re.match(r'(\d+)x(\d+)\+(\d+)\+(\d+)', geometry)
if match:
width, height, x, y = match.groups()
return f"{x},{y} {width}x{height}"
else:
# If it doesn't match X11 format, assume it's already in grim format or invalid
return geometry

def region(self) -> None:
if self.args.region == "slurp":
subprocess.run(
["qs", "-c", "caelestia", "ipc", "call", "picker", "openFreeze" if self.args.freeze else "open"]
)
else:
sc_data = subprocess.check_output(["grim", "-l", "0", "-g", self.args.region.strip(), "-"])
swappy = subprocess.Popen(["swappy", "-f", "-"], stdin=subprocess.PIPE, start_new_session=True)
swappy.stdin.write(sc_data)
swappy.stdin.close()
grim_geometry = self._convert_geometry_to_grim_format(self.args.region.strip())
sc_data = subprocess.check_output(["grim", "-l", "0", "-g", grim_geometry, "-"])

# Copy to clipboard
subprocess.run(["wl-copy"], input=sc_data)

# Save directly to screenshots directory with proper naming
dest = screenshots_dir / f"screenshot_{datetime.now().strftime('%Y%m%d_%H-%M-%S')}.png"
screenshots_dir.mkdir(exist_ok=True, parents=True)
dest.write_bytes(sc_data)

# Show notification with actions
action = notify(
"-t", "0", # No timeout, no close button
"-i",
"image-x-generic-symbolic",
"-h",
f"STRING:image-path:{dest}",
"--action=edit=Edit",
"--action=open=Open",
"--action=delete=Delete",
"Screenshot taken",
f"Screenshot saved to {dest.name} and copied to clipboard",
)

if action == "edit":
subprocess.Popen(["swappy", "-f", dest], start_new_session=True)
elif action == "open":
p = subprocess.run(
[
"dbus-send",
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:file://{dest}",
"string:",
]
)
if p.returncode != 0:
subprocess.Popen(["app2unit", "-O", dest.parent], start_new_session=True)
elif action == "delete":
dest.unlink()
notify("Screenshot deleted", f"Deleted {dest.name}")

def fullscreen(self) -> None:
sc_data = subprocess.check_output(["grim", "-"])

subprocess.run(["wl-copy"], input=sc_data)

dest = screenshots_cache_dir / datetime.now().strftime("%Y%m%d%H%M%S")
screenshots_cache_dir.mkdir(exist_ok=True, parents=True)
# Save directly to screenshots directory with proper naming
dest = screenshots_dir / f"screenshot_{datetime.now().strftime('%Y%m%d_%H-%M-%S')}.png"
screenshots_dir.mkdir(exist_ok=True, parents=True)
dest.write_bytes(sc_data)

action = notify(
"-t", "0", # No timeout, no close button
"-i",
"image-x-generic-symbolic",
"-h",
f"STRING:image-path:{dest}",
"--action=edit=Edit",
"--action=open=Open",
"--action=save=Save",
"--action=delete=Delete",
"Screenshot taken",
f"Screenshot stored in {dest} and copied to clipboard",
f"Screenshot saved to {dest.name} and copied to clipboard",
)

if action == "open":
if action == "edit":
subprocess.Popen(["swappy", "-f", dest], start_new_session=True)
elif action == "save":
new_dest = (screenshots_dir / dest.name).with_suffix(".png")
new_dest.parent.mkdir(exist_ok=True, parents=True)
dest.rename(new_dest)
notify("Screenshot saved", f"Saved to {new_dest}")
elif action == "open":
p = subprocess.run(
[
"dbus-send",
"--session",
"--dest=org.freedesktop.FileManager1",
"--type=method_call",
"/org/freedesktop/FileManager1",
"org.freedesktop.FileManager1.ShowItems",
f"array:string:file://{dest}",
"string:",
]
)
if p.returncode != 0:
subprocess.Popen(["app2unit", "-O", dest.parent], start_new_session=True)
elif action == "delete":
dest.unlink()
notify("Screenshot deleted", f"Deleted {dest.name}")