diff --git a/docs/configuration.md b/docs/configuration.md index 6e2a782..ac6fd53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -314,6 +314,19 @@ the same save and copy workflow. `ferese-screenshot --all` captures every enabled output as one image. +The editor window is sized from the captured image rather than the output, so a +small area selection does not open a large window. Sizing is advisory: the +compositor floats the editor at whatever size Satty requests, clamped to the +workspace. + +A capture is assembled from the readback each output produces when it is next +redrawn, so a request is abandoned if an output has not been redrawn within 30 +seconds. That is reported as an error rather than a partial or empty image, and +the request holds no resources once it is abandoned. Nothing is left in the +runtime directory: `feresctl screenshot` writes a private file that the caller +opens and unlinks, and any file a dead client leaves behind is reclaimed after +an hour. + ## Window rules diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot index 288a351..9043707 100755 --- a/packaging/ferese-screenshot +++ b/packaging/ferese-screenshot @@ -42,6 +42,8 @@ if [[ $mode == area ]]; then elif [[ $mode == full ]]; then # Resolve the active monitor before Satty can change keyboard focus. # Never fall back to an all-monitor capture when IPC is unavailable. + # This is the only mode that needs the output list; the editor is sized + # from the captured image instead. geometry=$(feresectl get-outputs | python3 -c ' import json, sys outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] @@ -61,19 +63,30 @@ input='' trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.' feresectl screenshot "${capture_args[@]}" >"$input" || fail 'Could not capture the screen. Ferese must be running, unlocked, and reachable over its socket.' -editor_size=$(feresectl get-outputs | python3 -c ' -import json, sys +# Size the editor to the image that was actually captured, so a small area +# selection does not open a huge window and a multi-monitor --all capture is not +# sized from whichever output happened to be focused. Satty treats this as a +# default size, and Ferese floats the window at whatever it commits. +editor_size=$(python3 -c ' +import sys -outputs = [output for output in json.load(sys.stdin) if output.get("enabled")] -focused = [output for output in outputs if output.get("focused")] -selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else [] -if len(selected) != 1 or any(selected[0].get(key) is None for key in ("width", "height")): +header = open(sys.argv[1], "rb").read(24) +if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + sys.exit(1) +width = int.from_bytes(header[16:20], "big") +height = int.from_bytes(header[20:24], "big") +if width <= 0 or height <= 0: sys.exit(1) -width = max(1, min(1500, round(selected[0]["width"] * 0.8))) -height = max(1, min(920, round(selected[0]["height"] * 0.8), round(width * 0.68))) -print(f"{width}x{height}") -') || editor_size=1000x700 +MAX_W, MAX_H = 1500, 920 +MIN_W, MIN_H = 480, 360 +factor = min(MAX_W / width, MAX_H / height, 2.0) +target_w = max(MIN_W, round(width * factor)) +target_h = max(MIN_H, round(height * factor)) +# The minimums can push past the maximums for extreme aspect ratios. +shrink = min(MAX_W / target_w, MAX_H / target_h, 1.0) +print("%dx%d" % (max(1, round(target_w * shrink)), max(1, round(target_h * shrink)))) +' "$input") || editor_size=1000x700 # Enter saves the annotated image and copies it; Escape discards the capture. # Keep the parent lock through editing. Close it only in Satty so clipboard # helpers cannot inherit the lock and keep later screenshots blocked. diff --git a/scripts/tests/test_screenshot.py b/scripts/tests/test_screenshot.py index 8dd6254..dddee97 100644 --- a/scripts/tests/test_screenshot.py +++ b/scripts/tests/test_screenshot.py @@ -23,7 +23,7 @@ case "$1" in get-outputs) cat "$TEST_ROOT/outputs.json" ;; screenshot) printf '%s\\n' "$@" >> "$TEST_ROOT/captures" - printf image + cat "$TEST_ROOT/capture.png" ;; *) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;; esac @@ -59,6 +59,7 @@ class ScreenshotTest(unittest.TestCase): TEST_ROOT=str(self.root), PATH=f'{self.bins}:{os.environ["PATH"]}') self.write_outputs(OUTPUTS) + self.write_capture(2560, 1440) for name, contents in fakes().items(): path = self.bins / name path.write_text(contents) @@ -67,6 +68,31 @@ class ScreenshotTest(unittest.TestCase): def write_outputs(self, outputs): (self.root / 'outputs.json').write_text(json.dumps(outputs)) + def write_capture(self, width, height): + '''Stand in for the real readback with a PNG of known dimensions.''' + import struct + import zlib + + def chunk(kind, data): + body = kind + data + return (struct.pack('>I', len(data)) + body + + struct.pack('>I', zlib.crc32(body))) + + rows = b''.join(b'\x00' + bytes([10, 20, 30, 255]) * width + for _ in range(height)) + png = (b'\x89PNG\r\n\x1a\n' + + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(rows)) + + chunk(b'IEND', b'')) + (self.root / 'capture.png').write_bytes(png) + + def editor_args(self): + return (self.root / 'satty-args').read_text().splitlines() + + def editor_size(self): + args = self.editor_args() + return args[args.index('--resize') + 1] + def captures(self): log = self.root / 'captures' if not log.exists(): @@ -92,8 +118,8 @@ class ScreenshotTest(unittest.TestCase): editor_args = (self.root / 'satty-args').read_text().splitlines() self.assertEqual(editor_args[editor_args.index('--app-id') + 1], 'dev.ferese.Screenshot') - self.assertEqual(editor_args[editor_args.index('--resize') + 1], - '1500x920') + # Derived from the 2560x1440 capture, not from the focused output. + self.assertEqual(self.editor_size(), '1500x844') self.assertIn('--no-window-decoration', editor_args) self.assertNotIn('--fullscreen', editor_args) self.assertFalse((self.root / 'inherited-lock').exists()) @@ -160,6 +186,27 @@ class ScreenshotTest(unittest.TestCase): # The fake only records these when the compositor command runs, # so a capture taken any other way leaves the log short. self.assertEqual(self.captures()[before:], expected) + def test_the_editor_is_sized_from_the_image_not_the_output(self): + # The focused output is 2560x1440 throughout, so a size that tracked the + # output would be identical for every capture. It must follow the image. + (self.root / 'release').touch() + expected = {(1920, 1080): '1500x844', # capped by width, aspect kept + (2560, 1440): '1500x844', + (640, 480): '1227x920', # height cap binds before 2x upscale + (3000, 200): '1500x360'} # extreme aspect still bounded + for (width, height), size in expected.items(): + with self.subTest(size=f'{width}x{height}'): + self.write_capture(width, height) + result = self.run_script('--all') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.editor_size(), size) + + def test_an_unreadable_capture_falls_back_to_a_default_size(self): + (self.root / 'capture.png').write_bytes(b'not a png') + (self.root / 'release').touch() + result = self.run_script('--all') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.editor_size(), '1000x700') if __name__ == '__main__':