diff --git a/linux/changelog.d/3927.fixed.1.md b/linux/changelog.d/3927.fixed.1.md new file mode 100644 index 0000000000..2c49b076f6 --- /dev/null +++ b/linux/changelog.d/3927.fixed.1.md @@ -0,0 +1 @@ +Changing the font or text size while a data grid was open left its columns at their old widths. diff --git a/linux/changelog.d/3927.fixed.md b/linux/changelog.d/3927.fixed.md new file mode 100644 index 0000000000..6650bd703d --- /dev/null +++ b/linux/changelog.d/3927.fixed.md @@ -0,0 +1 @@ +A data grid column sized by its header could cut the header's sort arrow. diff --git a/linux/crates/app/src/ui/data_grid/columns.rs b/linux/crates/app/src/ui/data_grid/columns.rs index 65d7fd4200..a634886d12 100644 --- a/linux/crates/app/src/ui/data_grid/columns.rs +++ b/linux/crates/app/src/ui/data_grid/columns.rs @@ -16,6 +16,7 @@ use crate::services::grid::{ CellDisplay, ColumnLayout, GridColumn, LayoutSlot, Placement, default_width_chars, header_text, merge_order, number_width_chars, sampled_texts, typical_texts, typical_width_chars, }; +use crate::ui::handler_guard::HandlerGuard; /// Rows sampled for a column's opening width. const WIDTH_SAMPLE: usize = 200; @@ -25,6 +26,21 @@ const CELL_CHROME_PX: i32 = 13; /// A resize step, in digits of the font the cells draw in, so a step is /// the same number of characters at any text size. const STEP_DIGITS: i32 = 2; +/// Adwaita draws a header's title in `.caption-heading`: bold, at 82 % of +/// the grid's font. +const HEADER_SCALE: f64 = 0.82; +/// GTK keeps a sort indicator beside the title of every sortable column, +/// sorted or not: 16 px wide in Adwaita, 6 px from the title. +const SORT_INDICATOR_PX: i32 = 16 + 6; + +/// Whether measuring may narrow a column the grid sized itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Measure { + /// More rows only widen it, so it holds still as pages arrive. + Widen, + /// A font changed, so the width it had means nothing now. + Again, +} /// What a column's cells read to draw a row. The column makes one and /// every cell it builds shares it. @@ -66,6 +82,8 @@ pub struct ColumnSet { /// keep: a better guess from other rows should still reach them. auto_widths: std::collections::HashMap, pages: Pages, + /// The fonts the grid last sized its columns in. + measured_in: Option, handlers: Vec<(glib::WeakRef, glib::SignalHandlerId)>, } @@ -234,20 +252,55 @@ impl DataGrid { /// the first rows, and keeps it while rows scroll by, since an /// automatic width measures every row GTK builds and shifts. fn size_unsized_columns(&self) { - let widths = self.opening_widths(); + self.size_columns(Measure::Widen); + } + + /// The monospace font, the interface font or the text size changed: + /// the columns the grid sized itself take the new measure, and a width + /// the user gave stays. + pub(super) fn fonts_changed(&self) { + self.size_columns(Measure::Again); + row_number_column::fit(self); + } + + /// A grid out of any window hears of no font change, and one sized + /// before it had a window measured in no theme's font. + pub(super) fn rooted(&self) { + let measured_in = self.imp().columns.borrow().measured_in.clone(); + if measured_in.is_some_and(|fonts| fonts != fonts_now(&self.column_view())) { + self.fonts_changed(); + } + } + + /// The interface font and the text size reach the grid as GTK system + /// settings; the monospace font is libadwaita's. + pub(super) fn watch_fonts(&self) { + let style = adw::StyleManager::default(); + let handler = style.connect_monospace_font_name_notify(glib::clone!( + #[weak(rename_to = grid)] + self, + move |_| grid.fonts_changed() + )); + self.imp().font_watch.replace(Some(HandlerGuard::new(&style, handler))); + } + + fn size_columns(&self, measure: Measure) { + let widths = self.opening_widths(measure); + self.imp().columns.borrow_mut().measured_in = Some(fonts_now(&self.column_view())); for (index, view_column, width) in widths { self.imp().columns.borrow_mut().auto_widths.insert(index, width); view_column.set_fixed_width(width); } } - fn opening_widths(&self) -> Vec<(usize, gtk::ColumnViewColumn, i32)> { + fn opening_widths(&self, measure: Measure) -> Vec<(usize, gtk::ColumnViewColumn, i32)> { let set = self.imp().columns.borrow(); let sample: Vec>> = self.store().rows().take(WIDTH_SAMPLE).map(|row| row.cells()).collect(); let by_type = set.pages == Pages::Empty; let view = self.column_view(); - let font = value_font(&view); - let metrics = view.pango_context().metrics(Some(&font), None); + let context = view.create_pango_context(); + let font = value_font(&context); + let metrics = context.metrics(Some(&font), None); let char_px = pango::units_to_double(metrics.approximate_char_width()); let null = editable_null_sentinel(); let mut widths = Vec::new(); @@ -272,31 +325,37 @@ impl DataGrid { // no rows a value its type spells out, as a UUID or an enum's // label, so those are measured as the cell draws them. let sampled = match (by_type, paint.numbers.get()) { - (_, true) => tabular_width(&view, &font, &"0".repeat(usize::try_from(chars).unwrap_or_default())), + (_, true) => tabular_width(&context, &font, &"0".repeat(usize::try_from(chars).unwrap_or_default())), (true, false) => 0, // One line each, so one layout measures them all. - (false, false) => drawn_width(&view, &font, &sampled_texts(column, values.iter().copied()).join("\n")), + (false, false) => drawn_width( + &context, + &font, + &sampled_texts(column, values.iter().copied()).join("\n"), + ), }; let spelled = match by_type || column.display() == CellDisplay::Temporal { true => typical_texts(column) .iter() - .map(|typical| tabular_width(&view, &font, typical)) + .map(|typical| tabular_width(&context, &font, typical)) .max() .unwrap_or(0), false => 0, }; - // The header draws its title smaller than the body font, so the - // body font's width holds it. - let header = view.create_pango_layout(Some(header_text(column))).pixel_size().0; + let indicator = match view_column.sorter() { + Some(_) => SORT_INDICATOR_PX, + None => 0, + }; + let header = header_width(&context, header_text(column)) + indicator; // A NULL the user can replace draws as ``, wider than the // fewest cells a column takes. let placeholder = match paint.tracked.tracks_changes() && values.iter().any(|value| value.is_null()) { - true => drawn_width(&view, &font, &null), + true => drawn_width(&context, &font, &null), false => 0, }; let text = estimate.max(sampled).max(spelled).max(header).max(placeholder); let width = text + CELL_CHROME_PX; - if auto.is_none_or(|auto| width > auto) { + if measure == Measure::Again || auto.is_none_or(|auto| width > auto) { widths.push((index, view_column.clone(), width)); } } @@ -493,7 +552,8 @@ impl DataGrid { return; }; let view = self.column_view(); - let metrics = view.pango_context().metrics(Some(&value_font(&view)), None); + let context = view.create_pango_context(); + let metrics = context.metrics(Some(&value_font(&context)), None); let step = (pango::units_to_double(metrics.approximate_digit_width()) * f64::from(STEP_DIGITS)).ceil() as i32; let width = column.fixed_width(); let width = match wider { @@ -721,18 +781,44 @@ fn same_names(layout: &ColumnLayout, columns: &[GridColumn]) -> bool { /// The font a cell draws its value in: libadwaita's `monospace` class /// sets only the family and size of the desktop's monospace font. -fn value_font(view: >k::ColumnView) -> pango::FontDescription { +fn value_font(context: &pango::Context) -> pango::FontDescription { let mut monospace = pango::FontDescription::from_string(&adw::StyleManager::default().monospace_font_name()); monospace.unset_fields(!(pango::FontMask::FAMILY | pango::FontMask::SIZE)); - let mut font = view.pango_context().font_description().unwrap_or_default(); + let mut font = context.font_description().unwrap_or_default(); font.merge(Some(&monospace), true); font } +/// How wide a header draws `title`. The scaled size rounds up, since GTK +/// scales the size before it rounds and the view's font is rounded. +fn header_width(context: &pango::Context, title: &str) -> i32 { + let mut font = context.font_description().unwrap_or_default(); + font.set_weight(pango::Weight::Bold); + let size = (f64::from(font.size()) * HEADER_SCALE).ceil(); + match font.is_size_absolute() { + true => font.set_absolute_size(size), + false => font.set_size(size as i32), + } + drawn_width(context, &font, title) +} + +/// The grid's font, the text size in it, and the monospace font. A +/// widget's own context follows a change only at the next frame, so a +/// fresh one is read. +fn fonts_now(view: >k::ColumnView) -> String { + let grid = view + .create_pango_context() + .font_description() + .map(|font| font.to_string()) + .unwrap_or_default(); + format!("{grid}; {}", adw::StyleManager::default().monospace_font_name()) +} + /// How wide `text` is in the tabular figures libadwaita's `numeric` /// class draws. -fn tabular_width(widget: &impl IsA, font: &pango::FontDescription, text: &str) -> i32 { - let layout = widget.create_pango_layout(Some(text)); +fn tabular_width(context: &pango::Context, font: &pango::FontDescription, text: &str) -> i32 { + let layout = pango::Layout::new(context); + layout.set_text(text); layout.set_font_description(Some(font)); let tabular = pango::AttrList::new(); tabular.insert(pango::AttrFontFeatures::new("tnum")); @@ -740,8 +826,9 @@ fn tabular_width(widget: &impl IsA, font: &pango::FontDescription, layout.pixel_size().0 } -fn drawn_width(widget: &impl IsA, font: &pango::FontDescription, text: &str) -> i32 { - let layout = widget.create_pango_layout(Some(text)); +fn drawn_width(context: &pango::Context, font: &pango::FontDescription, text: &str) -> i32 { + let layout = pango::Layout::new(context); + layout.set_text(text); layout.set_font_description(Some(font)); layout.pixel_size().0 } diff --git a/linux/crates/app/src/ui/data_grid/imp.rs b/linux/crates/app/src/ui/data_grid/imp.rs index 9720e464cd..d3c16c7de9 100644 --- a/linux/crates/app/src/ui/data_grid/imp.rs +++ b/linux/crates/app/src/ui/data_grid/imp.rs @@ -18,6 +18,7 @@ use super::row_detail::RowDetailPane; use super::selection::Target; use super::selection_summary::SummaryState; use super::{GridConfig, GridStore}; +use crate::ui::handler_guard::HandlerGuard; /// The grid's selection model, made with the grid and given its rows once /// the grid knows how it sorts. @@ -85,6 +86,7 @@ pub struct DataGrid { pub editing: Cell, /// The window's focus handler, while the grid is in a window. pub focus_watch: RefCell, glib::SignalHandlerId)>>, + pub(super) font_watch: RefCell>, } #[glib::object_subclass] @@ -122,6 +124,7 @@ impl ObjectImpl for DataGrid { self.find.stop(); self.summary.stop(); self.obj().stop_watching_focus(); + drop(self.font_watch.take()); if let Some(menus) = self.menus.get() { menus.unparent(); } @@ -134,12 +137,23 @@ impl WidgetImpl for DataGrid { fn root(&self) { self.parent_root(); self.obj().watch_focus(); + self.obj().rooted(); } fn unroot(&self) { self.obj().stop_watching_focus(); self.parent_unroot(); } + + fn system_setting_changed(&self, setting: >k4::SystemSetting) { + self.parent_system_setting_changed(setting); + if matches!( + setting, + gtk4::SystemSetting::Dpi | gtk4::SystemSetting::FontName | gtk4::SystemSetting::FontConfig + ) { + self.obj().fonts_changed(); + } + } } impl BinImpl for DataGrid {} diff --git a/linux/crates/app/src/ui/data_grid/mod.rs b/linux/crates/app/src/ui/data_grid/mod.rs index 47e2ca9bde..4f445f4c57 100644 --- a/linux/crates/app/src/ui/data_grid/mod.rs +++ b/linux/crates/app/src/ui/data_grid/mod.rs @@ -87,6 +87,7 @@ impl DataGrid { find_bar::install(&grid); imp.detail_pane.attach(&grid); grid.set_columns(columns, sorting, rules); + grid.watch_fonts(); grid } diff --git a/linux/crates/app/src/ui/data_grid/row_number_column.rs b/linux/crates/app/src/ui/data_grid/row_number_column.rs index 6edba56555..3e31735c1d 100644 --- a/linux/crates/app/src/ui/data_grid/row_number_column.rs +++ b/linux/crates/app/src/ui/data_grid/row_number_column.rs @@ -139,7 +139,9 @@ pub fn fit(grid: &DataGrid) { }; let largest = grid.row_base() + u64::from(grid.store().len()); let digits = largest.max(1).ilog10() as i32 + 1; - let metrics = grid.column_view().pango_context().metrics(None, None); + // A fresh context, since the view's own follows a font change only at + // the next frame. + let metrics = grid.column_view().create_pango_context().metrics(None, None); let digit_px = pango::units_to_double(metrics.approximate_digit_width()); column.set_fixed_width((f64::from(digits.max(2)) * digit_px).ceil() as i32 + CHROME_PX); } diff --git a/linux/crates/app/src/ui/data_grid/tests/columns.rs b/linux/crates/app/src/ui/data_grid/tests/columns.rs index 38c5338391..ec11131ac6 100644 --- a/linux/crates/app/src/ui/data_grid/tests/columns.rs +++ b/linux/crates/app/src/ui/data_grid/tests/columns.rs @@ -438,18 +438,146 @@ fn a_header_of_wide_letters_shows_whole() { vec![named("id", "integer", true), named("WWWWWWWW", "text", false)], vec![vec![Value::Int(1), text("a")]], )); + let (header, title) = header_title(&grid, "WWWWWWWW"); + let (_, needs, _, _) = title.measure(gtk::Orientation::Horizontal, -1); + + wait_until(WAIT, || header.width() >= needs).expect("the header's title shows whole"); +} + +/// GTK keeps a sort indicator beside the title of every sortable column, +/// so a column as wide as its title alone cuts the arrow once it sorts. +#[gtk4::test] +fn a_sorted_columns_header_shows_its_title_and_arrow_whole() { + let grid = laid_out(Grid::open( + vec![named("id", "integer", true), named("level", "text", false)], + vec![vec![Value::Int(1), text("a")]], + )); + grid.grid.sort_by(Some((1, true))); + let (header, title) = header_title(&grid, "level"); + let title_and_arrow = title.parent().expect("the title's box"); + let (_, needs, _, _) = title_and_arrow.measure(gtk::Orientation::Horizontal, -1); + + wait_until(WAIT, || header.width() >= needs).expect("the title and its sort arrow show whole"); +} + +/// The text size reaches the grid as a GTK setting: a column it sized +/// takes the new measure both ways, and a width the user gave stays. +#[gtk4::test] +fn a_text_size_change_measures_the_grids_columns_again() { + let grid = laid_out(Grid::open( + vec![ + named("id", "integer", true), + named("code", "text", false), + named("note", "text", false), + ], + vec![vec![Value::Int(1), text("CF-1001"), text("Ships in two days")]], + )); + let code = data_column(&grid, 1); + let opened = code.fixed_width(); + data_column(&grid, 2).set_fixed_width(300); + let scaled = TextScale::times(1.5); + + wait_until(WAIT, || code.fixed_width() > opened).expect("the column widened with the text"); + let display = grid.cell_of(1, 1).display().cloned().expect("the code's text"); + wait_until(WAIT, || shows_whole(&display)).expect("the code shows whole at the larger text"); + assert_eq!( + data_column(&grid, 2).fixed_width(), + 300, + "the width the user gave changed" + ); + let widened = code.fixed_width(); + + drop(scaled); + wait_until(WAIT, || code.fixed_width() < widened).expect("the column narrowed with the text"); + assert_eq!( + data_column(&grid, 2).fixed_width(), + 300, + "the width the user gave changed" + ); +} + +/// A grid out of its window hears of no text size change, so it measures +/// again when it is back if its fonts are not the ones it measured in. +#[gtk4::test] +fn a_grid_back_in_its_window_takes_a_text_size_it_missed() { + let grid = laid_out(Grid::open( + vec![ + named("id", "integer", true), + named("code", "text", false), + named("note", "text", false), + ], + vec![vec![Value::Int(1), text("CF-1001"), text("Ships in two days")]], + )); + let code = data_column(&grid, 1); + let opened = code.fixed_width(); + data_column(&grid, 2).set_fixed_width(300); + + grid.window.set_child(None::<>k::Widget>); + let _scaled = TextScale::times(1.5); + grid.window.set_child(Some(&grid.grid)); + + wait_until(WAIT, || code.fixed_width() > opened).expect("the column widened with the text"); + assert_eq!( + data_column(&grid, 2).fixed_width(), + 300, + "the width the user gave changed" + ); +} + +/// libadwaita announces a new monospace font; the grid measures the +/// columns it sized again, from the rows it holds now. +#[gtk4::test] +fn a_monospace_font_change_measures_the_grids_columns_again() { + let grid = laid_out(Grid::open( + vec![named("id", "integer", true), named("note", "text", false)], + vec![vec![Value::Int(1), text(&"x".repeat(30))]], + )); + let column = data_column(&grid, 1); + let wide = column.fixed_width(); + grid.grid.replace_rows(persisted(&[vec![Value::Int(1), text("a")]]), 0); + assert_eq!(column.fixed_width(), wide, "a page of shorter rows narrowed the column"); + + libadwaita::StyleManager::default().notify("monospace-font-name"); + + wait_until(WAIT, || column.fixed_width() < wide).expect("the column measured again"); +} + +/// The header of the column named `name`, and its title. +fn header_title(grid: &Grid, name: &str) -> (gtk::Widget, gtk::Label) { let title = descendants(&grid.grid.column_view()) .into_iter() .filter_map(|widget| widget.downcast::().ok()) - .find(|label| label.label() == "WWWWWWWW") + .find(|label| label.label() == name) .expect("the header's title"); let header = title .parent() .and_then(|title_box| title_box.parent()) .expect("the header button"); - let (_, needs, _, _) = title.measure(gtk::Orientation::Horizontal, -1); + (header, title) +} - wait_until(WAIT, || header.width() >= needs).expect("the header's title shows whole"); +/// The display's text scaled for one test, put back after even when the +/// test fails. +struct TextScale { + settings: gtk::Settings, + dpi: i32, +} + +impl TextScale { + fn times(factor: f64) -> Self { + let settings = gtk::Settings::default().expect("the display's settings"); + let dpi = settings.gtk_xft_dpi(); + // GTK reads -1 as 96 dots per inch, which it keeps in 1024ths. + let base = if dpi > 0 { dpi } else { 96 * 1024 }; + settings.set_gtk_xft_dpi((f64::from(base) * factor).round() as i32); + Self { settings, dpi } + } +} + +impl Drop for TextScale { + fn drop(&mut self) { + self.settings.set_gtk_xft_dpi(self.dpi); + } } /// A NULL the user can replace draws as ``, which a column of short