Size the screenshot editor from the captured image
This commit is contained in:
1 parent
38673e41c8
commit
77ff92c6d6
3 files changed
+86
-13
No files matched your search
@@ -314,6 +314,19 @@ the same save and copy workflow.
|
|||||||
|
|
||||||
`ferese-screenshot --all` captures every enabled output as one image.
|
`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
|
## Window rules
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -42,6 +42,8 @@ if [[ $mode == area ]]; then
|
|||||||
elif [[ $mode == full ]]; then
|
elif [[ $mode == full ]]; then
|
||||||
# Resolve the active monitor before Satty can change keyboard focus.
|
# Resolve the active monitor before Satty can change keyboard focus.
|
||||||
# Never fall back to an all-monitor capture when IPC is unavailable.
|
# 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 '
|
geometry=$(feresectl get-outputs | python3 -c '
|
||||||
import json, sys
|
import json, sys
|
||||||
outputs = [output for output in json.load(sys.stdin) if output.get("enabled")]
|
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
|
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.'
|
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.'
|
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 '
|
# Size the editor to the image that was actually captured, so a small area
|
||||||
import json, sys
|
# 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")]
|
header = open(sys.argv[1], "rb").read(24)
|
||||||
focused = [output for output in outputs if output.get("focused")]
|
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
|
||||||
selected = focused if len(focused) == 1 else outputs if len(outputs) == 1 else []
|
sys.exit(1)
|
||||||
if len(selected) != 1 or any(selected[0].get(key) is None for key in ("width", "height")):
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
width = max(1, min(1500, round(selected[0]["width"] * 0.8)))
|
MAX_W, MAX_H = 1500, 920
|
||||||
height = max(1, min(920, round(selected[0]["height"] * 0.8), round(width * 0.68)))
|
MIN_W, MIN_H = 480, 360
|
||||||
print(f"{width}x{height}")
|
factor = min(MAX_W / width, MAX_H / height, 2.0)
|
||||||
') || editor_size=1000x700
|
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.
|
# 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
|
# Keep the parent lock through editing. Close it only in Satty so clipboard
|
||||||
# helpers cannot inherit the lock and keep later screenshots blocked.
|
# helpers cannot inherit the lock and keep later screenshots blocked.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ case "$1" in
|
|||||||
get-outputs) cat "$TEST_ROOT/outputs.json" ;;
|
get-outputs) cat "$TEST_ROOT/outputs.json" ;;
|
||||||
screenshot)
|
screenshot)
|
||||||
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
||||||
printf image
|
cat "$TEST_ROOT/capture.png"
|
||||||
;;
|
;;
|
||||||
*) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;;
|
*) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
@@ -59,6 +59,7 @@ class ScreenshotTest(unittest.TestCase):
|
|||||||
TEST_ROOT=str(self.root),
|
TEST_ROOT=str(self.root),
|
||||||
PATH=f'{self.bins}:{os.environ["PATH"]}')
|
PATH=f'{self.bins}:{os.environ["PATH"]}')
|
||||||
self.write_outputs(OUTPUTS)
|
self.write_outputs(OUTPUTS)
|
||||||
|
self.write_capture(2560, 1440)
|
||||||
for name, contents in fakes().items():
|
for name, contents in fakes().items():
|
||||||
path = self.bins / name
|
path = self.bins / name
|
||||||
path.write_text(contents)
|
path.write_text(contents)
|
||||||
@@ -67,6 +68,31 @@ class ScreenshotTest(unittest.TestCase):
|
|||||||
def write_outputs(self, outputs):
|
def write_outputs(self, outputs):
|
||||||
(self.root / 'outputs.json').write_text(json.dumps(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):
|
def captures(self):
|
||||||
log = self.root / 'captures'
|
log = self.root / 'captures'
|
||||||
if not log.exists():
|
if not log.exists():
|
||||||
@@ -92,8 +118,8 @@ class ScreenshotTest(unittest.TestCase):
|
|||||||
editor_args = (self.root / 'satty-args').read_text().splitlines()
|
editor_args = (self.root / 'satty-args').read_text().splitlines()
|
||||||
self.assertEqual(editor_args[editor_args.index('--app-id') + 1],
|
self.assertEqual(editor_args[editor_args.index('--app-id') + 1],
|
||||||
'dev.ferese.Screenshot')
|
'dev.ferese.Screenshot')
|
||||||
self.assertEqual(editor_args[editor_args.index('--resize') + 1],
|
# Derived from the 2560x1440 capture, not from the focused output.
|
||||||
'1500x920')
|
self.assertEqual(self.editor_size(), '1500x844')
|
||||||
self.assertIn('--no-window-decoration', editor_args)
|
self.assertIn('--no-window-decoration', editor_args)
|
||||||
self.assertNotIn('--fullscreen', editor_args)
|
self.assertNotIn('--fullscreen', editor_args)
|
||||||
self.assertFalse((self.root / 'inherited-lock').exists())
|
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,
|
# The fake only records these when the compositor command runs,
|
||||||
# so a capture taken any other way leaves the log short.
|
# so a capture taken any other way leaves the log short.
|
||||||
self.assertEqual(self.captures()[before:], expected)
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in new issue
Block a user