@@ -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