Skip to content

Commit f4ee4af

Browse files
committed
add click-to-inspect frame chart
1 parent 34b8e83 commit f4ee4af

4 files changed

Lines changed: 207 additions & 8 deletions

File tree

‎sharp_frames/ui/screens/selection.py‎

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from textual.binding import Binding
1010
from textual.containers import Container, Horizontal
1111
from textual.css.query import NoMatches
12-
from textual.events import Resize
12+
from textual.events import Click, Resize
1313
from textual.geometry import Size
1414
from textual.message import Message
1515
from textual.reactive import reactive
@@ -27,6 +27,7 @@
2727
from ...models.frame_data import ExtractionResult, FrameData
2828
from ...processing.tui_processor import TUIProcessor
2929
from ..keyboard import OptionSelect, select_focused_option
30+
from ..utils.image_opener import open_image_file
3031

3132

3233
class SharpnessChart(ScrollView):
@@ -50,6 +51,7 @@ class SharpnessChart(ScrollView):
5051
"sharpness-chart--background",
5152
"sharpness-chart--selected",
5253
"sharpness-chart--unselected",
54+
"sharpness-chart--inspected",
5355
"sharpness-chart--title",
5456
}
5557

@@ -84,6 +86,12 @@ class SharpnessChart(ScrollView):
8486
background: $background;
8587
}
8688
89+
SharpnessChart .sharpness-chart--inspected {
90+
color: $warning;
91+
background: $background;
92+
text-style: bold;
93+
}
94+
8795
SharpnessChart .sharpness-chart--title {
8896
color: $primary;
8997
background: $background;
@@ -100,10 +108,13 @@ def __init__(
100108
super().__init__(**kwargs)
101109
self.frames = frames
102110
self.selected_indices = set(selected_indices or ())
111+
self.inspected_index: Optional[int] = None
103112
self.timeline_width = max(len(frames) * self.FRAME_STRIDE, 1)
104113
self.virtual_size = Size(self.timeline_width, 1)
105114
self.border_title = "Frame selection"
106-
self.border_subtitle = "Arrows scroll | Ctrl+PgUp/PgDn page | Home/End"
115+
self.border_subtitle = (
116+
"Click inspect | Arrows scroll | Ctrl+PgUp/PgDn page | Home/End"
117+
)
107118

108119
if self.frames:
109120
scores = [f.sharpness_score for f in self.frames]
@@ -128,6 +139,30 @@ def update_selection(self, selected_indices: set[int]) -> None:
128139
self.selected_indices = set(selected_indices)
129140
self.refresh()
130141

142+
class FrameInspectRequested(Message):
143+
"""Request that the owning screen open a frame for inspection."""
144+
145+
def __init__(self, frame: FrameData) -> None:
146+
self.frame = frame
147+
super().__init__()
148+
149+
def on_click(self, event: Click) -> None:
150+
"""Open the frame represented by a clicked chart column."""
151+
content_offset = event.get_content_offset(self)
152+
if content_offset is None or content_offset.y == 0:
153+
return
154+
155+
virtual_column = int(self.scroll_offset.x) + content_offset.x
156+
frame_position = virtual_column // self.FRAME_STRIDE
157+
if not 0 <= frame_position < len(self.frames):
158+
return
159+
160+
frame = self.frames[frame_position]
161+
self.inspected_index = frame.index
162+
self.refresh()
163+
self.post_message(self.FrameInspectRequested(frame))
164+
event.stop()
165+
131166
def action_first_frame(self) -> None:
132167
"""Scroll directly to the beginning of the analyzed timeline."""
133168
self.scroll_to(x=0, animate=False)
@@ -182,13 +217,17 @@ def _render_chart_line(
182217
unselected_style = self.get_component_rich_style(
183218
"sharpness-chart--unselected"
184219
)
220+
inspected_style = self.get_component_rich_style(
221+
"sharpness-chart--inspected"
222+
)
185223
segments = [
186224
self._render_timeline_column(
187225
virtual_column,
188226
chart_y,
189227
chart_height,
190228
selected_style,
191229
unselected_style,
230+
inspected_style,
192231
blank_style,
193232
)
194233
for virtual_column in range(
@@ -205,6 +244,7 @@ def _render_timeline_column(
205244
chart_height: int,
206245
selected_style: Style,
207246
unselected_style: Style,
247+
inspected_style: Style,
208248
blank_style: Style,
209249
) -> Segment:
210250
"""Render a bar column or the blank gap following it."""
@@ -218,11 +258,12 @@ def _render_timeline_column(
218258
if glyph == " ":
219259
return Segment(" ", blank_style)
220260

221-
style = (
222-
selected_style
223-
if frame.index in self.selected_indices
224-
else unselected_style
225-
)
261+
if frame.index == self.inspected_index:
262+
style = inspected_style
263+
elif frame.index in self.selected_indices:
264+
style = selected_style
265+
else:
266+
style = unselected_style
226267
return Segment(glyph, style)
227268

228269
@classmethod
@@ -574,6 +615,27 @@ def compose(self) -> ComposeResult:
574615

575616
yield Footer()
576617

618+
async def on_sharpness_chart_frame_inspect_requested(
619+
self, event: SharpnessChart.FrameInspectRequested
620+
) -> None:
621+
"""Open a clicked frame in the platform's default image viewer."""
622+
frame = event.frame
623+
try:
624+
await asyncio.to_thread(open_image_file, frame.path)
625+
except (FileNotFoundError, OSError) as exc:
626+
self.notify(
627+
str(exc),
628+
title="Unable to inspect frame",
629+
severity="error",
630+
)
631+
return
632+
633+
self.notify(
634+
f"Frame {frame.index + 1:,} · sharpness {frame.sharpness_score:.2f}",
635+
title="Opened image",
636+
timeout=3,
637+
)
638+
577639
def _frame_count_summary(self) -> str:
578640
"""Summarize analyzed and excluded inputs for the title row."""
579641
analyzed_count = len(self.extraction_result.frames)
@@ -730,7 +792,8 @@ def action_help(self) -> None:
730792
1. **Choose a method** from the dropdown
731793
2. **Adjust parameters** as needed
732794
3. **Watch the count update** in real-time as you change settings
733-
4. **Press "Process"** when you're happy with the selection
795+
4. **Click a chart bar** to inspect that image in your default viewer
796+
5. **Press "Process"** when you're happy with the selection
734797
735798
The preview count updates instantly as you make changes, so you can experiment freely!
736799
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Open analyzed frame images with the platform's default viewer."""
2+
3+
import os
4+
import platform
5+
import subprocess
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
10+
def open_image_file(path: str, system_name: Optional[str] = None) -> None:
11+
"""Open an existing image without blocking the Textual application."""
12+
image_path = Path(path).expanduser()
13+
if not image_path.is_file():
14+
raise FileNotFoundError(f"Frame image is no longer available: {image_path}")
15+
16+
current_system = system_name or platform.system()
17+
if current_system == "Windows":
18+
startfile = getattr(os, "startfile", None)
19+
if startfile is None:
20+
raise OSError("Windows image opener is unavailable")
21+
startfile(str(image_path))
22+
return
23+
24+
command = ["open", str(image_path)] if current_system == "Darwin" else [
25+
"xdg-open",
26+
str(image_path),
27+
]
28+
subprocess.Popen(
29+
command,
30+
stdout=subprocess.DEVNULL,
31+
stderr=subprocess.DEVNULL,
32+
start_new_session=True,
33+
)

‎tests/ui/test_selection_screen.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,64 @@ def compose(self) -> ComposeResult:
245245
assert chart.scroll_offset.x == 0
246246

247247

248+
@pytest.mark.asyncio
249+
async def test_clicking_chart_bar_requests_frame_inspection():
250+
frames = [
251+
FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1))
252+
for index in range(100)
253+
]
254+
chart = SharpnessChart(frames)
255+
256+
class ChartApp(App):
257+
CSS = SHARP_FRAMES_CSS
258+
259+
def __init__(self):
260+
super().__init__()
261+
self.inspected = []
262+
263+
def compose(self) -> ComposeResult:
264+
yield chart
265+
266+
def on_sharpness_chart_frame_inspect_requested(self, event):
267+
self.inspected.append(event.frame)
268+
269+
app = ChartApp()
270+
async with app.run_test(size=(80, 24)) as pilot:
271+
await pilot.pause()
272+
await pilot.click(chart, offset=(1, 2))
273+
await pilot.pause()
274+
275+
assert app.inspected == [frames[0]]
276+
assert chart.inspected_index == frames[0].index
277+
278+
chart.scroll_to(x=SharpnessChart.FRAME_STRIDE * 2, animate=False)
279+
await pilot.pause()
280+
await pilot.click(chart, offset=(1, 2))
281+
await pilot.pause()
282+
283+
assert app.inspected[-1] == frames[2]
284+
assert chart.inspected_index == frames[2].index
285+
286+
287+
@pytest.mark.asyncio
288+
async def test_frame_inspection_handler_opens_analyzed_path(screen):
289+
frame = screen.extraction_result.frames[3]
290+
event = SimpleNamespace(frame=frame)
291+
292+
with (
293+
patch(
294+
"sharp_frames.ui.screens.selection.asyncio.to_thread",
295+
new=AsyncMock(),
296+
) as to_thread,
297+
patch.object(screen, "notify") as notify,
298+
):
299+
await screen.on_sharpness_chart_frame_inspect_requested(event)
300+
301+
to_thread.assert_awaited_once()
302+
assert to_thread.await_args.args[1] == frame.path
303+
assert notify.call_args.kwargs["title"] == "Opened image"
304+
305+
248306
def test_input_with_controls_retains_value_before_mount():
249307
control = InputWithControls(
250308
value="5", input_id="batch-size", min_value=1, max_value=10
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests for opening analyzed frames in a platform viewer."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from sharp_frames.ui.utils.image_opener import open_image_file
8+
9+
10+
def test_macos_uses_default_open_command(tmp_path):
11+
image = tmp_path / "frame with spaces.jpg"
12+
image.touch()
13+
14+
with patch("sharp_frames.ui.utils.image_opener.subprocess.Popen") as popen:
15+
open_image_file(str(image), system_name="Darwin")
16+
17+
assert popen.call_args.args[0] == ["open", str(image)]
18+
assert popen.call_args.kwargs["start_new_session"] is True
19+
20+
21+
def test_linux_uses_xdg_open(tmp_path):
22+
image = tmp_path / "frame.jpg"
23+
image.touch()
24+
25+
with patch("sharp_frames.ui.utils.image_opener.subprocess.Popen") as popen:
26+
open_image_file(str(image), system_name="Linux")
27+
28+
assert popen.call_args.args[0] == ["xdg-open", str(image)]
29+
30+
31+
def test_windows_uses_startfile(tmp_path):
32+
image = tmp_path / "frame.jpg"
33+
image.touch()
34+
35+
with patch(
36+
"sharp_frames.ui.utils.image_opener.os.startfile", create=True
37+
) as startfile:
38+
open_image_file(str(image), system_name="Windows")
39+
40+
startfile.assert_called_once_with(str(image))
41+
42+
43+
def test_missing_frame_reports_that_temp_file_is_unavailable(tmp_path):
44+
with pytest.raises(FileNotFoundError, match="no longer available"):
45+
open_image_file(str(tmp_path / "missing.jpg"), system_name="Darwin")

0 commit comments

Comments
 (0)