Skip to content

Commit de69972

Browse files
authored
Add pinning row height (#83)
* Add pinning row height * Add text wrapping * Reformat
1 parent 02eef9c commit de69972

4 files changed

Lines changed: 175 additions & 58 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "phosphor"
3-
version = "0.4.3"
3+
version = "0.4.4"
44
edition = "2021"
55
description = "Phosphor — a SID player for USBSID-Pico / Ultimate 64"
66

src/main.rs

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,93 @@ use std::time::{Duration, Instant};
8080

8181
use std::sync::{Arc, Mutex};
8282

83+
// ─────────────────────────────────────────────────────────────────────────────
84+
// Opt-in per-frame timing profiler
85+
// ─────────────────────────────────────────────────────────────────────────────
86+
//
87+
// Enable with `PHOSPHOR_PROFILE_UPDATE=1`. When set, each `update()` and
88+
// `view()` call is timed via an RAII guard; every 30 samples (roughly one
89+
// second at the 30 Hz tick rate) we print the per-frame averages to
90+
// stderr:
91+
//
92+
// [perf] update=0.31ms/frame view=3.14ms/frame
93+
//
94+
// Zero cost when the env var is unset (single `OnceLock` bool check per
95+
// frame + no accumulator writes).
96+
97+
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
98+
use std::sync::OnceLock;
99+
100+
static PROFILE_ENABLED: OnceLock<bool> = OnceLock::new();
101+
static UPDATE_ACCUM_US: AtomicU64 = AtomicU64::new(0);
102+
static VIEW_ACCUM_US: AtomicU64 = AtomicU64::new(0);
103+
static SAMPLE_COUNT: AtomicU64 = AtomicU64::new(0);
104+
105+
fn profile_enabled() -> bool {
106+
*PROFILE_ENABLED.get_or_init(|| std::env::var("PHOSPHOR_PROFILE_UPDATE").is_ok())
107+
}
108+
109+
enum ProfilerKind {
110+
Update,
111+
View,
112+
}
113+
114+
struct ProfilerGuard {
115+
kind: ProfilerKind,
116+
start: Instant,
117+
}
118+
119+
impl ProfilerGuard {
120+
#[inline]
121+
fn update() -> Option<Self> {
122+
if profile_enabled() {
123+
Some(Self {
124+
kind: ProfilerKind::Update,
125+
start: Instant::now(),
126+
})
127+
} else {
128+
None
129+
}
130+
}
131+
132+
#[inline]
133+
fn view() -> Option<Self> {
134+
if profile_enabled() {
135+
Some(Self {
136+
kind: ProfilerKind::View,
137+
start: Instant::now(),
138+
})
139+
} else {
140+
None
141+
}
142+
}
143+
}
144+
145+
impl Drop for ProfilerGuard {
146+
fn drop(&mut self) {
147+
let us = self.start.elapsed().as_micros() as u64;
148+
match self.kind {
149+
ProfilerKind::Update => {
150+
UPDATE_ACCUM_US.fetch_add(us, AtomicOrdering::Relaxed);
151+
}
152+
ProfilerKind::View => {
153+
VIEW_ACCUM_US.fetch_add(us, AtomicOrdering::Relaxed);
154+
let n = SAMPLE_COUNT.fetch_add(1, AtomicOrdering::Relaxed) + 1;
155+
if n >= 30 {
156+
let u = UPDATE_ACCUM_US.swap(0, AtomicOrdering::Relaxed);
157+
let v = VIEW_ACCUM_US.swap(0, AtomicOrdering::Relaxed);
158+
SAMPLE_COUNT.store(0, AtomicOrdering::Relaxed);
159+
eprintln!(
160+
"[perf] update={:.2}ms/frame view={:.2}ms/frame (last {n} frames)",
161+
u as f64 / (n as f64) / 1000.0,
162+
v as f64 / (n as f64) / 1000.0,
163+
);
164+
}
165+
}
166+
}
167+
}
168+
}
169+
83170
use crossbeam_channel::{self, Receiver, Sender};
84171
use iced::widget::{column, container, mouse_area, rule, Space};
85172
use iced::{event, time, Color, Element, Length, Subscription, Task, Theme};
@@ -698,6 +785,7 @@ impl App {
698785
}
699786

700787
fn update(&mut self, message: Message) -> Task<Message> {
788+
let _perf = ProfilerGuard::update();
701789
match message {
702790
// ── Any interaction dismisses the context menu ────────────────
703791
// (handled per-message below where needed; explicit dismiss too)
@@ -711,7 +799,7 @@ impl App {
711799
// is stored and reused for x as well.
712800
//
713801
// Logical row centre y =
714-
// playlist_viewport_y + display_pos*ROW_HEIGHT - scroll_offset_y + ROW_HEIGHT/2
802+
// playlist_viewport_y + display_pos*row_height() - scroll_offset_y + row_height()/2
715803
//
716804
// pixel_ratio = raw_cursor_y / logical_row_y
717805
// We round to the nearest common value (1.0, 1.5, 2.0, 3.0) to avoid
@@ -724,9 +812,9 @@ impl App {
724812
.unwrap_or(0) as f32;
725813

726814
// Logical y of the centre of the clicked row.
727-
let logical_row_y = self.playlist_viewport_y + display_pos * ui::ROW_HEIGHT
815+
let logical_row_y = self.playlist_viewport_y + display_pos * ui::row_height()
728816
- self.playlist_scroll_offset_y
729-
+ ui::ROW_HEIGHT * 0.5;
817+
+ ui::row_height() * 0.5;
730818

731819
// Calibrate ratio when we have valid logical geometry.
732820
if logical_row_y > 0.0 && y > 0.0 {
@@ -739,10 +827,10 @@ impl App {
739827

740828
let ratio = self.pixel_ratio;
741829
let menu_x = (x / ratio).max(0.0);
742-
let menu_y = (self.playlist_viewport_y + display_pos * ui::ROW_HEIGHT
830+
let menu_y = (self.playlist_viewport_y + display_pos * ui::row_height()
743831
- self.playlist_scroll_offset_y
744-
+ ui::ROW_HEIGHT)
745-
.max(0.0);
832+
+ ui::row_height())
833+
.max(0.0);
746834

747835
eprintln!("[phosphor] ShowContextMenu idx={idx} raw=({x:.1},{y:.1}) ratio={ratio:.2} menu=({menu_x:.1},{menu_y:.1})");
748836

@@ -1258,7 +1346,7 @@ impl App {
12581346
let total = self.filtered_indices.len();
12591347
// Update virtual scroll offset immediately so the newly selected
12601348
// row is included in the render window before the snap_to fires.
1261-
self.playlist_scroll_offset_y = next as f32 * ui::ROW_HEIGHT;
1349+
self.playlist_scroll_offset_y = next as f32 * ui::row_height();
12621350
if total > 1 {
12631351
return iced::widget::operation::snap_to(
12641352
ui::playlist_scrollable_id(),
@@ -1283,7 +1371,7 @@ impl App {
12831371
self.selected = Some(self.filtered_indices[prev]);
12841372
let total = self.filtered_indices.len();
12851373
// Same immediate update for SelectPrev.
1286-
self.playlist_scroll_offset_y = prev as f32 * ui::ROW_HEIGHT;
1374+
self.playlist_scroll_offset_y = prev as f32 * ui::row_height();
12871375
if total > 1 {
12881376
return iced::widget::operation::snap_to(
12891377
ui::playlist_scrollable_id(),
@@ -2867,6 +2955,7 @@ impl App {
28672955
}
28682956

28692957
fn view(&self) -> Element<'_, Message> {
2958+
let _perf = ProfilerGuard::view();
28702959
// ── Mini player mode ─────────────────────────────────────────────────
28712960
if self.mini_mode {
28722961
let current_duration = self.playlist.current_entry().and_then(|e| e.duration_secs);

src/ui/mod.rs

Lines changed: 76 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,14 +1092,25 @@ pub fn search_bar<'a>(
10921092
// ─────────────────────────────────────────────────────────────────────────────
10931093

10941094
/// Height of a single playlist row in logical pixels.
1095-
/// Must match the actual rendered height: 4px top pad + 13px text + 4px bottom
1096-
/// pad + 1px rule = 22px. We add a small buffer (26px) so a partially visible
1097-
/// row at either edge is always included.
1098-
pub const ROW_HEIGHT: f32 = 26.0;
1095+
/// Row height that scales with the user's chosen base font size. At the
1096+
/// default 12 pt base (scale = 1.0) this returns 26.0, matching the
1097+
/// historical constant. At a 16 pt base (scale ≈ 1.33) it returns ~35,
1098+
/// keeping the virtual-scroll math in sync with the actually-rendered
1099+
/// text so rows don't shake or clip into their neighbours.
1100+
///
1101+
/// Derived as: 13 pt text intrinsic height (~18 px) + 4+4 px top/bottom
1102+
/// padding on the inner row + a small line-height slack — comes out to
1103+
/// exactly 26 px at scale 1.0.
1104+
pub fn row_height() -> f32 {
1105+
26.0 * font::scale()
1106+
}
10991107

11001108
/// Number of extra rows to render above and below the visible window.
1101-
/// Acts as a scroll lookahead so rows don't pop in mid-scroll.
1102-
const OVERSCAN: usize = 8;
1109+
/// Acts as a scroll lookahead so rows don't pop in mid-scroll. Kept
1110+
/// deliberately small — every extra row is a fresh widget tree we
1111+
/// rebuild every frame. At smaller font sizes more rows fit into the
1112+
/// viewport, so this constant compounds the per-frame widget count.
1113+
const OVERSCAN: usize = 3;
11031114

11041115
/// Build the scrollable playlist table with sortable column headers.
11051116
/// `filtered_indices` maps visible row position → actual `playlist.entries` index.
@@ -1217,12 +1228,15 @@ pub fn playlist_view<'a>(
12171228

12181229
// ── Virtual window calculation ────────────────────────────────────
12191230
// Compute which rows are visible, with overscan on both sides.
1220-
let first_visible = ((scroll_offset_y / ROW_HEIGHT) as usize).saturating_sub(OVERSCAN);
1221-
let rows_in_view = (viewport_height / ROW_HEIGHT).ceil() as usize + 1;
1231+
// Snapshot the row height once so every step of the math uses the
1232+
// same value even if the font scale changes mid-render.
1233+
let rh = row_height();
1234+
let first_visible = ((scroll_offset_y / rh) as usize).saturating_sub(OVERSCAN);
1235+
let rows_in_view = (viewport_height / rh).ceil() as usize + 1;
12221236
let last_visible = (first_visible + rows_in_view + OVERSCAN * 2).min(total_rows);
12231237

12241238
// Top spacer — replaces all rows above the render window
1225-
let top_space = first_visible as f32 * ROW_HEIGHT;
1239+
let top_space = first_visible as f32 * rh;
12261240
if top_space > 0.0 {
12271241
rows = rows.push(Space::new().height(Length::Fixed(top_space)));
12281242
}
@@ -1251,7 +1265,7 @@ pub fn playlist_view<'a>(
12511265

12521266
// Bottom spacer — replaces all rows below the render window
12531267
let bottom_rows = total_rows.saturating_sub(last_visible);
1254-
let bottom_space = bottom_rows as f32 * ROW_HEIGHT;
1268+
let bottom_space = bottom_rows as f32 * rh;
12551269
if bottom_space > 0.0 {
12561270
rows = rows.push(Space::new().height(Length::Fixed(bottom_space)));
12571271
}
@@ -1642,6 +1656,11 @@ fn playlist_entry_row<'a>(
16421656
.padding(Padding::from([0, 4])),
16431657
)
16441658
.width(Length::Fill)
1659+
// Pin to the same value the virtual scroller uses so 1-px intrinsic
1660+
// font-metric differences between rows can't accumulate into visible
1661+
// shaking, and font-size changes stay consistent.
1662+
.height(Length::Fixed(row_height()))
1663+
.clip(true)
16451664
.style(move |_theme: &Theme| container::Style {
16461665
background: bg,
16471666
..Default::default()
@@ -1675,54 +1694,63 @@ fn playlist_row_content<'a>(
16751694
};
16761695
let indicator = if is_current { "▶ " } else { " " };
16771696

1697+
// Column cell wrapper: forces the text to a single line AND clips
1698+
// any horizontal overflow so long titles / authors can't bleed
1699+
// into the neighbouring column. `Length::Fill` for height lets the
1700+
// container inherit the fixed row height instead of doing its own
1701+
// intrinsic layout pass every frame.
1702+
let nowrap_cell = |content: Element<'a, Message>, width: Length| -> Element<'a, Message> {
1703+
container(content)
1704+
.width(width)
1705+
.height(Length::Fill)
1706+
.clip(true)
1707+
.into()
1708+
};
1709+
let nowrap_text = |s: String, col: Color, width: Length| -> Element<'a, Message> {
1710+
nowrap_cell(
1711+
text(s)
1712+
.size(font::sized(size))
1713+
.color(col)
1714+
.wrapping(iced::widget::text::Wrapping::None)
1715+
.into(),
1716+
width,
1717+
)
1718+
};
1719+
16781720
let title_cell: Element<'a, Message> = if has_wds {
1679-
row![
1680-
text(title).size(font::sized(size)).color(color),
1681-
text(" (Karaoke)")
1682-
.size(font::sized(10.0))
1683-
.color(Color::from_rgb(0.30, 0.75, 0.45)),
1684-
]
1685-
.spacing(4)
1686-
.width(Length::FillPortion(4))
1687-
.into()
1721+
nowrap_cell(
1722+
row![
1723+
text(title)
1724+
.size(font::sized(size))
1725+
.color(color)
1726+
.wrapping(iced::widget::text::Wrapping::None),
1727+
text(" (Karaoke)")
1728+
.size(font::sized(10.0))
1729+
.color(Color::from_rgb(0.30, 0.75, 0.45))
1730+
.wrapping(iced::widget::text::Wrapping::None),
1731+
]
1732+
.spacing(4)
1733+
.into(),
1734+
Length::FillPortion(4),
1735+
)
16881736
} else {
1689-
text(title)
1690-
.size(font::sized(size))
1691-
.color(color)
1692-
.width(Length::FillPortion(4))
1693-
.into()
1737+
nowrap_text(title, color, Length::FillPortion(4))
16941738
};
16951739

16961740
row![
1697-
text(format!("{indicator}{num:>3}"))
1698-
.size(font::sized(size))
1699-
.color(color)
1700-
.width(Length::Fixed(50.0)),
1741+
nowrap_text(format!("{indicator}{num:>3}"), color, Length::Fixed(50.0)),
17011742
title_cell,
1702-
text(author)
1703-
.size(font::sized(size))
1704-
.color(color)
1705-
.width(Length::FillPortion(3)),
1706-
text(released)
1707-
.size(font::sized(size))
1708-
.color(color)
1709-
.width(Length::FillPortion(2)),
1710-
text(time)
1711-
.size(font::sized(size))
1712-
.color(color)
1713-
.width(Length::Fixed(55.0)),
1714-
text(sid_type)
1715-
.size(font::sized(size))
1716-
.color(type_color)
1717-
.width(Length::Fixed(42.0)),
1718-
text(sids)
1719-
.size(font::sized(size))
1720-
.color(color)
1721-
.width(Length::Fixed(45.0)),
1743+
nowrap_text(author, color, Length::FillPortion(3)),
1744+
nowrap_text(released, color, Length::FillPortion(2)),
1745+
nowrap_text(time, color, Length::Fixed(55.0)),
1746+
nowrap_text(sid_type, type_color, Length::Fixed(42.0)),
1747+
nowrap_text(sids, color, Length::Fixed(45.0)),
17221748
]
17231749
.spacing(8)
17241750
.align_y(Alignment::Center)
17251751
.padding(Padding::from([4, 4]))
1752+
// Fill the fixed-height parent so intrinsic row height never wins.
1753+
.height(Length::Fill)
17261754
.into()
17271755
}
17281756

0 commit comments

Comments
 (0)