diff --git a/.gitignore b/.gitignore index dfca89d4..7f1b4b76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ /dist/ /result +install-local.sh \ No newline at end of file diff --git a/src/caelestia/subcommands/record.py b/src/caelestia/subcommands/record.py index 867eb1b5..2b01bf31 100644 --- a/src/caelestia/subcommands/record.py +++ b/src/caelestia/subcommands/record.py @@ -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"] @@ -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): @@ -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( diff --git a/src/caelestia/subcommands/screenshot.py b/src/caelestia/subcommands/screenshot.py index 6b4c00ad..2b524f40 100644 --- a/src/caelestia/subcommands/screenshot.py +++ b/src/caelestia/subcommands/screenshot.py @@ -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}")