99from textual .binding import Binding
1010from textual .containers import Container , Horizontal
1111from textual .css .query import NoMatches
12- from textual .events import Resize
12+ from textual .events import Click , Resize
1313from textual .geometry import Size
1414from textual .message import Message
1515from textual .reactive import reactive
2727from ...models .frame_data import ExtractionResult , FrameData
2828from ...processing .tui_processor import TUIProcessor
2929from ..keyboard import OptionSelect , select_focused_option
30+ from ..utils .image_opener import open_image_file
3031
3132
3233class 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:
7307921. **Choose a method** from the dropdown
7317932. **Adjust parameters** as needed
7327943. **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
735798The preview count updates instantly as you make changes, so you can experiment freely!
736799
0 commit comments