3131class SharpnessChart (ScrollView ):
3232 """Horizontally scrollable frame-by-frame sharpness timeline."""
3333
34+ FRAME_STRIDE = 2
3435 can_focus = True
3536
3637 BINDINGS = [
@@ -68,7 +69,7 @@ class SharpnessChart(ScrollView):
6869 }
6970
7071 SharpnessChart .sharpness-chart--unselected {
71- color: $text-muted ;
72+ color: $primary-darken-2 ;
7273 }
7374
7475 SharpnessChart .sharpness-chart--title {
@@ -86,7 +87,8 @@ def __init__(
8687 super ().__init__ (** kwargs )
8788 self .frames = frames
8889 self .selected_indices = set (selected_indices or ())
89- self .virtual_size = Size (max (len (frames ), 1 ), 1 )
90+ self .timeline_width = max (len (frames ) * self .FRAME_STRIDE , 1 )
91+ self .virtual_size = Size (self .timeline_width , 1 )
9092 self .border_title = f"Frame selection - { len (frames ):,} analyzed"
9193 self .border_subtitle = "Arrows scroll | Ctrl+PgUp/PgDn page | Home/End"
9294
@@ -102,9 +104,9 @@ def __init__(
102104
103105 def on_resize (self , _event : Resize ) -> None :
104106 """Keep the virtual canvas as tall as the visible chart."""
105- scrollbar_rows = int (len ( self .frames ) > self .size .width )
107+ scrollbar_rows = int (self .timeline_width > self .size .width )
106108 self .virtual_size = Size (
107- max ( len ( self .frames ), 1 ) ,
109+ self .timeline_width ,
108110 max (self .size .height - scrollbar_rows , 1 ),
109111 )
110112
@@ -117,6 +119,14 @@ def action_first_frame(self) -> None:
117119 """Scroll directly to the beginning of the analyzed timeline."""
118120 self .scroll_to (x = 0 , animate = False )
119121
122+ def action_scroll_left (self ) -> None :
123+ """Scroll left by one complete frame slot."""
124+ self .scroll_relative (x = - self .FRAME_STRIDE , animate = False )
125+
126+ def action_scroll_right (self ) -> None :
127+ """Scroll right by one complete frame slot."""
128+ self .scroll_relative (x = self .FRAME_STRIDE , animate = False )
129+
120130 def action_last_frame (self ) -> None :
121131 """Scroll directly to the end of the analyzed timeline."""
122132 self .scroll_to (x = self .max_scroll_x , animate = False )
@@ -129,43 +139,90 @@ def render_line(self, y: int) -> Strip:
129139 if width < 1 or y >= viewport_height :
130140 return Strip ([], 0 )
131141
132- start , stop = self ._visible_frame_range (width )
133142 if y == 0 :
143+ start , stop = self ._visible_frame_range (width )
134144 return self ._render_window_title (width , start , stop , blank_style )
135145
136146 if not self .frames :
137147 return Strip ([Segment (" " * width , blank_style )], width )
138148
139- chart_y = y - 1
140- chart_height = max (viewport_height - 1 , 1 )
149+ return self ._render_chart_line (
150+ width ,
151+ y - 1 ,
152+ max (viewport_height - 1 , 1 ),
153+ blank_style ,
154+ )
155+
156+ def _render_chart_line (
157+ self ,
158+ width : int ,
159+ chart_y : int ,
160+ chart_height : int ,
161+ blank_style : Style ,
162+ ) -> Strip :
163+ """Render frame bars and their guaranteed blank separator columns."""
141164 selected_style = self .get_component_rich_style (
142165 "sharpness-chart--selected"
143166 )
144167 unselected_style = self .get_component_rich_style (
145168 "sharpness-chart--unselected"
146169 )
147- segments : list [Segment ] = []
148-
149- for frame in self .frames [start :stop ]:
150- normalized_score = (frame .sharpness_score - self .min_score ) / self .score_range
151- bar_height = 1 + int (normalized_score * (chart_height - 1 ))
152- should_draw = (chart_height - 1 - chart_y ) < bar_height
153- style = (
154- selected_style
155- if frame .index in self .selected_indices
156- else unselected_style
170+ segments = [
171+ self ._render_timeline_column (
172+ virtual_column ,
173+ chart_y ,
174+ chart_height ,
175+ selected_style ,
176+ unselected_style ,
177+ blank_style ,
157178 )
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 ))
179+ for virtual_column in range (
180+ int ( self . scroll_offset . x ),
181+ int ( self . scroll_offset . x ) + width ,
182+ )
183+ ]
163184 return Strip (segments , width )
164185
186+ def _render_timeline_column (
187+ self ,
188+ virtual_column : int ,
189+ chart_y : int ,
190+ chart_height : int ,
191+ selected_style : Style ,
192+ unselected_style : Style ,
193+ blank_style : Style ,
194+ ) -> Segment :
195+ """Render a bar column or the blank gap following it."""
196+ frame_position , slot_column = divmod (virtual_column , self .FRAME_STRIDE )
197+ if slot_column or frame_position >= len (self .frames ):
198+ return Segment (" " , blank_style )
199+
200+ frame = self .frames [frame_position ]
201+ normalized = (frame .sharpness_score - self .min_score ) / self .score_range
202+ bar_height = 1 + int (normalized * (chart_height - 1 ))
203+ if (chart_height - 1 - chart_y ) >= bar_height :
204+ return Segment (" " , blank_style )
205+
206+ style = (
207+ selected_style
208+ if frame .index in self .selected_indices
209+ else unselected_style
210+ )
211+ return Segment ("█" , style )
212+
165213 def _visible_frame_range (self , width : int ) -> tuple [int , int ]:
166214 """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 ))
215+ scroll_x = min (int (self .scroll_offset .x ), self .timeline_width )
216+ visible_end = min (scroll_x + width , self .timeline_width )
217+ start = min (
218+ (scroll_x + self .FRAME_STRIDE - 1 ) // self .FRAME_STRIDE ,
219+ len (self .frames ),
220+ )
221+ stop = min (
222+ (visible_end + self .FRAME_STRIDE - 1 ) // self .FRAME_STRIDE ,
223+ len (self .frames ),
224+ )
225+ return start , stop
169226
170227 def _render_window_title (
171228 self ,
0 commit comments