@@ -83,8 +83,10 @@ pub struct BrowseTab {
8383 grid_search_bar : gtk:: SearchBar ,
8484 grid_search_handler : Option < glib:: SignalHandlerId > ,
8585 paginator_label : gtk:: Label ,
86+ first_button : gtk:: Button ,
8687 prev_button : gtk:: Button ,
8788 next_button : gtk:: Button ,
89+ last_button : gtk:: Button ,
8890 insert_button : gtk:: Button ,
8991 delete_button : gtk:: Button ,
9092 save_button : gtk:: Button ,
@@ -114,10 +116,15 @@ pub enum BrowseTabInput {
114116 Refresh ,
115117 /// Reveal the in-grid find bar and focus it.
116118 FindInResults ,
119+ /// User clicked First page (offset → 0).
120+ FirstPage ,
117121 /// User clicked Prev page.
118122 PrevPage ,
119123 /// User clicked Next page.
120124 NextPage ,
125+ /// User clicked Last page (offset → last full page based on
126+ /// row count). No-op if the row count isn't known yet.
127+ LastPage ,
121128 /// Sort flipped on column idx (from grid sorter).
122129 SortChanged {
123130 col_idx : usize ,
@@ -307,6 +314,16 @@ impl BrowseTab {
307314 /// `build_mutation_bar`) so destructive controls aren't sitting
308315 /// next to navigation arrows in misclick range.
309316 fn build_paginator ( sender : ComponentSender < Self > , page_size : u64 ) -> Paginator {
317+ // First / Last bracket the Prev / Next pair. Tables of
318+ // millions of rows make Last especially valuable — without
319+ // it the user has to spam Next to reach the bottom. Same
320+ // visual + interaction model as TablePlus / DataGrip /
321+ // DBeaver. Last stays disabled until the row count loads.
322+ let first_button = gtk:: Button :: builder ( )
323+ . icon_name ( "go-first-symbolic" )
324+ . tooltip_text ( crate :: tr!( "First page" ) )
325+ . sensitive ( false )
326+ . build ( ) ;
310327 let prev_button = gtk:: Button :: builder ( )
311328 . icon_name ( "go-previous-symbolic" )
312329 . tooltip_text ( crate :: tr!( "Previous page (Page Up)" ) )
@@ -317,6 +334,11 @@ impl BrowseTab {
317334 . tooltip_text ( crate :: tr!( "Next page (Page Down)" ) )
318335 . sensitive ( false )
319336 . build ( ) ;
337+ let last_button = gtk:: Button :: builder ( )
338+ . icon_name ( "go-last-symbolic" )
339+ . tooltip_text ( crate :: tr!( "Last page" ) )
340+ . sensitive ( false )
341+ . build ( ) ;
320342 let paginator_label = gtk:: Label :: builder ( ) . build ( ) ;
321343 paginator_label. add_css_class ( "dim-label" ) ;
322344 paginator_label. set_accessible_role ( gtk:: AccessibleRole :: Status ) ;
@@ -349,10 +371,14 @@ impl BrowseTab {
349371 let page_size_label = gtk:: Label :: builder ( ) . label ( crate :: tr!( "Rows:" ) ) . build ( ) ;
350372 page_size_label. add_css_class ( "dim-label" ) ;
351373
374+ let sender_for_first = sender. clone ( ) ;
375+ first_button. connect_clicked ( move |_| sender_for_first. input ( BrowseTabInput :: FirstPage ) ) ;
352376 let sender_for_prev = sender. clone ( ) ;
353377 prev_button. connect_clicked ( move |_| sender_for_prev. input ( BrowseTabInput :: PrevPage ) ) ;
354- let sender_for_next = sender;
378+ let sender_for_next = sender. clone ( ) ;
355379 next_button. connect_clicked ( move |_| sender_for_next. input ( BrowseTabInput :: NextPage ) ) ;
380+ let sender_for_last = sender;
381+ last_button. connect_clicked ( move |_| sender_for_last. input ( BrowseTabInput :: LastPage ) ) ;
356382
357383 // Paginator lives in a native `gtk::ActionBar` to match the
358384 // mutations bar and the Structure tab's bottom action bar.
@@ -375,13 +401,15 @@ impl BrowseTab {
375401 . build ( ) ;
376402 export_button. add_css_class ( "flat" ) ;
377403
378- // Prev / Next sit in a `linked` group so they read as one
379- // navigation control — same pattern GNOME Files uses on its
380- // back/forward toolbar buttons.
404+ // First / Prev / Next / Last sit in a `linked` group so they
405+ // read as one navigation control — same pattern GNOME Files
406+ // uses on its back/forward toolbar buttons.
381407 let nav_box = gtk:: Box :: builder ( ) . orientation ( gtk:: Orientation :: Horizontal ) . build ( ) ;
382408 nav_box. add_css_class ( "linked" ) ;
409+ nav_box. append ( & first_button) ;
383410 nav_box. append ( & prev_button) ;
384411 nav_box. append ( & next_button) ;
412+ nav_box. append ( & last_button) ;
385413
386414 paginator_bar. pack_start ( & nav_box) ;
387415 paginator_bar. pack_start ( & paginator_label) ;
@@ -391,8 +419,10 @@ impl BrowseTab {
391419
392420 Paginator {
393421 bar : paginator_bar,
422+ first_button,
394423 prev_button,
395424 next_button,
425+ last_button,
396426 paginator_label,
397427 }
398428 }
@@ -892,9 +922,21 @@ impl BrowseTab {
892922 fn refresh_grid_chrome ( & self , result : & QueryResult ) {
893923 self . refresh_crud_buttons ( ) ;
894924 self . update_paginator_label ( ) ;
895- self . prev_button . set_sensitive ( self . current_offset > 0 ) ;
925+ let on_first_page = self . current_offset == 0 ;
926+ self . first_button . set_sensitive ( !on_first_page) ;
927+ self . prev_button . set_sensitive ( !on_first_page) ;
896928 let n_rows = result. rows . len ( ) as u64 ;
897929 self . next_button . set_sensitive ( n_rows == self . page_size ) ;
930+ // Last only enables when we know the total AND we aren't
931+ // already there. Without a known total, the button stays
932+ // disabled — matches `RowCountLoaded`-gated UX everywhere
933+ // else.
934+ let last_target = self
935+ . current_total_rows
936+ . filter ( |t| * t > 0 )
937+ . map ( |t| ( t - 1 ) / self . page_size * self . page_size ) ;
938+ self . last_button
939+ . set_sensitive ( last_target. is_some_and ( |target| self . current_offset != target) ) ;
898940
899941 // Empty-state: zero rows on the first page with no drafts →
900942 // show a status page instead of an empty grid rectangle. As
@@ -1006,20 +1048,30 @@ impl BrowseTab {
10061048 } ;
10071049 let n_rows = result. rows . len ( ) ;
10081050 if n_rows == 0 {
1009- self . paginator_label
1010- . set_label ( & crate :: tr!( "No rows at offset {n}" ) . replace ( "{n}" , & self . current_offset . to_string ( ) ) ) ;
1051+ // Reachable when the user navigated past the end of a
1052+ // table that shrank in another session, before
1053+ // RowCountLoaded clamps the offset back. Human
1054+ // wording — the previous "No rows at offset N" read as
1055+ // a bug message.
1056+ self . paginator_label . set_label ( & crate :: tr!( "No rows on this page" ) ) ;
10111057 return ;
10121058 }
10131059 let start = self . current_offset + 1 ;
10141060 let end = self . current_offset + n_rows as u64 ;
1061+ // Match the page-size dropdown's thousands grouping.
1062+ // "Rows 10,001 – 10,100 of 5,000,000" is faster to read than
1063+ // "Rows 10001 – 10100 of 5000000" and matches GNOME File's
1064+ // "1,234 items" idiom.
1065+ let start_s = format_thousands ( start) ;
1066+ let end_s = format_thousands ( end) ;
10151067 let label = match self . current_total_rows {
10161068 Some ( total) => crate :: tr!( "Rows {start} – {end} of {total}" )
1017- . replace ( "{start}" , & start . to_string ( ) )
1018- . replace ( "{end}" , & end . to_string ( ) )
1019- . replace ( "{total}" , & total . to_string ( ) ) ,
1069+ . replace ( "{start}" , & start_s )
1070+ . replace ( "{end}" , & end_s )
1071+ . replace ( "{total}" , & format_thousands ( total ) ) ,
10201072 None => crate :: tr!( "Rows {start} – {end}" )
1021- . replace ( "{start}" , & start . to_string ( ) )
1022- . replace ( "{end}" , & end . to_string ( ) ) ,
1073+ . replace ( "{start}" , & start_s )
1074+ . replace ( "{end}" , & end_s ) ,
10231075 } ;
10241076 self . paginator_label . set_label ( & label) ;
10251077 }
@@ -1033,8 +1085,8 @@ impl BrowseTab {
10331085 }
10341086
10351087 fn show_loading_inner ( & self , title : & str , description : & str ) {
1036- let spinner = gtk:: Spinner :: builder ( )
1037- . spinning ( true )
1088+ // adw::Spinner replaces deprecated gtk::Spinner (GTK 4.12+).
1089+ let spinner = adw :: Spinner :: builder ( )
10381090 . width_request ( 32 )
10391091 . height_request ( 32 )
10401092 . halign ( gtk:: Align :: Center )
@@ -1048,9 +1100,12 @@ impl BrowseTab {
10481100 }
10491101
10501102 fn show_error_inner ( & self , message : & str ) {
1103+ // Title pattern matches the structure tab ("Couldn't load
1104+ // structure"). The previous terse "Failed" left the user
1105+ // guessing what failed.
10511106 let page = adw:: StatusPage :: builder ( )
10521107 . icon_name ( "dialog-error-symbolic" )
1053- . title ( crate :: tr!( "Failed " ) )
1108+ . title ( crate :: tr!( "Couldn't load rows " ) )
10541109 . description ( message)
10551110 . build ( ) ;
10561111 self . replace_status_child ( "error" , & page) ;
@@ -1133,8 +1188,7 @@ impl SimpleComponent for BrowseTab {
11331188 } ,
11341189 ) )
11351190 . child (
1136- & gtk:: Spinner :: builder ( )
1137- . spinning ( true )
1191+ & adw:: Spinner :: builder ( )
11381192 . width_request ( 32 )
11391193 . height_request ( 32 )
11401194 . halign ( gtk:: Align :: Center )
@@ -1409,8 +1463,10 @@ impl SimpleComponent for BrowseTab {
14091463 grid_search_bar,
14101464 grid_search_handler : None ,
14111465 paginator_label : paginator. paginator_label ,
1466+ first_button : paginator. first_button ,
14121467 prev_button : paginator. prev_button ,
14131468 next_button : paginator. next_button ,
1469+ last_button : paginator. last_button ,
14141470 insert_button : mutations. insert_button ,
14151471 delete_button : mutations. delete_button ,
14161472 save_button : mutations. save_button ,
@@ -1495,8 +1551,10 @@ impl SimpleComponent for BrowseTab {
14951551 self . current_result = None ;
14961552 self . current_total_rows = None ;
14971553 self . paginator_label . set_label ( "" ) ;
1554+ self . first_button . set_sensitive ( false ) ;
14981555 self . prev_button . set_sensitive ( false ) ;
14991556 self . next_button . set_sensitive ( false ) ;
1557+ self . last_button . set_sensitive ( false ) ;
15001558 self . show_error_inner ( & message) ;
15011559 self . inner_stack . set_visible_child_name ( "error" ) ;
15021560 }
@@ -1515,6 +1573,13 @@ impl SimpleComponent for BrowseTab {
15151573 }
15161574 self . grid_search . grab_focus ( ) ;
15171575 }
1576+ BrowseTabInput :: FirstPage => {
1577+ if self . current_offset > 0 {
1578+ self . current_offset = 0 ;
1579+ let _ = sender. output ( BrowseTabOutput :: FetchPage ) ;
1580+ let _ = sender. output ( BrowseTabOutput :: StateChanged ) ;
1581+ }
1582+ }
15181583 BrowseTabInput :: PrevPage => {
15191584 if self . current_offset >= self . page_size {
15201585 self . current_offset -= self . page_size ;
@@ -1527,6 +1592,23 @@ impl SimpleComponent for BrowseTab {
15271592 let _ = sender. output ( BrowseTabOutput :: FetchPage ) ;
15281593 let _ = sender. output ( BrowseTabOutput :: StateChanged ) ;
15291594 }
1595+ BrowseTabInput :: LastPage => {
1596+ let Some ( total) = self . current_total_rows else {
1597+ // Total unknown — Last has no target. UI keeps the
1598+ // button disabled until RowCountLoaded fires, so
1599+ // this branch is a defensive guard.
1600+ return ;
1601+ } ;
1602+ if total == 0 {
1603+ return ;
1604+ }
1605+ let last_page_offset = ( total - 1 ) / self . page_size * self . page_size ;
1606+ if self . current_offset != last_page_offset {
1607+ self . current_offset = last_page_offset;
1608+ let _ = sender. output ( BrowseTabOutput :: FetchPage ) ;
1609+ let _ = sender. output ( BrowseTabOutput :: StateChanged ) ;
1610+ }
1611+ }
15301612 BrowseTabInput :: SortChanged { col_idx, ascending } => {
15311613 // Idempotent: GtkColumnViewSorter fires both
15321614 // `primary-sort-column` and `primary-sort-order`
@@ -2357,8 +2439,10 @@ fn format_thousands(n: u64) -> String {
23572439/// signature stays narrow.
23582440struct Paginator {
23592441 bar : gtk:: ActionBar ,
2442+ first_button : gtk:: Button ,
23602443 prev_button : gtk:: Button ,
23612444 next_button : gtk:: Button ,
2445+ last_button : gtk:: Button ,
23622446 paginator_label : gtk:: Label ,
23632447}
23642448
0 commit comments