Skip to content

Commit d43baeb

Browse files
committed
refactor(linux): UX polish — Structure tab, Browse paginator, status pages
Structure tab (mod.rs): - Loading state now uses adw::Spinner (animated) inside a centred Box with title + dim subtitle. The previous static emblem-synchronizing-symbolic icon read as "this could be idle". - Error status page gets a "Try Again" button (suggested-action pill) that fires another FetchStructure round-trip. Without it, a transient network blip on a remote DB forced the user to close and reopen the tab. - SQL preview's Copy-button toolbar uses gtk::CenterBox::set_end_widget (native idiom) instead of a hexpand label spacer. Browse paginator (browse_tab.rs): - First / Last page buttons added — major gap for tables of millions of rows. Last enables only when row count is known. Pattern matches TablePlus / DataGrip / DBeaver. - Paginator label now uses thousands-separated numbers ("Rows 10,001 – 10,100 of 5,000,000"), consistent with the page-size dropdown. - Empty-page wording: "No rows at offset 5000" → "No rows on this page" (the previous string read as a bug message). - gtk::Spinner (deprecated in 4.12) → adw::Spinner in two places. - Error title "Failed" → "Couldn't load rows" (matches Structure tab's "Couldn't load structure" idiom). Other status pages: - Workspace empty-state copy: Ctrl+T → Ctrl+E (Ctrl+T is scoped to the workspace inner box and doesn't fire from the empty page; Ctrl+E is window-scoped and always works). - Editor's NotRun outcome icon: emblem-synchronizing-symbolic (sync) → media-playback-stop-symbolic (skip semantics). clippy + tests pass.
1 parent f40f3d8 commit d43baeb

4 files changed

Lines changed: 156 additions & 31 deletions

File tree

linux/crates/app/src/ui/app/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1197,7 +1197,7 @@ impl SimpleComponent for App {
11971197
.icon_name(StatusKind::Info.icon())
11981198
.title(crate::tr!("Select a table"))
11991199
.description(crate::tr!(
1200-
"Pick a table from the sidebar, or press Ctrl+T to open a query editor."
1200+
"Pick a table from the sidebar, or press Ctrl+E to open a query editor."
12011201
))
12021202
.build();
12031203
workspace_outer_stack.add_named(&workspace_empty_page, Some("empty"));

linux/crates/app/src/ui/browse_tab.rs

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
23582440
struct 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

linux/crates/app/src/ui/editor.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ fn build_outcome_widget(o: &StatementOutcome, idx: usize) -> gtk::Widget {
710710
StatementOutcomeKind::NotRun => adw::StatusPage::builder()
711711
.title(crate::tr!("Statement {n} not run").replace("{n}", &(idx + 1).to_string()))
712712
.description(crate::tr!("Skipped because an earlier statement failed."))
713-
.icon_name("emblem-synchronizing-symbolic")
713+
.icon_name("media-playback-stop-symbolic")
714714
.vexpand(true)
715715
.build()
716716
.upcast(),

linux/crates/app/src/ui/structure_tab/mod.rs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -560,25 +560,24 @@ impl SimpleComponent for StructureTab {
560560
let copy_sql_btn = gtk::Button::builder()
561561
.icon_name("edit-copy-symbolic")
562562
.tooltip_text(crate::tr!("Copy SQL to clipboard"))
563-
.halign(gtk::Align::End)
563+
.valign(gtk::Align::Center)
564564
.build();
565565
copy_sql_btn.add_css_class("flat");
566566
let buffer_for_copy = sql_buffer.clone();
567567
copy_sql_btn.connect_clicked(move |btn| {
568568
let text = buffer_for_copy.text(&buffer_for_copy.start_iter(), &buffer_for_copy.end_iter(), false);
569569
btn.clipboard().set_text(text.as_str());
570570
});
571-
let sql_toolbar = gtk::Box::builder()
572-
.orientation(gtk::Orientation::Horizontal)
571+
// CenterBox is the native idiom for "leading / centred /
572+
// trailing" toolbar layouts. The earlier hexpand label-spacer
573+
// worked but was a CSS-flexbox-era pattern that fights GTK's
574+
// layout system.
575+
let sql_toolbar = gtk::CenterBox::builder()
573576
.margin_top(6)
574-
.margin_bottom(0)
575577
.margin_start(6)
576578
.margin_end(6)
577-
.hexpand(true)
578579
.build();
579-
let toolbar_spacer = gtk::Label::builder().hexpand(true).build();
580-
sql_toolbar.append(&toolbar_spacer);
581-
sql_toolbar.append(&copy_sql_btn);
580+
sql_toolbar.set_end_widget(Some(&copy_sql_btn));
582581
let sql_page_box = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build();
583582
sql_page_box.append(&sql_toolbar);
584583
sql_page_box.append(&sql_scroll);
@@ -609,11 +608,31 @@ impl SimpleComponent for StructureTab {
609608
let inner_stack = gtk::Stack::new();
610609
inner_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
611610

612-
let loading_status = adw::StatusPage::builder()
613-
.icon_name("emblem-synchronizing-symbolic")
614-
.title(crate::tr!("Loading structure…"))
611+
// AdwSpinner (libadwaita 1.6+) is the native animated spinner
612+
// — pulses while the introspection round-trip is in flight.
613+
// The earlier `emblem-synchronizing-symbolic` rendered as a
614+
// static sync icon that read as "this could be idle". A
615+
// centred vertical box with spinner + title + dim subtitle
616+
// is the same pattern GNOME Software / Console use for
617+
// in-flight load states.
618+
let loading_spinner = adw::Spinner::builder().width_request(48).height_request(48).build();
619+
let loading_title = gtk::Label::builder().label(crate::tr!("Loading structure…")).build();
620+
loading_title.add_css_class("title-2");
621+
let loading_subtitle = gtk::Label::builder()
622+
.label(crate::tr!("Reading columns, indexes, and foreign keys…"))
615623
.build();
616-
inner_stack.add_named(&loading_status, Some("loading"));
624+
loading_subtitle.add_css_class("dim-label");
625+
let loading_box = gtk::Box::builder()
626+
.orientation(gtk::Orientation::Vertical)
627+
.spacing(12)
628+
.halign(gtk::Align::Center)
629+
.valign(gtk::Align::Center)
630+
.vexpand(true)
631+
.build();
632+
loading_box.append(&loading_spinner);
633+
loading_box.append(&loading_title);
634+
loading_box.append(&loading_subtitle);
635+
inner_stack.add_named(&loading_box, Some("loading"));
617636

618637
let editor_box = gtk::Box::builder()
619638
.orientation(gtk::Orientation::Vertical)
@@ -666,6 +685,28 @@ impl SimpleComponent for StructureTab {
666685
.icon_name("dialog-error-symbolic")
667686
.title(crate::tr!("Couldn't load structure"))
668687
.build();
688+
// "Try again" — fires another FetchStructure round-trip via
689+
// the existing output channel. Without this, a transient
690+
// network blip on a remote DB forces the user to close and
691+
// reopen the tab. Suggested-action + pill styling matches
692+
// GNOME Software's "Try Again" affordance on its own
693+
// load-failure page.
694+
let retry_button = gtk::Button::builder()
695+
.label(crate::tr!("Try Again"))
696+
.halign(gtk::Align::Center)
697+
.build();
698+
retry_button.add_css_class("suggested-action");
699+
retry_button.add_css_class("pill");
700+
let sender_for_retry = sender.clone();
701+
let inner_stack_for_retry = inner_stack.clone();
702+
retry_button.connect_clicked(move |_| {
703+
// Flip back to the loading page so the user sees we're
704+
// trying — otherwise the click looks like a no-op until
705+
// StructureLoaded arrives.
706+
inner_stack_for_retry.set_visible_child_name("loading");
707+
let _ = sender_for_retry.output(StructureTabOutput::FetchStructure);
708+
});
709+
error_status.set_child(Some(&retry_button));
669710
inner_stack.add_named(&error_status, Some("error"));
670711

671712
inner_stack.set_visible_child_name(match init.mode {

0 commit comments

Comments
 (0)