Skip to content

Commit 7c670a3

Browse files
committed
fix: separate frame chart bars
1 parent 63f6e0d commit 7c670a3

2 files changed

Lines changed: 87 additions & 28 deletions

File tree

‎sharp_frames/ui/screens/selection.py‎

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
class 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,

‎tests/ui/test_selection_screen.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def test_chart_normalizes_scores_without_discarding_frames(extraction_result):
160160
chart = SharpnessChart(frames, selected_indices={2, 4, 4_999})
161161

162162
assert len(chart.frames) == 5_000
163-
assert chart.virtual_size.width == 5_000
163+
assert chart.virtual_size.width == 10_000
164164
assert chart.min_score == 1.0
165165
assert chart.max_score == 5_000.0
166166
assert chart.score_range == 4_999.0
@@ -182,16 +182,18 @@ def compose(self) -> ComposeResult:
182182
async with ChartApp().run_test(size=(80, 24)) as pilot:
183183
await pilot.pause()
184184

185-
assert chart.max_scroll_x > 4_000
185+
assert chart.max_scroll_x > 9_000
186186
assert chart.max_scroll_y == 0
187187
assert chart.render_line(1).cell_length == chart.size.width
188+
assert "Frames 1-39 of 5,000" in chart.render_line(0).text
188189
bottom_line = chart.render_line(chart.scrollable_content_region.height - 1)
189-
assert bottom_line.text[0] == "█"
190+
assert bottom_line.text[:8] == "█ █ █ █ "
190191

191192
chart.focus()
192193
await pilot.press("right")
193194
await pilot.pause(0.1)
194-
assert chart.scroll_offset.x > 0
195+
assert chart.scroll_offset.x == SharpnessChart.FRAME_STRIDE
196+
assert "Frames 2-40 of 5,000" in chart.render_line(0).text
195197

196198
await pilot.press("end")
197199
await pilot.pause()

0 commit comments

Comments
 (0)