88from textual .app import ComposeResult
99from textual .binding import Binding
1010from textual .containers import Container , Horizontal
11+ from textual .css .query import NoMatches
12+ from textual .events import Resize
13+ from textual .geometry import Size
1114from textual .message import Message
1215from textual .reactive import reactive
1316from textual .screen import Screen
17+ from textual .scroll_view import ScrollView
1418from textual .strip import Strip
1519from textual .widget import Widget
1620from textual .widgets import (
2428from ...processing .tui_processor import TUIProcessor
2529
2630
27- class SharpnessChart (Widget ):
28- """Bar chart widget to display sharpness scores and selection status."""
29-
31+ class SharpnessChart (ScrollView ):
32+ """Horizontally scrollable frame-by-frame sharpness timeline."""
33+
34+ can_focus = True
35+
36+ BINDINGS = [
37+ Binding ("left" , "scroll_left" , "Scroll Left" , show = False ),
38+ Binding ("right" , "scroll_right" , "Scroll Right" , show = False ),
39+ Binding ("ctrl+pageup" , "page_left" , "Page Left" , show = False ),
40+ Binding ("ctrl+pagedown" , "page_right" , "Page Right" , show = False ),
41+ Binding ("home" , "first_frame" , "First Frame" , show = False ),
42+ Binding ("end" , "last_frame" , "Last Frame" , show = False ),
43+ ]
44+
45+ COMPONENT_CLASSES = {
46+ "sharpness-chart--selected" ,
47+ "sharpness-chart--unselected" ,
48+ "sharpness-chart--title" ,
49+ }
50+
3051 DEFAULT_CSS = """
3152 SharpnessChart {
3253 height: 12;
3354 width: 100%;
3455 border: solid $primary;
3556 margin: 1 0;
57+ overflow-x: auto;
58+ overflow-y: hidden;
59+ }
60+
61+ SharpnessChart:focus {
62+ border: tall $accent;
63+ }
64+
65+ SharpnessChart .sharpness-chart--selected {
66+ color: $primary-lighten-2;
67+ text-style: bold;
68+ }
69+
70+ SharpnessChart .sharpness-chart--unselected {
71+ color: $text-muted;
72+ }
73+
74+ SharpnessChart .sharpness-chart--title {
75+ color: $primary;
76+ text-style: bold;
3677 }
3778 """
38-
39- def __init__ (self , frames : List [FrameData ], selected_indices : set = None , max_frames : int = 100 , ** kwargs ):
79+
80+ def __init__ (
81+ self ,
82+ frames : List [FrameData ],
83+ selected_indices : Optional [set [int ]] = None ,
84+ ** kwargs ,
85+ ) -> None :
4086 super ().__init__ (** kwargs )
41- self .frames = frames [:max_frames ] # Only show first 100 frames
42- self .selected_indices = selected_indices or set ()
43- self .max_frames = max_frames
44-
45- # Calculate min and max sharpness for normalization
87+ self .frames = frames
88+ self .selected_indices = set (selected_indices or ())
89+ self .virtual_size = Size (max (len (frames ), 1 ), 1 )
90+ self .border_title = f"Frame selection - { len (frames ):,} analyzed"
91+ self .border_subtitle = "Arrows scroll | Ctrl+PgUp/PgDn page | Home/End"
92+
4693 if self .frames :
4794 scores = [f .sharpness_score for f in self .frames ]
4895 self .min_score = min (scores )
4996 self .max_score = max (scores )
50- self .score_range = self .max_score - self .min_score if self . max_score > self . min_score else 1
97+ self .score_range = max ( self .max_score - self .min_score , 1 )
5198 else :
5299 self .min_score = 0
53100 self .max_score = 1
54101 self .score_range = 1
55-
56- def update_selection (self , selected_indices : set ):
102+
103+ def on_resize (self , _event : Resize ) -> None :
104+ """Keep the virtual canvas as tall as the visible chart."""
105+ scrollbar_rows = int (len (self .frames ) > self .size .width )
106+ self .virtual_size = Size (
107+ max (len (self .frames ), 1 ),
108+ max (self .size .height - scrollbar_rows , 1 ),
109+ )
110+
111+ def update_selection (self , selected_indices : set [int ]) -> None :
57112 """Update the selection status and refresh the chart."""
58- self .selected_indices = selected_indices
113+ self .selected_indices = set ( selected_indices )
59114 self .refresh ()
60-
61- def render_line (self , y : int ) -> "Strip" :
62- """Render a single line of the chart."""
63- from textual .strip import Strip
64115
65- # Textual's monochrome/ANSI filters expect every custom segment to
66- # carry a concrete Rich style. Unstyled padding segments render in a
67- # normal terminal, but crash newer Textual releases in no-color mode.
68- blank_style = Style ()
116+ def action_first_frame (self ) -> None :
117+ """Scroll directly to the beginning of the analyzed timeline."""
118+ self .scroll_to (x = 0 , animate = False )
119+
120+ def action_last_frame (self ) -> None :
121+ """Scroll directly to the end of the analyzed timeline."""
122+ self .scroll_to (x = self .max_scroll_x , animate = False )
69123
124+ def render_line (self , y : int ) -> Strip :
125+ """Render only the visible frame window at the current scroll offset."""
126+ blank_style = Style ()
70127 width = self .size .width
71- height = self .size .height - 2 # Account for border
128+ viewport_height = self .scrollable_content_region .height
129+ if width < 1 or y >= viewport_height :
130+ return Strip ([], 0 )
72131
73- if not self .frames or width < 10 or height < 1 :
74- return Strip ([Segment (" " * width , blank_style )])
75-
76- # First line is the title
132+ start , stop = self ._visible_frame_range (width )
77133 if y == 0 :
78- title = "Frame selection - first 100"
79- padding = (width - len (title )) // 2
80- return Strip ([
81- Segment (" " * padding , blank_style ),
82- Segment (title , Style (color = "#3190FF" , bold = True )),
83- Segment (" " * (width - padding - len (title )), blank_style )
84- ])
85-
86- # Chart content starts from line 1
134+ return self ._render_window_title (width , start , stop , blank_style )
135+
136+ if not self .frames :
137+ return Strip ([Segment (" " * width , blank_style )], width )
138+
87139 chart_y = y - 1
88- chart_height = height - 1 # Reserve first line for title
89-
90- # Calculate bar dimensions
91- num_frames = min (len (self .frames ), 100 ) # Max 100 frames
92- bar_width = 1 # Each bar is 1 character wide
93- gap_width = 1 # 1 character gap between bars
94-
95- segments = []
96-
97- for i in range (num_frames ):
98- frame = self .frames [i ]
99-
100- # Normalize sharpness score to chart height
140+ chart_height = max (viewport_height - 1 , 1 )
141+ selected_style = self .get_component_rich_style (
142+ "sharpness-chart--selected"
143+ )
144+ unselected_style = self .get_component_rich_style (
145+ "sharpness-chart--unselected"
146+ )
147+ segments : list [Segment ] = []
148+
149+ for frame in self .frames [start :stop ]:
101150 normalized_score = (frame .sharpness_score - self .min_score ) / self .score_range
102- bar_height = int (normalized_score * chart_height )
103-
104- # Determine if we should draw the bar at this y position
105- # chart_y=0 is top of chart, chart_height-1 is bottom
151+ bar_height = 1 + int (normalized_score * (chart_height - 1 ))
106152 should_draw = (chart_height - 1 - chart_y ) < bar_height
107-
108- # Choose color based on selection status
109- if frame .index in self .selected_indices :
110- color = Style (color = "white" , bold = True )
111- else :
112- color = Style (color = "#666666" ) # Light grey for unselected
113-
114- # Draw the bar
115- if should_draw :
116- segments .append (Segment ("█" , color ))
117- else :
118- segments .append (Segment (" " , blank_style ))
119-
120- # Add gap after bar (except for the last one)
121- if i < num_frames - 1 :
122- segments .append (Segment (" " , blank_style ))
123-
124- # Calculate total width used
125- total_used = num_frames * bar_width + (num_frames - 1 ) * gap_width
126-
127- # Fill remaining space
128- remaining = width - total_used
129- if remaining > 0 :
130- segments .append (Segment (" " * remaining , blank_style ))
131-
132- return Strip (segments )
153+ style = (
154+ selected_style
155+ if frame .index in self .selected_indices
156+ else unselected_style
157+ )
158+ segments .append (Segment ("█" if should_draw else " " , style ))
159+
160+ padding = width - (stop - start )
161+ if padding > 0 :
162+ segments .append (Segment (" " * padding , blank_style ))
163+ return Strip (segments , width )
164+
165+ def _visible_frame_range (self , width : int ) -> tuple [int , int ]:
166+ """Return the frame positions represented by the visible viewport."""
167+ start = min (int (self .scroll_offset .x ), len (self .frames ))
168+ return start , min (start + width , len (self .frames ))
169+
170+ def _render_window_title (
171+ self ,
172+ width : int ,
173+ start : int ,
174+ stop : int ,
175+ blank_style : Style ,
176+ ) -> Strip :
177+ """Render a centered description of the visible timeline window."""
178+ if not self .frames :
179+ title = "No analyzed frames"
180+ else :
181+ title = f"Frames { start + 1 :,} -{ stop :,} of { len (self .frames ):,} "
182+ title = title [:width ]
183+ left_padding = max ((width - len (title )) // 2 , 0 )
184+ right_padding = width - left_padding - len (title )
185+ title_style = self .get_component_rich_style ("sharpness-chart--title" )
186+ return Strip (
187+ [
188+ Segment (" " * left_padding , blank_style ),
189+ Segment (title , title_style ),
190+ Segment (" " * right_padding , blank_style ),
191+ ],
192+ width ,
193+ )
133194
134195
135196class InputWithControls (Widget ):
@@ -230,7 +291,7 @@ def value(self) -> str:
230291 try :
231292 input_widget = self .query_one (f"#{ self .input_id } " , Input )
232293 return input_widget .value
233- except :
294+ except NoMatches :
234295 return self ._value
235296
236297 @value .setter
@@ -240,7 +301,7 @@ def value(self, new_value: str) -> None:
240301 try :
241302 input_widget = self .query_one (f"#{ self .input_id } " , Input )
242303 input_widget .value = new_value
243- except :
304+ except NoMatches :
244305 pass
245306
246307
@@ -329,11 +390,10 @@ def compose(self) -> ComposeResult:
329390 yield Static ("Select Frames" , classes = "title_left" )
330391 yield Static (f"Choose from { total_frames :,} analyzed frames" , classes = "title_right" )
331392
332- # Sharpness chart - shows first 100 frames
393+ # Sharpness chart - one horizontally scrollable column per frame
333394 yield SharpnessChart (
334395 self .extraction_result .frames ,
335396 selected_indices = self .selected_indices ,
336- max_frames = 100 ,
337397 id = "sharpness_chart"
338398 )
339399
@@ -608,7 +668,7 @@ async def _update_parameter_inputs_async(self) -> None:
608668 try :
609669 input_field = first_input .query_one (Input )
610670 input_field .focus ()
611- except :
671+ except NoMatches :
612672 first_input .focus ()
613673
614674 except Exception as e :
0 commit comments