Skip to content

Commit 63f6e0d

Browse files
committed
feat: add scrollable frame timeline
1 parent 27465a2 commit 63f6e0d

2 files changed

Lines changed: 192 additions & 91 deletions

File tree

‎sharp_frames/ui/screens/selection.py‎

Lines changed: 144 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
from textual.app import ComposeResult
99
from textual.binding import Binding
1010
from textual.containers import Container, Horizontal
11+
from textual.css.query import NoMatches
12+
from textual.events import Resize
13+
from textual.geometry import Size
1114
from textual.message import Message
1215
from textual.reactive import reactive
1316
from textual.screen import Screen
17+
from textual.scroll_view import ScrollView
1418
from textual.strip import Strip
1519
from textual.widget import Widget
1620
from textual.widgets import (
@@ -24,112 +28,169 @@
2428
from ...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

135196
class 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:

‎tests/ui/test_selection_screen.py‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import AsyncMock, Mock, patch
66

77
import pytest
8+
from textual.app import App, ComposeResult
89

910
from sharp_frames.models.frame_data import ExtractionResult, FrameData
1011
from sharp_frames.ui.screens.selection import (
@@ -151,15 +152,55 @@ def test_selection_preview_message_preserves_payload(screen):
151152
assert message.params == {"n": 14, "min_buffer": 3}
152153

153154

154-
def test_chart_normalizes_scores_and_caps_rendered_frame_count(extraction_result):
155-
frames = extraction_result.frames * 6
156-
chart = SharpnessChart(frames, selected_indices={2, 4}, max_frames=100)
155+
def test_chart_normalizes_scores_without_discarding_frames(extraction_result):
156+
frames = [
157+
FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1))
158+
for index in range(5_000)
159+
]
160+
chart = SharpnessChart(frames, selected_indices={2, 4, 4_999})
157161

158-
assert len(chart.frames) == 100
162+
assert len(chart.frames) == 5_000
163+
assert chart.virtual_size.width == 5_000
159164
assert chart.min_score == 1.0
160-
assert chart.max_score == 20.0
161-
assert chart.score_range == 19.0
162-
assert chart.selected_indices == {2, 4}
165+
assert chart.max_score == 5_000.0
166+
assert chart.score_range == 4_999.0
167+
assert chart.selected_indices == {2, 4, 4_999}
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_chart_scrolls_across_thousands_of_frames():
172+
frames = [
173+
FrameData(f"/tmp/frame_{index:05d}.jpg", index, float(index + 1))
174+
for index in range(5_000)
175+
]
176+
chart = SharpnessChart(frames)
177+
178+
class ChartApp(App):
179+
def compose(self) -> ComposeResult:
180+
yield chart
181+
182+
async with ChartApp().run_test(size=(80, 24)) as pilot:
183+
await pilot.pause()
184+
185+
assert chart.max_scroll_x > 4_000
186+
assert chart.max_scroll_y == 0
187+
assert chart.render_line(1).cell_length == chart.size.width
188+
bottom_line = chart.render_line(chart.scrollable_content_region.height - 1)
189+
assert bottom_line.text[0] == "█"
190+
191+
chart.focus()
192+
await pilot.press("right")
193+
await pilot.pause(0.1)
194+
assert chart.scroll_offset.x > 0
195+
196+
await pilot.press("end")
197+
await pilot.pause()
198+
assert chart.is_horizontal_scroll_end
199+
assert chart.render_line(0).text.rstrip().endswith("of 5,000")
200+
201+
await pilot.press("home")
202+
await pilot.pause()
203+
assert chart.scroll_offset.x == 0
163204

164205

165206
def test_input_with_controls_retains_value_before_mount():

0 commit comments

Comments
 (0)