diff --git a/linux/.config/nextest.toml b/linux/.config/nextest.toml index ac45f8b46e..5200cae186 100644 --- a/linux/.config/nextest.toml +++ b/linux/.config/nextest.toml @@ -55,10 +55,10 @@ filter = 'package(tablepro-session)' test-group = 'session' # The app's docker tests are integration targets, and the large-catalog -# case in its library. Its other unit tests start no container, so they -# keep running in parallel. +# and diagram engine cases in its library. Its other unit tests start no +# container, so they keep running in parallel. [[profile.default.overrides]] -filter = 'package(tablepro) & (kind(test) | test(/::large_catalog::/))' +filter = 'package(tablepro) & (kind(test) | test(/::large_catalog::/) | test(/::diagram::engines::/))' test-group = 'app' # These press real keys on the X display every test shares. A window diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index e4deacb623..6dc33e7455 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -278,10 +278,12 @@ pub enum WorkspaceTab { Editor(EditorTabSlot), // SQL editor, free-form query Structure(StructureTabSlot), // a new table's draft, or an existing table's definition Table(TableTabSlot), // one (schema, table) in the browse grid + Diagram(DiagramTabSlot), // one schema's ER diagram + Key(KeyTabSlot), // one key of a key-value engine } ``` -A table's structure opens in a Structure tab of its own. Saving a new table closes its Structure tab and opens a Table tab on it. +A table's structure opens in a Structure tab of its own. Saving a new table closes its Structure tab and opens a Table tab on it. A schema's ER diagram opens in a Diagram tab, one per schema: it reads each table's columns, foreign keys and indexes on one connection lease, two tables at a time, and draws the cards and lines on a canvas of its own (`crates/app/src/ui/diagram/`), with a focusable `DiagramNode` where each card is so the keyboard and a screen reader reach the tables. Its record keeps the schema and where the user moved tables, so a restore reads the tables again, lays them out and puts the moved ones back. Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. The workspace keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). A `TabIndex` maps each `AdwTabPage` to its tab's UUID, so close and right-click actions can recover it. `TabFactory` builds every tab from a `TabSpec`, whether the sidebar, the overview's new-tab button, a restore or a reopen asks for it. Every tab's title comes from its `TabIdentity` through `resolve_tab_title`, and the window's from the tab in front through `resolve_window_title`. A tab that closing would lose something in shows `document-modified-symbolic` as its page icon, as GNOME Text Editor does; its title never changes for it. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index f6f154da41..482c11df75 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4498,6 +4498,7 @@ name = "tablepro" version = "0.0.0" dependencies = [ "async-trait", + "cairo-rs", "chrono", "futures", "gettext-rs", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 223ac36cb2..7532acbf85 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -95,6 +95,9 @@ http = "1" rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } rustls-pki-types = { version = "1", features = ["std"] } gtk4 = { version = "0.11", features = ["gnome_50"] } +# The PNG and SVG surfaces the ER diagram exports through; the crate is +# already in the tree through gtk4. +cairo-rs = { version = "0.22", features = ["png", "svg"] } libadwaita = { version = "0.9", features = ["v1_9"] } sourceview5 = { version = "0.11", features = ["v5_18"] } gio = { version = "0.22", features = ["v2_88"] } diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 8457d17c54..d39154162f 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -55,6 +55,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Column widths | Kept per table; Widen Column and Narrow Column in the cell menu (Shift+Alt+Right and Shift+Alt+Left) change them from the keyboard | | CSV import | Into an existing table, with a column mapping, in one write batch (one transaction where the engine has them), up to 64 MiB | | Sidebar | Tables (partitioned ones included), views, materialized views and foreign tables by schema, with search and Refresh, read again after the editor runs DDL, Open in New Tab and Show CREATE TABLE; Edit Structure, Import CSV and Drop Table for tables | +| ER diagram | Show ER Diagram in a table's menu or on a schema's heading opens the schema's tables as cards and their foreign keys as lines in crow's foot notation, read two tables at a time with Stop. Pan, zoom, Best Fit, drag tables, Reset Layout, and PNG and SVG export. Each table is a list item the keyboard and a screen reader reach: Tab and the arrow keys move between tables, Enter opens one, Alt+arrows move it, and its description names its columns and the tables it joins | ### Structure @@ -155,7 +156,6 @@ Not built yet, in no order. - Functions, procedures, sequences and triggers in the sidebar. The drivers already list the first three. - The CREATE INDEX statement for one index. Show CREATE TABLE lists them after the table on PostgreSQL, SQL Server and SQLite (with the triggers on SQLite). - Reorder columns in the structure tab by dragging, with the ALTER previewed. -- An ER diagram. ### SQL editor @@ -206,7 +206,7 @@ What has to hold before the first release, for a user on GNOME 50 with Ubuntu 26 | 5 | Edits data in place, and the changes commit correctly even when the connection or the app misbehaves | Write, row guard and commit resolution conformance on every engine, with the fault fakes and the grid save Docker test. `kill_during_save` kills TablePro halfway through a SQLite save: the file keeps none of it, and the next launch restores the session with its history, settings and saved tabs. A save into a SQLite file another program holds locked fails as `Busy(FileLocked)`, which says another program or an editor tab holds the file's lock, and leaves the file as it was whether the lock shows at `BEGIN IMMEDIATE` or at the `COMMIT` | Nothing | | 6 | Trusts the app with credentials: TLS, SSH tunnels, no password in plain text | Verify full by default, the TLS conformance suite, the Secret Service, and statement logging silenced | An outside security review | | 7 | Recovers when the network drops or the database restarts | Health monitoring, backoff and replay rules with their tests, a stopped container in the liveness tests, and a session test that restarts PostgreSQL: the read in flight runs again, the save in flight does not and leaves no row | Nothing | -| 8 | Works with a screen reader, the keyboard alone and larger text | Label audits of the picker, the connect pages, the workspace, the connect dialog and preferences, labelled cells and real-key tests. Sorting from the cell menu, named filter fields and cell editors, focus handed on when a control hides, and announced query results and errors. Columns resize from the keyboard, and a column sized by a key or a header drag draws at that width, also when it comes back | The manual pass in the backlog | +| 8 | Works with a screen reader, the keyboard alone and larger text | Label audits of the picker, the connect pages, the workspace, the connect dialog and preferences, labelled cells and real-key tests. Sorting from the cell menu, named filter fields and cell editors, focus handed on when a control hides, and announced query results and errors. Columns resize from the keyboard, and a column sized by a key or a header drag draws at that width, also when it comes back. The ER diagram's tables are focusable list items that name their columns and joins | The manual pass in the backlog | | 9 | Sees no message that cannot be translated, even while only English ships | Every string the app writes is in the template. Core errors carry values the app words itself: CSV import, cell and JSON parsing, type and default syntax, the keyring, certificate files, unsupported features, the SSH host, username and jump host checks, a failed write of the saved connections, the SSH runtime directory checks, ssh's exit status, and an ssh that would not start. A panic, an export encoder failure, an SSH failure with no sentence of its own and a TLS failure the app does not classify show the app's words and log the detail | Nothing | | 10 | Uses the app daily for a week without an unrecoverable failure | Nothing yet | A dated week of daily use | | 11 | Works with a database of many schemas or tables, such as one schema per tenant, without the sidebar or completion freezing | The large-catalog case on PostgreSQL with 100 schemas of 500 tables and MySQL with one database of 50,000: the sidebar's first listing, its search a key at a time, and completion after `FROM `, a key typed into it and after a schema's dot each have a time bound, none may stall the main loop past 100 ms, and the catalog has a bound on resident peak growth ([testing.md](docs/testing.md#large-catalog-case)) | Nothing | diff --git a/linux/changelog.d/3817.added.md b/linux/changelog.d/3817.added.md new file mode 100644 index 0000000000..2143c97f5a --- /dev/null +++ b/linux/changelog.d/3817.added.md @@ -0,0 +1 @@ +ER diagram of a schema's tables and foreign keys, with pan, zoom, movable tables and PNG and SVG export. diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 3e38616cf3..85a53ba526 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -79,6 +79,7 @@ thiserror.workspace = true gio.workspace = true gio-unix.workspace = true gtk4.workspace = true +cairo-rs.workspace = true libadwaita.workspace = true sourceview5.workspace = true glib.workspace = true diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index d0f6d5d24b..14884adc0f 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -7,10 +7,10 @@ use super::{Accelerator, AcceleratorScope, ShortcutListing, ShortcutOwner, Short use AcceleratorScope::{Application, Component, Local, Stock}; use ShortcutListing::{Listed, Unlisted}; use ShortcutOwner::{ - BrowseGrid, BrowseTab, CellEditor, FilterStrip, GridCell, HistoryRow, PickerPage, PickerRow, SidebarList, - SqlEditor, SqlEditorTab, StructureTab, + BrowseGrid, BrowseTab, CellEditor, DiagramCanvas, DiagramTab, FilterStrip, GridCell, HistoryRow, PickerPage, + PickerRow, SidebarList, SqlEditor, SqlEditorTab, StructureTab, }; -use ShortcutSection::{BrowseTable, DataGrid, General, TableStructure, Tabs}; +use ShortcutSection::{BrowseTable, DataGrid, ErDiagram, General, TableStructure, Tabs}; const BUBBLE: gtk::PropagationPhase = gtk::PropagationPhase::Bubble; /// GtkTextView binds Ctrl+/ to select-all, and a bubble controller on @@ -312,6 +312,82 @@ pub const ACCELERATORS: &[Accelerator] = &[ Unlisted, ), local(PickerRow, BUBBLE, "picker-row.menu", &["Menu", "F10"], Unlisted), + // The GNOME HIG's zoom keys, with Ctrl+= for a keyboard where + needs + // Shift. + local( + DiagramTab, + BUBBLE, + "diagram.zoom-in", + &["plus", "equal"], + Listed(ErDiagram), + ), + local( + DiagramTab, + BUBBLE, + "diagram.zoom-out", + &["minus"], + Listed(ErDiagram), + ), + local( + DiagramTab, + BUBBLE, + "diagram.zoom-reset", + &["0"], + Listed(ErDiagram), + ), + // As in Image Viewer. Nothing in the tab takes text. + local(DiagramTab, BUBBLE, "diagram.zoom-fit", &["f"], Listed(ErDiagram)), + local( + DiagramTab, + BUBBLE, + "diagram.refresh", + &["F5", "r"], + Listed(ErDiagram), + ), + local( + DiagramCanvas, + BUBBLE, + "diagram.open-table", + &["Return"], + Listed(ErDiagram), + ), + local( + DiagramCanvas, + BUBBLE, + "diagram.context-menu", + &["Menu", "F10"], + Unlisted, + ), + // The arrow keys alone move the focus from table to table, as they + // do between any widgets. + local( + DiagramCanvas, + BUBBLE, + "diagram.move-table-left", + &["Left"], + Listed(ErDiagram), + ), + local( + DiagramCanvas, + BUBBLE, + "diagram.move-table-right", + &["Right"], + Listed(ErDiagram), + ), + local( + DiagramCanvas, + BUBBLE, + "diagram.move-table-up", + &["Up"], + Listed(ErDiagram), + ), + local( + DiagramCanvas, + BUBBLE, + "diagram.move-table-down", + &["Down"], + Listed(ErDiagram), + ), local( PickerPage, BUBBLE, @@ -367,6 +443,8 @@ pub const fn controller_name(owner: ShortcutOwner) -> &'static str { SidebarList => "tablepro-sidebar-list-shortcuts", HistoryRow => "tablepro-history-row-shortcuts", PickerRow => "tablepro-picker-row-shortcuts", + DiagramTab => "tablepro-diagram-tab-shortcuts", + DiagramCanvas => "tablepro-diagram-canvas-shortcuts", PickerPage => "tablepro-picker-page-shortcuts", } } @@ -461,6 +539,11 @@ pub(crate) fn keypad_twins(key: gdk::Key) -> &'static [gdk::Key] { gdk::Key::Delete => &[gdk::Key::KP_Delete], gdk::Key::Left => &[gdk::Key::KP_Left], gdk::Key::Right => &[gdk::Key::KP_Right], + gdk::Key::Up => &[gdk::Key::KP_Up], + gdk::Key::Down => &[gdk::Key::KP_Down], + gdk::Key::plus => &[gdk::Key::KP_Add], + gdk::Key::minus => &[gdk::Key::KP_Subtract], + gdk::Key::_0 => &[gdk::Key::KP_0], _ => &[], } } @@ -751,6 +834,7 @@ mod tests { (ShortcutSection::DataGrid, "section-data-grid"), (ShortcutSection::SqlEditor, "section-sql-editor"), (ShortcutSection::TableStructure, "section-table-structure"), + (ShortcutSection::ErDiagram, "section-er-diagram"), ]; let mut placed: Vec<(adw::ShortcutsItem, ShortcutSection)> = Vec::new(); for (section, id) in sections { diff --git a/linux/crates/app/src/application/shortcut_owner.rs b/linux/crates/app/src/application/shortcut_owner.rs index 01216f68fb..98db78a906 100644 --- a/linux/crates/app/src/application/shortcut_owner.rs +++ b/linux/crates/app/src/application/shortcut_owner.rs @@ -19,6 +19,10 @@ pub enum ShortcutOwner { HistoryRow, /// A saved connection's row in the picker. PickerRow, + /// The whole ER diagram tab, its bottom bar included. + DiagramTab, + /// The diagram's canvas, where a table has the keyboard focus. + DiagramCanvas, /// The picker as a whole, its search included. PickerPage, } diff --git a/linux/crates/app/src/application/shortcut_section.rs b/linux/crates/app/src/application/shortcut_section.rs index 219b422a26..3145e40d82 100644 --- a/linux/crates/app/src/application/shortcut_section.rs +++ b/linux/crates/app/src/application/shortcut_section.rs @@ -7,4 +7,5 @@ pub enum ShortcutSection { DataGrid, SqlEditor, TableStructure, + ErDiagram, } diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs index 3860e1fb96..0f2ba859b2 100644 --- a/linux/crates/app/src/ui/a11y/tests.rs +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -7,8 +7,8 @@ use crate::test_support::{ ButtonFace, button_face, descendants, has_accessible_label, test_backend_records_properties, }; use crate::ui::{ - PreferencesDialog, SessionWindow, browse_tab, connect_dialog, editor, export, filter_strip, history_dialog, - preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, workspace, + PreferencesDialog, SessionWindow, browse_tab, connect_dialog, diagram, editor, export, filter_strip, + history_dialog, preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, workspace, }; /// Every class the app's templates build. A template whose class is @@ -20,6 +20,7 @@ fn template_classes() -> Vec { browse_tab::PagerBar::static_type(), browse_tab::PendingChangesBar::static_type(), connect_dialog::ConnectDialog::static_type(), + diagram::DiagramTab::static_type(), editor::SqlEditor::static_type(), export::ExportOperationsButton::static_type(), filter_strip::FilterRuleRow::static_type(), diff --git a/linux/crates/app/src/ui/diagram/canvas.rs b/linux/crates/app/src/ui/diagram/canvas.rs new file mode 100644 index 0000000000..9110b8b070 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/canvas.rs @@ -0,0 +1,853 @@ +use std::collections::HashMap; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, glib, graphene, gsk}; +use tablepro_core::meta::TableRef; + +use super::canvas_imp as imp; +use super::card_text::CardText; +use super::describe::table_description; +use super::draw::{self, Frame}; +use super::layout::{self, COLUMN_GAP, Point, ROW_GAP}; +use super::model::SchemaDiagram; +use super::node::DiagramNode; +use super::scene::{self, Card, EdgePath, Rect}; +use super::zoom; + +glib::wrapper! { + /// The diagram: cards and lines drawn in one widget that scrolls in + /// a scrolled window, zooms, and moves its cards. Each card also has + /// a `DiagramNode` where it is drawn, which the keyboard and a screen + /// reader reach. + pub struct DiagramCanvas(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Scrollable; +} + +pub(crate) struct Content { + pub diagram: SchemaDiagram, + pub texts: Vec, + pub cards: Vec, + /// One per relation, in the diagram's order. + pub edges: Vec, + /// The order cards are drawn in; a card moved last is on top. + pub order: Vec, + pub nodes: Vec, + pub extent: Rect, + /// The user moved a card since the automatic layout placed them. + pub moved: bool, +} + +impl Content { + fn route_all(&mut self) { + self.edges = self + .diagram + .relations + .iter() + .map(|relation| { + let (from, to) = (self.cards[relation.from], self.cards[relation.to]); + scene::route(&from, &relation.from_columns, &to, &relation.to_columns) + }) + .collect(); + } + + fn reroute(&mut self, table: usize) { + for (at, relation) in self.diagram.relations.iter().enumerate() { + if relation.from != table && relation.to != table { + continue; + } + let (Some(from), Some(to)) = (self.cards.get(relation.from), self.cards.get(relation.to)) else { + continue; + }; + if let Some(edge) = self.edges.get_mut(at) { + *edge = scene::route(from, &relation.from_columns, to, &relation.to_columns); + } + } + } + + fn measure_extent(&mut self) { + self.extent = self.bounds(); + } + + /// What the cards and lines cover now, which Best Fit and an export + /// show. The scrolled area can be larger: it keeps the room a moved + /// card left. + fn bounds(&self) -> Rect { + scene::extent(&self.cards, &self.edges).unwrap_or(Rect { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }) + } + + fn arrange(&mut self) { + let sizes: Vec = self.texts.iter().map(|text| text.shape.size()).collect(); + let pairs: Vec<(usize, usize)> = self + .diagram + .relations + .iter() + .map(|relation| (relation.from, relation.to)) + .collect(); + let positions = layout::arrange(&sizes, &pairs); + for (card, at) in self.cards.iter_mut().zip(positions) { + card.at = at; + } + self.moved = false; + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Drag { + Card { index: usize, from: Point, moved: bool }, + Pan { scroll: (f64, f64) }, +} + +/// A colour of the theme, read from a hidden widget whose `color` the +/// stylesheet sets to one of libadwaita's colour variables. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Probe { + WindowBackground, + CardBackground, + CardForeground, + Border, + Accent, +} + +impl Probe { + pub(crate) const ALL: [Self; 5] = [ + Self::WindowBackground, + Self::CardBackground, + Self::CardForeground, + Self::Border, + Self::Accent, + ]; + + pub(crate) fn css_class(self) -> &'static str { + match self { + Self::WindowBackground => "tp-diagram-window-bg", + Self::CardBackground => "tp-diagram-card-bg", + Self::CardForeground => "tp-diagram-card-fg", + Self::Border => "tp-diagram-border", + Self::Accent => "tp-diagram-accent", + } + } +} + +/// Diagram units to canvas pixels and back, for one moment's zoom and +/// scroll. The scrolled area runs half a canvas past the diagram on each +/// side, so any part of it, small diagram or large, can be scrolled to +/// the middle, and a card moved past the edge never shifts the others. +#[derive(Debug, Clone, Copy)] +pub(crate) struct View { + pub extent: Rect, + pub zoom: f64, + pub scroll_x: f64, + pub scroll_y: f64, + pub pad_x: f64, + pub pad_y: f64, +} + +impl View { + pub(crate) fn new(extent: Option, zoom: f64, size: (f64, f64), scroll: (f64, f64)) -> Self { + let extent = extent.unwrap_or(Rect { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + }); + Self { + extent, + zoom, + scroll_x: scroll.0, + scroll_y: scroll.1, + pad_x: (size.0 / 2.0).max(0.0), + pad_y: (size.1 / 2.0).max(0.0), + } + } + + pub(crate) fn content_width(self) -> f64 { + self.extent.width * self.zoom + 2.0 * self.pad_x + } + + pub(crate) fn content_height(self) -> f64 { + self.extent.height * self.zoom + 2.0 * self.pad_y + } + + pub(crate) fn to_widget(self, x: f64, y: f64) -> (f64, f64) { + ( + (x - self.extent.x) * self.zoom - self.scroll_x + self.pad_x, + (y - self.extent.y) * self.zoom - self.scroll_y + self.pad_y, + ) + } + + pub(crate) fn to_scene(self, x: f64, y: f64) -> Point { + Point { + x: (x + self.scroll_x - self.pad_x) / self.zoom + self.extent.x, + y: (y + self.scroll_y - self.pad_y) / self.zoom + self.extent.y, + } + } + + /// The scroll that puts the middle of the diagram in the middle of + /// the canvas. + pub(crate) fn centred(self) -> (f64, f64) { + ( + self.extent.width * self.zoom / 2.0, + self.extent.height * self.zoom / 2.0, + ) + } + + /// The scroll that puts the diagram's top left corner at the canvas's. + pub(crate) fn top_left(self) -> (f64, f64) { + (self.pad_x, self.pad_y) + } + + /// The part of the diagram a canvas of this size shows. + pub(crate) fn visible(self, width: f64, height: f64) -> Rect { + let corner = self.to_scene(0.0, 0.0); + Rect { + x: corner.x, + y: corner.y, + width: width / self.zoom, + height: height / self.zoom, + } + } + + /// The scroll that puts diagram point `point` at canvas pixel `at`. + fn scroll_for(self, point: Point, at: (f64, f64)) -> (f64, f64) { + ( + (point.x - self.extent.x) * self.zoom + self.pad_x - at.0, + (point.y - self.extent.y) * self.zoom + self.pad_y - at.1, + ) + } +} + +impl Default for DiagramCanvas { + fn default() -> Self { + Self::new() + } +} + +impl DiagramCanvas { + pub(crate) fn new() -> Self { + glib::Object::new() + } + + /// Show `diagram`. With `kept`, each table keeps the place it had and + /// a table new since then goes to the right of the others; without, + /// the automatic layout places every table. The first diagram picks + /// its zoom; one read again keeps the zoom and scroll there were. + pub(crate) fn set_diagram(&self, diagram: SchemaDiagram, kept: Option>) { + let imp = self.imp(); + let selected = self.selected_table(); + let first = !self.has_diagram(); + // The keyboard stays on the same table in the new diagram. GTK 4.22 + // leaks a focused widget that is unparented, so the focus leaves + // the old one first. + let had_focus = crate::ui::a11y::holds_focus(self); + // A drag names a card by its place in the old diagram. + imp.drag.replace(None); + if had_focus && let Some(window) = self.root().and_downcast::() { + GtkWindowExt::set_focus(&window, None::<>k::Widget>); + } + let previous_area = imp.content.take().map(|old| { + for node in old.nodes { + node.unparent(); + } + old.extent + }); + let context = self.pango_context(); + let texts: Vec = diagram + .tables + .iter() + .map(|table| CardText::lay_out(&context, table)) + .collect(); + let cards = texts + .iter() + .map(|text| Card { + at: Point { x: 0.0, y: 0.0 }, + shape: text.shape, + }) + .collect(); + let count = diagram.tables.len(); + let nodes = (0..count) + .map(|at| { + let label = diagram.tables[at].table.name.as_str(); + let node = DiagramNode::new(at, label, &table_description(&diagram, at), count); + node.set_parent(self); + node + }) + .collect(); + let mut content = Content { + diagram, + texts, + cards, + edges: Vec::new(), + order: (0..count).collect(), + nodes, + extent: Rect { + x: 0.0, + y: 0.0, + width: 1.0, + height: 1.0, + }, + moved: false, + }; + let fresh = match kept { + Some(kept) => !place_kept(&mut content, &kept), + None => true, + }; + if fresh { + content.arrange(); + } + content.route_all(); + content.measure_extent(); + if let Some(area) = previous_area { + content.extent = area; + } + let reselect = selected.and_then(|table| content.diagram.position_of(&table)); + imp.content.replace(Some(content)); + imp.selected.set(None); + if first { + imp.pending_fit.set(true); + } else { + self.grow_area(); + } + self.select(reselect); + if had_focus && let Some(index) = reselect.or((count > 0).then_some(0)) { + imp.quiet_focus.set(true); + self.focus_table(index); + imp.quiet_focus.set(false); + } + self.queue_resize(); + self.queue_draw(); + } + + #[cfg(test)] + pub(crate) fn palette(&self) -> super::palette::Palette { + self.imp().palette() + } + + pub(crate) fn has_diagram(&self) -> bool { + self.imp().content.borrow().is_some() + } + + /// Where every table is, by name, once the user has moved one: a + /// refresh and a restore put them back there. + pub(crate) fn kept_places(&self) -> Option> { + let content = self.imp().content.borrow(); + let content = content.as_ref().filter(|content| content.moved)?; + Some( + content + .diagram + .tables + .iter() + .zip(&content.cards) + .map(|(table, card)| (table.table.name.clone(), card.at)) + .collect(), + ) + } + + #[cfg(test)] + pub(crate) fn card_position(&self, index: usize) -> Point { + self.imp() + .content + .borrow() + .as_ref() + .and_then(|content| content.cards.get(index)) + .map_or(Point { x: 0.0, y: 0.0 }, |card| card.at) + } + + pub(crate) fn table(&self, index: usize) -> Option { + let content = self.imp().content.borrow(); + content + .as_ref()? + .diagram + .tables + .get(index) + .map(|table| table.table.clone()) + } + + pub(crate) fn table_count(&self) -> usize { + self.imp() + .content + .borrow() + .as_ref() + .map_or(0, |content| content.diagram.tables.len()) + } + + pub(crate) fn selected(&self) -> Option { + self.imp().selected.get() + } + + pub(crate) fn selected_table(&self) -> Option { + self.table(self.selected()?) + } + + /// The widget a screen reader and the keyboard meet for `index`. + pub(crate) fn node(&self, index: usize) -> Option { + self.imp().content.borrow().as_ref()?.nodes.get(index).cloned() + } + + pub(crate) fn select(&self, index: Option) { + let imp = self.imp(); + let index = index.filter(|&at| at < self.table_count()); + if imp.selected.get() == index { + return; + } + let previous = imp.selected.replace(index); + for at in [previous, index].into_iter().flatten() { + if let Some(node) = self.node(at) { + node.set_selected(Some(at) == index); + } + } + if let Some(at) = index { + self.raise(at); + } + self.queue_draw(); + self.emit_by_name::<()>("selection-changed", &[]); + } + + pub(crate) fn focus_table(&self, index: usize) { + if let Some(node) = self.node(index) { + self.select(Some(index)); + node.grab_focus(); + } + } + + fn raise(&self, index: usize) { + if let Some(content) = self.imp().content.borrow_mut().as_mut() { + content.order.retain(|&at| at != index); + content.order.push(index); + } + } + + pub(crate) fn zoom(&self) -> f64 { + self.imp().zoom.get() + } + + /// Zoom to `zoom`, keeping the diagram point under `anchor`, in canvas + /// pixels, where it is; the middle of the canvas without one. + pub(crate) fn set_zoom(&self, zoom: f64, anchor: Option<(f64, f64)>) { + let imp = self.imp(); + let zoom = zoom::clamp(zoom); + if (zoom - imp.zoom.get()).abs() < 1e-9 { + return; + } + let (width, height) = (f64::from(self.width()), f64::from(self.height())); + let anchor = anchor.unwrap_or((width / 2.0, height / 2.0)); + let point = imp.view().to_scene(anchor.0, anchor.1); + imp.zoom.set(zoom); + let scroll = imp.view().scroll_for(point, anchor); + imp.configure_adjustments(width, height, Some(scroll)); + self.queue_allocate(); + self.queue_draw(); + self.emit_by_name::<()>("zoom-changed", &[]); + } + + pub(crate) fn zoom_in(&self) { + self.set_zoom(zoom::step_in(self.zoom()), None); + } + + pub(crate) fn zoom_out(&self) { + self.set_zoom(zoom::step_out(self.zoom()), None); + } + + pub(crate) fn zoom_reset(&self) { + self.set_zoom(1.0, None); + } + + pub(crate) fn zoom_fit(&self) { + let imp = self.imp(); + let Some(bounds) = imp.content.borrow().as_ref().map(Content::bounds) else { + return; + }; + let (width, height) = (f64::from(self.width()), f64::from(self.height())); + imp.zoom.set(zoom::fit((bounds.width, bounds.height), (width, height))); + let middle = Point { + x: bounds.x + bounds.width / 2.0, + y: bounds.y + bounds.height / 2.0, + }; + let scroll = imp.view().scroll_for(middle, (width / 2.0, height / 2.0)); + imp.configure_adjustments(width, height, Some(scroll)); + self.queue_allocate(); + self.queue_draw(); + self.emit_by_name::<()>("zoom-changed", &[]); + } + + /// The zoom a new diagram opens at: whole when that is readable, + /// else readable from its top left corner. + pub(crate) fn open_zoom(&self, width: f64, height: f64) { + let imp = self.imp(); + let Some(extent) = imp.content.borrow().as_ref().map(|content| content.extent) else { + return; + }; + let fit = zoom::fit((extent.width, extent.height), (width, height)); + let zoom = fit.max(OPENING_ZOOM); + imp.zoom.set(zoom); + let view = imp.view(); + let whole = extent.width * zoom <= width && extent.height * zoom <= height; + let scroll = if whole { view.centred() } else { view.top_left() }; + imp.configure_adjustments(width, height, Some(scroll)); + // This runs inside the canvas's allocation, where the zoom label + // must not change its size. + let weak = self.downgrade(); + glib::idle_add_local_once(move || { + if let Some(canvas) = weak.upgrade() { + canvas.emit_by_name::<()>("zoom-changed", &[]); + } + }); + } + + /// Put every table back where the automatic layout places it. + pub(crate) fn reset_layout(&self) { + let imp = self.imp(); + { + let mut content = imp.content.borrow_mut(); + let Some(content) = content.as_mut() else { + return; + }; + content.arrange(); + content.route_all(); + content.measure_extent(); + } + self.emit_by_name::<()>("layout-changed", &[]); + self.zoom_fit(); + if let Some(selected) = self.selected() { + self.scroll_to_card(selected); + } + } + + /// Move the selected table by `dx`, `dy` diagram units. + pub(crate) fn move_selected(&self, dx: f64, dy: f64) { + let Some(index) = self.selected() else { + return; + }; + let Some(from) = self + .imp() + .content + .borrow() + .as_ref() + .and_then(|content| content.cards.get(index)) + .map(|card| card.at) + else { + return; + }; + self.place_card( + index, + Point { + x: from.x + dx, + y: from.y + dy, + }, + ); + self.settle_move(); + self.scroll_to_card(index); + } + + /// Move a card while it is dragged. The scrolled area stays as it was + /// until the drag ends. + pub(crate) fn place_card(&self, index: usize, at: Point) { + if let Some(content) = self.imp().content.borrow_mut().as_mut() + && let Some(card) = content.cards.get_mut(index) + { + card.at = at; + content.reroute(index); + content.moved = true; + } + self.queue_allocate(); + self.queue_draw(); + } + + /// After a move: the scrolled area takes in where the card went. + pub(crate) fn settle_move(&self) { + self.grow_area(); + self.emit_by_name::<()>("layout-changed", &[]); + } + + /// The scrolled area takes in what the cards and lines cover now and + /// keeps all it held before, and the scroll moves with its corner, so + /// nothing on screen moves: a moved card, and a refresh, leave the + /// view where it was. Only a new layout or Best Fit moves it. + fn grow_area(&self) { + let imp = self.imp(); + let shift = { + let mut content = imp.content.borrow_mut(); + let Some(content) = content.as_mut() else { + return; + }; + let before = content.extent; + content.extent = union(before, content.bounds()); + (before.x - content.extent.x, before.y - content.extent.y) + }; + let zoom = self.zoom(); + let (h, v) = imp.adjustments(); + let scroll = ( + h.map_or(0.0, |adjustment| adjustment.value()) + shift.0 * zoom, + v.map_or(0.0, |adjustment| adjustment.value()) + shift.1 * zoom, + ); + imp.configure_adjustments(f64::from(self.width()), f64::from(self.height()), Some(scroll)); + self.queue_resize(); + self.queue_draw(); + } + + /// Scroll so the card is on screen, if it is not. + pub(crate) fn scroll_to_card(&self, index: usize) { + let imp = self.imp(); + let Some(bounds) = imp + .content + .borrow() + .as_ref() + .and_then(|content| content.cards.get(index)) + .map(Card::rect) + else { + return; + }; + let view = imp.view(); + let (width, height) = (f64::from(self.width()), f64::from(self.height())); + let (left, top) = view.to_widget(bounds.x, bounds.y); + let (right, bottom) = view.to_widget(bounds.right(), bounds.bottom()); + let margin = 24.0; + let shift = |near: f64, far: f64, size: f64| { + if far - near + 2.0 * margin > size || near < margin { + near - margin + } else if far > size - margin { + far - size + margin + } else { + 0.0 + } + }; + let (dx, dy) = (shift(left, right, width), shift(top, bottom, height)); + if dx == 0.0 && dy == 0.0 { + return; + } + imp.configure_adjustments(width, height, Some((view.scroll_x + dx, view.scroll_y + dy))); + self.queue_allocate(); + self.queue_draw(); + } + + /// The card at canvas pixel (`x`, `y`). + pub(crate) fn card_at(&self, x: f64, y: f64) -> Option { + let imp = self.imp(); + let point = imp.view().to_scene(x, y); + let content = imp.content.borrow(); + let content = content.as_ref()?; + scene::hit(&content.cards, &content.order, point) + } + + /// Where `index`'s card is on the canvas, for a menu to point at. + pub(crate) fn card_bounds(&self, index: usize) -> Option { + let imp = self.imp(); + let bounds = imp.content.borrow().as_ref()?.cards.get(index)?.rect(); + let view = imp.view(); + let (x, y) = view.to_widget(bounds.x, bounds.y); + Some(gdk::Rectangle::new( + x as i32, + y as i32, + (bounds.width * view.zoom) as i32, + (bounds.height * view.zoom).min(48.0) as i32, + )) + } + + /// Measure the text again after the font changed, and lay the tables + /// out again unless the user placed them. + pub(crate) fn measure_again(&self) { + let context = self.pango_context(); + let mut guard = self.imp().content.borrow_mut(); + let Some(content) = guard.as_mut() else { + return; + }; + let texts: Vec = content + .diagram + .tables + .iter() + .map(|table| CardText::lay_out(&context, table)) + .collect(); + if texts + .iter() + .zip(&content.texts) + .all(|(new, old)| new.shape == old.shape) + { + content.texts = texts; + return; + } + for (card, text) in content.cards.iter_mut().zip(&texts) { + card.shape = text.shape; + } + content.texts = texts; + if !content.moved { + content.arrange(); + } + content.route_all(); + content.measure_extent(); + drop(guard); + self.queue_resize(); + self.queue_draw(); + } + + /// The whole diagram at 100%, on its background, for an export, with + /// its size in pixels. + pub(crate) fn render(&self) -> Option<(gsk::RenderNode, f64, f64)> { + let imp = self.imp(); + let palette = imp.palette(); + let content = imp.content.borrow(); + let content = content.as_ref()?; + let extent = content.bounds(); + let snapshot = gtk::Snapshot::new(); + snapshot.append_color( + &palette.background, + &graphene::Rect::new(0.0, 0.0, extent.width as f32, extent.height as f32), + ); + snapshot.translate(&graphene::Point::new(-extent.x as f32, -extent.y as f32)); + let frame = Frame { + diagram: &content.diagram, + texts: &content.texts, + cards: &content.cards, + edges: &content.edges, + order: &content.order, + selected: None, + focused: None, + detail: true, + }; + draw::draw(&snapshot, &frame, &extent, &palette); + Some((snapshot.to_node()?, extent.width, extent.height)) + } + + pub(crate) fn connect_zoom_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "zoom-changed", + false, + glib::closure_local!(move |canvas: &Self| f(canvas)), + ) + } + + pub(crate) fn connect_selection_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "selection-changed", + false, + glib::closure_local!(move |canvas: &Self| f(canvas)), + ) + } + + /// The user moved a table, or put them all back. + pub(crate) fn connect_layout_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "layout-changed", + false, + glib::closure_local!(move |canvas: &Self| f(canvas)), + ) + } + + /// A double click on a card. + pub(crate) fn connect_table_activated(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "table-activated", + false, + glib::closure_local!(move |canvas: &Self, index: u32| { + f(canvas, usize::try_from(index).unwrap_or(usize::MAX)); + }), + ) + } +} + +fn union(a: Rect, b: Rect) -> Rect { + let (x, y) = (a.x.min(b.x), a.y.min(b.y)); + Rect { + x, + y, + width: a.right().max(b.right()) - x, + height: a.bottom().max(b.bottom()) - y, + } +} + +/// A new diagram opens no smaller than this, so its text can be read. +const OPENING_ZOOM: f64 = 0.75; + +/// Put each table `kept` names where it was, and the others in a column +/// right of them. False when `kept` names none of the tables, so the +/// automatic layout does better. +fn place_kept(content: &mut Content, kept: &HashMap) -> bool { + let mut right = f64::NEG_INFINITY; + let mut top = f64::INFINITY; + let mut placed = vec![false; content.cards.len()]; + for (at, table) in content.diagram.tables.iter().enumerate() { + if let (Some(point), Some(card)) = (kept.get(&table.table.name), content.cards.get_mut(at)) { + card.at = *point; + placed[at] = true; + right = right.max(point.x + card.shape.width); + top = top.min(point.y); + } + } + if !placed.contains(&true) { + return false; + } + let mut y = top; + for (at, card) in content.cards.iter_mut().enumerate() { + if placed[at] { + continue; + } + card.at = Point { + x: right + COLUMN_GAP, + y, + }; + y += card.shape.size().height + ROW_GAP; + } + content.moved = true; + true +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXTENT: Rect = Rect { + x: -40.0, + y: -40.0, + width: 2000.0, + height: 1500.0, + }; + + #[test] + fn a_zoom_keeps_the_point_under_the_pointer() { + let before = View::new(Some(EXTENT), 1.0, (800.0, 600.0), (700.0, 500.0)); + let point = before.to_scene(100.0, 50.0); + let scroll = View::new(Some(EXTENT), 2.0, (800.0, 600.0), (0.0, 0.0)).scroll_for(point, (100.0, 50.0)); + + let after = View::new(Some(EXTENT), 2.0, (800.0, 600.0), scroll); + + assert_eq!(after.to_widget(point.x, point.y), (100.0, 50.0)); + assert_eq!(point, Point { x: 360.0, y: 210.0 }); + } + + #[test] + fn any_diagram_can_be_centred_or_shown_from_its_corner() { + let shape = View::new(Some(EXTENT), 0.25, (800.0, 600.0), (0.0, 0.0)); + let centred = View::new(Some(EXTENT), 0.25, (800.0, 600.0), shape.centred()); + let corner = View::new(Some(EXTENT), 0.25, (800.0, 600.0), shape.top_left()); + + let middle = (EXTENT.x + EXTENT.width / 2.0, EXTENT.y + EXTENT.height / 2.0); + assert_eq!(centred.to_widget(middle.0, middle.1), (400.0, 300.0)); + assert_eq!(corner.to_widget(EXTENT.x, EXTENT.y), (0.0, 0.0)); + assert_eq!(shape.content_width(), 2000.0 * 0.25 + 800.0); + let visible = shape.visible(800.0, 600.0); + assert_eq!((visible.width, visible.height), (3200.0, 2400.0)); + } + + #[test] + fn the_union_holds_both() { + let moved = Rect { + x: -200.0, + y: 100.0, + width: 100.0, + height: 50.0, + }; + + let both = union(EXTENT, moved); + + assert_eq!( + (both.x, both.y, both.right(), both.bottom()), + (-200.0, -40.0, 1960.0, 1460.0) + ); + } +} diff --git a/linux/crates/app/src/ui/diagram/canvas_imp.rs b/linux/crates/app/src/ui/diagram/canvas_imp.rs new file mode 100644 index 0000000000..166d894c00 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/canvas_imp.rs @@ -0,0 +1,415 @@ +use std::cell::{Cell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, glib, graphene}; + +use super::canvas::{Content, Drag, Probe, View}; +use super::draw::{self, DETAIL_ZOOM, Frame}; +use super::node::DiagramNode; +use super::palette::{Palette, ThemeColors}; + +#[derive(glib::Properties)] +#[properties(wrapper_type = super::canvas::DiagramCanvas)] +pub struct DiagramCanvas { + #[property(override_interface = gtk::Scrollable, get, set = Self::set_hadjustment)] + hadjustment: RefCell>, + #[property(override_interface = gtk::Scrollable, get, set = Self::set_vadjustment)] + vadjustment: RefCell>, + #[property(override_interface = gtk::Scrollable, get, set)] + hscroll_policy: Cell, + #[property(override_interface = gtk::Scrollable, get, set)] + vscroll_policy: Cell, + + pub(super) content: RefCell>, + pub(super) zoom: Cell, + pub(super) selected: Cell>, + pub(super) drag: RefCell>, + /// Where the pointer last was, which Ctrl+scroll zooms around. + pub(super) pointer: Cell>, + /// The zoom when a pinch began. + pub(super) pinch: Cell>, + /// A new diagram picks its zoom once the canvas knows its size. + pub(super) pending_fit: Cell, + /// The focus moves without the keyboard, from a click or a refresh, + /// so the view stays where the user left it. + pub(super) quiet_focus: Cell, + pub(super) popover: RefCell>, + pub(super) probes: RefCell>, + configuring: Cell, + adjustment_handlers: RefCell>, + style_handlers: RefCell>, + window_handler: RefCell, glib::SignalHandlerId)>>, +} + +impl Default for DiagramCanvas { + fn default() -> Self { + Self { + hadjustment: RefCell::default(), + vadjustment: RefCell::default(), + hscroll_policy: Cell::new(gtk::ScrollablePolicy::Minimum), + vscroll_policy: Cell::new(gtk::ScrollablePolicy::Minimum), + content: RefCell::default(), + zoom: Cell::new(1.0), + selected: Cell::default(), + drag: RefCell::default(), + pointer: Cell::default(), + pinch: Cell::default(), + pending_fit: Cell::default(), + quiet_focus: Cell::default(), + popover: RefCell::default(), + probes: RefCell::default(), + configuring: Cell::default(), + adjustment_handlers: RefCell::default(), + style_handlers: RefCell::default(), + window_handler: RefCell::default(), + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for DiagramCanvas { + const NAME: &'static str = "TableProDiagramCanvas"; + type Type = super::canvas::DiagramCanvas; + type ParentType = gtk::Widget; + type Interfaces = (gtk::Scrollable,); + + fn class_init(klass: &mut Self::Class) { + klass.set_css_name("tp-diagram-canvas"); + klass.set_accessible_role(gtk::AccessibleRole::List); + } +} + +#[glib::derived_properties] +impl ObjectImpl for DiagramCanvas { + fn constructed(&self) { + self.parent_constructed(); + let canvas = self.obj(); + canvas.set_overflow(gtk::Overflow::Hidden); + let probes = Probe::ALL + .iter() + .map(|&probe| { + let widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); + widget.add_css_class(probe.css_class()); + widget.set_visible(false); + widget.set_parent(&*canvas); + (probe, widget.upcast::()) + }) + .collect(); + self.probes.replace(probes); + super::canvas_input::install(&canvas); + let manager = libadwaita::StyleManager::default(); + let weak = canvas.downgrade(); + let redraw = move || { + if let Some(canvas) = weak.upgrade() { + canvas.queue_draw(); + } + }; + let handlers = ["dark", "high-contrast", "accent-color-rgba"] + .into_iter() + .map(|property| { + let redraw = redraw.clone(); + manager.connect_notify_local(Some(property), move |_, _| redraw()) + }) + .collect(); + self.style_handlers.replace(handlers); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("zoom-changed").build(), + Signal::builder("selection-changed").build(), + Signal::builder("layout-changed").build(), + Signal::builder("table-activated") + .param_types([u32::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + let manager = libadwaita::StyleManager::default(); + for handler in self.style_handlers.take() { + manager.disconnect(handler); + } + self.disconnect_window(); + for (adjustment, handler) in self.adjustment_handlers.take() { + adjustment.disconnect(handler); + } + if let Some(popover) = self.popover.take() { + popover.unparent(); + } + if let Some(content) = self.content.take() { + for node in content.nodes { + node.unparent(); + } + } + for (_, probe) in self.probes.take() { + probe.unparent(); + } + } +} + +impl WidgetImpl for DiagramCanvas { + fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { + let view = self.view(); + let natural = match orientation { + gtk::Orientation::Horizontal => view.content_width(), + _ => view.content_height(), + }; + (0, natural.ceil().min(f64::from(i32::MAX)) as i32, -1, -1) + } + + fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { + let (width, height) = (f64::from(width), f64::from(height)); + if self.pending_fit.get() && width > 0.0 && height > 0.0 { + self.pending_fit.set(false); + self.obj().open_zoom(width, height); + } + self.configure_adjustments(width, height, None); + self.allocate_nodes(); + if let Some(popover) = self.popover.borrow().as_ref() { + popover.present(); + } + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let canvas = self.obj(); + let (width, height) = (f64::from(canvas.width()), f64::from(canvas.height())); + let palette = self.palette(); + snapshot.append_color( + &palette.background, + &graphene::Rect::new(0.0, 0.0, width as f32, height as f32), + ); + let content = self.content.borrow(); + let Some(content) = content.as_ref() else { + return; + }; + let view = self.view(); + let zoom = self.zoom.get(); + snapshot.save(); + snapshot.translate(&graphene::Point::new( + (view.pad_x - view.scroll_x) as f32, + (view.pad_y - view.scroll_y) as f32, + )); + snapshot.scale(zoom as f32, zoom as f32); + snapshot.translate(&graphene::Point::new( + -content.extent.x as f32, + -content.extent.y as f32, + )); + let frame = Frame { + diagram: &content.diagram, + texts: &content.texts, + cards: &content.cards, + edges: &content.edges, + order: &content.order, + selected: self.selected.get(), + focused: self.focus_ring(content), + detail: zoom >= DETAIL_ZOOM, + }; + draw::draw(snapshot, &frame, &view.visible(width, height), &palette); + snapshot.restore(); + } + + fn set_focus_child(&self, child: Option<>k::Widget>) { + self.parent_set_focus_child(child); + let Some(node) = child.and_then(|child| child.downcast_ref::()) else { + self.obj().queue_draw(); + return; + }; + let canvas = self.obj(); + canvas.select(Some(node.index())); + if !self.quiet_focus.replace(false) { + canvas.scroll_to_card(node.index()); + } + canvas.queue_draw(); + } + + fn system_setting_changed(&self, setting: >k::SystemSetting) { + self.parent_system_setting_changed(setting); + if matches!( + setting, + gtk::SystemSetting::FontName | gtk::SystemSetting::Dpi | gtk::SystemSetting::FontConfig + ) { + self.obj().measure_again(); + } + } + + fn root(&self) { + self.parent_root(); + let canvas = self.obj(); + if let Some(window) = canvas.root().and_downcast::() { + let weak = canvas.downgrade(); + let handler = window.connect_focus_visible_notify(move |_| { + if let Some(canvas) = weak.upgrade() { + canvas.queue_draw(); + } + }); + self.window_handler.replace(Some((window.downgrade(), handler))); + } + // The window's font can differ from the default the text was + // first measured with. + canvas.measure_again(); + } + + fn unroot(&self) { + self.disconnect_window(); + self.parent_unroot(); + } +} + +impl ScrollableImpl for DiagramCanvas {} + +impl DiagramCanvas { + fn set_hadjustment(&self, adjustment: Option) { + self.hadjustment.replace(self.follow(adjustment)); + } + + fn set_vadjustment(&self, adjustment: Option) { + self.vadjustment.replace(self.follow(adjustment)); + } + + /// Redraw and move the cards' widgets when the scrolled window + /// scrolls. + fn follow(&self, adjustment: Option) -> Option { + let adjustment = adjustment?; + let weak = self.obj().downgrade(); + let handler = adjustment.connect_value_changed(move |_| { + let Some(canvas) = weak.upgrade() else { + return; + }; + if !canvas.imp().configuring.get() { + canvas.queue_allocate(); + } + canvas.queue_draw(); + }); + self.adjustment_handlers + .borrow_mut() + .push((adjustment.clone(), handler)); + Some(adjustment) + } + + fn disconnect_window(&self) { + if let Some((window, handler)) = self.window_handler.take() + && let Some(window) = window.upgrade() + { + window.disconnect(handler); + } + } + + pub(super) fn adjustments(&self) -> (Option, Option) { + (self.hadjustment.borrow().clone(), self.vadjustment.borrow().clone()) + } + + /// The transform from the diagram to the canvas as it stands. + pub(super) fn view(&self) -> View { + let canvas = self.obj(); + let (h, v) = self.adjustments(); + let extent = self.content.borrow().as_ref().map(|content| content.extent); + View::new( + extent, + self.zoom.get(), + (f64::from(canvas.width()), f64::from(canvas.height())), + ( + h.map_or(0.0, |adjustment| adjustment.value()), + v.map_or(0.0, |adjustment| adjustment.value()), + ), + ) + } + + /// Fit the scrolled area to the diagram at the zoom, and scroll to + /// `values` when given, else keep the scroll within the new bounds. + pub(super) fn configure_adjustments(&self, width: f64, height: f64, values: Option<(f64, f64)>) { + let view = self.view(); + let (h, v) = self.adjustments(); + self.configuring.set(true); + for (adjustment, content, page, wanted) in [ + (h, view.content_width(), width, values.map(|values| values.0)), + (v, view.content_height(), height, values.map(|values| values.1)), + ] { + let Some(adjustment) = adjustment else { + continue; + }; + let upper = content.max(page); + let value = wanted.unwrap_or_else(|| adjustment.value()); + adjustment.configure( + value.clamp(0.0, (upper - page).max(0.0)), + 0.0, + upper, + page * 0.1, + page * 0.9, + page, + ); + } + self.configuring.set(false); + } + + /// Put each card's widget where its card is drawn. + pub(super) fn allocate_nodes(&self) { + let view = self.view(); + let content = self.content.borrow(); + let Some(content) = content.as_ref() else { + return; + }; + for node in &content.nodes { + let Some(card) = content.cards.get(node.index()) else { + continue; + }; + let bounds = card.rect(); + let (x, y) = view.to_widget(bounds.x, bounds.y); + let width = (bounds.width * view.zoom).ceil().clamp(1.0, f64::from(i32::MAX)) as i32; + let height = (bounds.height * view.zoom).ceil().clamp(1.0, f64::from(i32::MAX)) as i32; + let transform = gtk4::gsk::Transform::new().translate(&graphene::Point::new(x as f32, y as f32)); + // GTK asks that a widget be measured before it is allocated. + let _ = node.preferred_size(); + node.allocate(width, height, -1, Some(transform)); + } + } + + /// The colours of the theme the canvas is in. Each probe's `color` is + /// one of libadwaita's colour variables, from the app's stylesheet. + pub(super) fn palette(&self) -> Palette { + let canvas = self.obj(); + let fallback = canvas.color(); + let color = |wanted: Probe| { + self.probes + .borrow() + .iter() + .find(|(probe, _)| *probe == wanted) + .map_or(fallback, |(_, widget)| widget.color()) + }; + Palette::from_theme(ThemeColors { + window_bg: color(Probe::WindowBackground), + card_bg: color(Probe::CardBackground), + card_fg: color(Probe::CardForeground), + border: color(Probe::Border), + accent: color(Probe::Accent), + }) + } + + /// The card whose widget holds the keyboard focus, while the window + /// shows where the focus is. + fn focus_ring(&self, content: &Content) -> Option { + let canvas = self.obj(); + let window = canvas.root().and_downcast::()?; + if !window.gets_focus_visible() { + return None; + } + let focus = gtk::prelude::GtkWindowExt::focus(&window)?; + content + .nodes + .iter() + .find(|node| node.upcast_ref::() == &focus) + .map(DiagramNode::index) + } +} + +/// The pointer's modifiers, to tell Ctrl+scroll from a scroll. +pub(super) fn has_primary_modifier(state: gdk::ModifierType) -> bool { + state.contains(gdk::ModifierType::CONTROL_MASK) +} diff --git a/linux/crates/app/src/ui/diagram/canvas_input.rs b/linux/crates/app/src/ui/diagram/canvas_input.rs new file mode 100644 index 0000000000..214fa0d9c6 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/canvas_input.rs @@ -0,0 +1,243 @@ +//! The pointer on the canvas: drag a card or the empty space, click to +//! select, double click to open, right click for the menu, Ctrl+scroll +//! and a pinch to zoom. + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio}; + +use super::canvas::{DiagramCanvas, Drag}; +use super::canvas_imp::has_primary_modifier; +use super::layout::Point; +use crate::application::menu_item; + +/// A press that moves less than this is a click, not a drag. +const DRAG_THRESHOLD: f64 = 4.0; +/// Ctrl+scroll zooms by this much per wheel notch. +const WHEEL_ZOOM: f64 = 1.1; +/// A touchpad scrolls in pixels; this many make one notch's zoom. +const PIXELS_PER_NOTCH: f64 = 40.0; + +pub(super) fn install(canvas: &DiagramCanvas) { + install_drag(canvas, gdk::BUTTON_PRIMARY, true); + install_drag(canvas, gdk::BUTTON_MIDDLE, false); + install_click(canvas); + install_scroll(canvas); + install_pinch(canvas); + let motion = gtk::EventControllerMotion::new(); + let weak = canvas.downgrade(); + motion.connect_motion(move |_, x, y| { + if let Some(canvas) = weak.upgrade() { + canvas.imp().pointer.set(Some((x, y))); + } + }); + let weak = canvas.downgrade(); + motion.connect_leave(move |_| { + if let Some(canvas) = weak.upgrade() { + canvas.imp().pointer.set(None); + } + }); + canvas.add_controller(motion); +} + +/// The primary button drags a card, or pans from empty space; the +/// middle button always pans. +fn install_drag(canvas: &DiagramCanvas, button: u32, moves_cards: bool) { + let drag = gtk::GestureDrag::new(); + drag.set_button(button); + let weak = canvas.downgrade(); + drag.connect_drag_begin(move |_, x, y| { + let Some(canvas) = weak.upgrade() else { + return; + }; + let card = moves_cards.then(|| canvas.card_at(x, y)).flatten(); + let start = match card.and_then(|index| card_origin(&canvas, index).map(|from| (index, from))) { + Some((index, from)) => Drag::Card { + index, + from, + moved: false, + }, + None => { + let (h, v) = canvas.imp().adjustments(); + Drag::Pan { + scroll: ( + h.map_or(0.0, |adjustment| adjustment.value()), + v.map_or(0.0, |adjustment| adjustment.value()), + ), + } + } + }; + canvas.imp().drag.replace(Some(start)); + }); + let weak = canvas.downgrade(); + drag.connect_drag_update(move |_, dx, dy| { + if let Some(canvas) = weak.upgrade() { + drag_update(&canvas, dx, dy); + } + }); + let weak = canvas.downgrade(); + drag.connect_drag_end(move |_, dx, dy| { + let Some(canvas) = weak.upgrade() else { + return; + }; + drag_update(&canvas, dx, dy); + if let Some(Drag::Card { moved: true, .. }) = canvas.imp().drag.take() { + canvas.settle_move(); + } + }); + canvas.add_controller(drag); +} + +fn card_origin(canvas: &DiagramCanvas, index: usize) -> Option { + let content = canvas.imp().content.borrow(); + content.as_ref()?.cards.get(index).map(|card| card.at) +} + +fn drag_update(canvas: &DiagramCanvas, dx: f64, dy: f64) { + let imp = canvas.imp(); + let current = *imp.drag.borrow(); + match current { + Some(Drag::Card { index, from, moved }) => { + if !moved && dx.hypot(dy) < DRAG_THRESHOLD { + return; + } + imp.drag.replace(Some(Drag::Card { + index, + from, + moved: true, + })); + let zoom = canvas.zoom(); + canvas.place_card( + index, + Point { + x: from.x + dx / zoom, + y: from.y + dy / zoom, + }, + ); + } + Some(Drag::Pan { scroll }) => { + let (h, v) = imp.adjustments(); + if let Some(h) = h { + h.set_value(scroll.0 - dx); + } + if let Some(v) = v { + v.set_value(scroll.1 - dy); + } + } + None => {} + } +} + +fn install_click(canvas: &DiagramCanvas) { + let click = gtk::GestureClick::new(); + click.set_button(0); + let weak = canvas.downgrade(); + click.connect_pressed(move |gesture, presses, x, y| { + let Some(canvas) = weak.upgrade() else { + return; + }; + let card = canvas.card_at(x, y); + match gesture.current_button() { + gdk::BUTTON_PRIMARY => { + match card { + Some(index) => focus_by_pointer(&canvas, index), + None => canvas.select(None), + } + if presses == 2 + && let Some(index) = card + { + canvas.emit_by_name::<()>("table-activated", &[&u32::try_from(index).unwrap_or(u32::MAX)]); + } + } + gdk::BUTTON_SECONDARY => { + if let Some(index) = card { + focus_by_pointer(&canvas, index); + popup_menu(&canvas, gdk::Rectangle::new(x.round() as i32, y.round() as i32, 1, 1)); + } + } + _ => {} + } + }); + canvas.add_controller(click); +} + +/// A click puts the keyboard on the card it selects, so the keys go on +/// from there, without scrolling: the card is under the pointer. +fn focus_by_pointer(canvas: &DiagramCanvas, index: usize) { + canvas.imp().quiet_focus.set(true); + canvas.focus_table(index); + canvas.imp().quiet_focus.set(false); +} + +fn install_scroll(canvas: &DiagramCanvas) { + let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + let weak = canvas.downgrade(); + scroll.connect_scroll(move |controller, _, dy| { + let Some(canvas) = weak.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; + if !has_primary_modifier(controller.current_event_state()) { + // The scrolled window pans. + return gtk::glib::Propagation::Proceed; + } + let notches = match controller.unit() { + gdk::ScrollUnit::Surface => dy / PIXELS_PER_NOTCH, + _ => dy, + }; + let anchor = canvas.imp().pointer.get(); + canvas.set_zoom(canvas.zoom() * WHEEL_ZOOM.powf(-notches), anchor); + gtk::glib::Propagation::Stop + }); + canvas.add_controller(scroll); +} + +fn install_pinch(canvas: &DiagramCanvas) { + let pinch = gtk::GestureZoom::new(); + let weak = canvas.downgrade(); + pinch.connect_begin(move |_, _| { + if let Some(canvas) = weak.upgrade() { + canvas.imp().pinch.set(Some(canvas.zoom())); + } + }); + let weak = canvas.downgrade(); + pinch.connect_scale_changed(move |gesture, scale| { + let Some(canvas) = weak.upgrade() else { + return; + }; + let Some(start) = canvas.imp().pinch.get() else { + return; + }; + canvas.set_zoom(start * scale, gesture.bounding_box_center()); + }); + let weak = canvas.downgrade(); + pinch.connect_end(move |_, _| { + if let Some(canvas) = weak.upgrade() { + canvas.imp().pinch.set(None); + } + }); + canvas.add_controller(pinch); +} + +/// The selected table's menu, pointing at `at` on the canvas. +pub(super) fn popup_menu(canvas: &DiagramCanvas, at: gdk::Rectangle) { + let imp = canvas.imp(); + let popover = imp + .popover + .borrow_mut() + .get_or_insert_with(|| { + let menu = gio::Menu::new(); + menu.append_item(&menu_item(&crate::i18n::gettext("Open Table"), "diagram.open-table")); + menu.append_item(&menu_item( + &crate::i18n::gettext("Show Structure"), + "diagram.show-structure", + )); + let popover = gtk::PopoverMenu::from_model(Some(&menu)); + popover.set_has_arrow(true); + popover.set_parent(canvas); + popover + }) + .clone(); + popover.set_pointing_to(Some(&at)); + popover.popup(); +} diff --git a/linux/crates/app/src/ui/diagram/card_text.rs b/linux/crates/app/src/ui/diagram/card_text.rs new file mode 100644 index 0000000000..e07e041df1 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/card_text.rs @@ -0,0 +1,173 @@ +use gtk4::pango; + +use super::model::{DiagramColumn, DiagramTable}; +use super::scene::CardShape; + +pub(crate) const PAD_X: f64 = 12.0; +const BADGE_GAP: f64 = 6.0; +const TYPE_GAP: f64 = 16.0; +const HEADER_PAD_Y: f64 = 8.0; +const ROW_PAD_Y: f64 = 3.0; +const FOOTER: f64 = 4.0; +const MIN_WIDTH: f64 = 160.0; +/// Longer names and types end in an ellipsis; the screen reader's +/// description and the tooltip-free export keep the whole name. +const MAX_HEADER: i32 = 420; +const MAX_NAME: i32 = 240; +const MAX_TYPE: i32 = 180; + +/// A card's text, laid out once, and the shape it gives the card. +pub(crate) struct CardText { + pub header: pango::Layout, + pub rows: Vec, + pub shape: CardShape, + /// Where each row's name starts, from the card's left edge. + pub name_x: f64, +} + +pub(crate) struct RowText { + pub badge: Option, + pub name: pango::Layout, + pub type_name: pango::Layout, +} + +impl CardText { + pub(crate) fn lay_out(context: &pango::Context, table: &DiagramTable) -> Self { + let header = text(context, &table.table.name, MAX_HEADER, Style::Bold); + let rows: Vec = table.columns.iter().map(|column| row(context, column)).collect(); + let (header_width, header_height) = size(&header); + let line = rows + .iter() + .map(|row| size(&row.name).1.max(size(&row.type_name).1)) + .fold(header_height, f64::max); + let badge_width = rows + .iter() + .filter_map(|row| row.badge.as_ref()) + .map(|badge| size(badge).0) + .fold(0.0, f64::max); + let name_width = rows.iter().map(|row| size(&row.name).0).fold(0.0, f64::max); + let type_width = rows.iter().map(|row| size(&row.type_name).0).fold(0.0, f64::max); + let name_x = match badge_width > 0.0 { + true => PAD_X + badge_width + BADGE_GAP, + false => PAD_X, + }; + let width = (name_x + name_width + TYPE_GAP + type_width + PAD_X) + .max(header_width + 2.0 * PAD_X) + .max(MIN_WIDTH) + .ceil(); + let shape = CardShape { + width, + header_height: (header_height + 2.0 * HEADER_PAD_Y).ceil(), + row_height: (line + 2.0 * ROW_PAD_Y).ceil(), + rows: rows.len(), + footer: FOOTER, + }; + Self { + header, + rows, + shape, + name_x, + } + } +} + +fn row(context: &pango::Context, column: &DiagramColumn) -> RowText { + let mut badges = Vec::new(); + if column.primary_key { + badges.push(crate::i18n::pgettext("diagram key badge", "PK")); + } + if column.foreign_key { + badges.push(crate::i18n::pgettext("diagram key badge", "FK")); + } + let badge = (!badges.is_empty()).then(|| text(context, &badges.join(" "), MAX_NAME, Style::Badge)); + RowText { + badge, + name: text(context, &column.name, MAX_NAME, Style::Plain), + type_name: text(context, &column.type_name, MAX_TYPE, Style::Plain), + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Style { + Plain, + Bold, + /// A key badge: small bold capitals beside the name. + Badge, +} + +fn text(context: &pango::Context, value: &str, max: i32, style: Style) -> pango::Layout { + let layout = pango::Layout::new(context); + layout.set_text(value); + layout.set_single_paragraph_mode(true); + layout.set_ellipsize(pango::EllipsizeMode::End); + layout.set_width(max * pango::SCALE); + if style != Style::Plain { + let list = pango::AttrList::new(); + list.insert(pango::AttrInt::new_weight(pango::Weight::Bold)); + if style == Style::Badge { + list.insert(pango::AttrFloat::new_scale(0.75)); + } + layout.set_attributes(Some(&list)); + } + layout +} + +/// Width and height in logical pixels. +pub(crate) fn size(layout: &pango::Layout) -> (f64, f64) { + let (width, height) = layout.pixel_size(); + (f64::from(width), f64::from(height)) +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use tablepro_core::meta::TableRef; + + use super::*; + + fn table(columns: &[(&str, &str, bool, bool)]) -> DiagramTable { + DiagramTable { + table: TableRef::unqualified("orders"), + columns: columns + .iter() + .map(|&(name, type_name, primary_key, foreign_key)| DiagramColumn { + name: name.to_owned(), + type_name: type_name.to_owned(), + primary_key, + foreign_key, + nullable: false, + }) + .collect(), + } + } + + #[gtk4::test] + fn a_card_is_as_tall_as_its_rows_and_as_wide_as_its_longest_line() { + let context = gtk4::Label::new(None).pango_context(); + let short = CardText::lay_out(&context, &table(&[("id", "integer", true, false)])); + let long = CardText::lay_out( + &context, + &table(&[ + ("id", "integer", true, false), + ("customer_reference_number", "character varying(255)", false, true), + ]), + ); + + assert_eq!(long.shape.rows, 2); + assert!(long.shape.size().height > short.shape.size().height); + assert!(long.shape.width > short.shape.width); + assert!(short.shape.width >= MIN_WIDTH); + assert!(long.rows[1].badge.is_some() && long.rows[0].badge.is_some()); + assert!(long.name_x > PAD_X, "names start after the badges"); + } + + #[gtk4::test] + fn a_very_long_name_is_cut_short() { + let context = gtk4::Label::new(None).pango_context(); + let name = "n".repeat(500); + let card = CardText::lay_out(&context, &table(&[(name.as_str(), "text", false, false)])); + + assert!(size(&card.rows[0].name).0 <= f64::from(MAX_NAME)); + assert!(card.rows[0].name.is_ellipsized()); + } +} diff --git a/linux/crates/app/src/ui/diagram/describe.rs b/linux/crates/app/src/ui/diagram/describe.rs new file mode 100644 index 0000000000..33328afc08 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/describe.rs @@ -0,0 +1,92 @@ +//! What a screen reader says for a table in the diagram: sighted users +//! read the same from its card and its lines. + +use super::model::{DiagramColumn, SchemaDiagram}; +use crate::i18n::{gettext_f, ngettext_f, pgettext}; + +/// The table's columns and the tables it is joined to. +pub(crate) fn table_description(diagram: &SchemaDiagram, table: usize) -> String { + let Some(found) = diagram.tables.get(table) else { + return String::new(); + }; + let count = found.columns.len(); + let mut sentences = vec![ngettext_f( + "{n} column.", + "{n} columns.", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + )]; + let (references, referenced_by) = diagram.neighbours(table); + if !references.is_empty() { + sentences.push(gettext_f("References {tables}.", &[("tables", &references.join(", "))])); + } + if !referenced_by.is_empty() { + sentences.push(gettext_f( + "Referenced by {tables}.", + &[("tables", &referenced_by.join(", "))], + )); + } + if !found.columns.is_empty() { + let columns: Vec = found.columns.iter().map(column_text).collect(); + sentences.push(gettext_f("Columns: {columns}.", &[("columns", &columns.join("; "))])); + } + sentences.join(" ") +} + +fn column_text(column: &DiagramColumn) -> String { + let mut parts = vec![column.name.clone(), column.type_name.clone()]; + if column.primary_key { + parts.push(pgettext("diagram column", "primary key")); + } + if column.foreign_key { + parts.push(pgettext("diagram column", "foreign key")); + } + if column.nullable { + parts.push(pgettext("diagram column", "optional")); + } + parts.retain(|part| !part.is_empty()); + parts.join(", ") +} + +/// What the diagram as a whole is called. +pub(crate) fn diagram_label(schema: Option<&str>) -> String { + match schema { + Some(schema) => gettext_f("Tables in {schema}", &[("schema", schema)]), + None => crate::i18n::gettext("Tables"), + } +} + +#[cfg(test)] +mod tests { + use super::super::model::tests::{column, key, read}; + use super::*; + + #[test] + fn a_table_is_described_with_its_columns_and_neighbours() { + let diagram = SchemaDiagram::build(vec![ + read( + "orders", + vec![column("id", true, false), column("customer_id", false, true)], + vec![key("orders_customer", &["customer_id"], None, "customers", &["id"])], + ), + read("customers", vec![column("id", true, false)], Vec::new()), + ]); + + assert_eq!( + table_description(&diagram, 1), + "2 columns. References customers. Columns: id, integer, primary key; \ + customer_id, integer, foreign key, optional." + ); + assert_eq!( + table_description(&diagram, 0), + "1 column. Referenced by orders. Columns: id, integer, primary key." + ); + assert_eq!(table_description(&diagram, 9), ""); + } + + #[test] + fn the_diagram_is_named_for_its_schema() { + assert_eq!(diagram_label(Some("public")), "Tables in public"); + assert_eq!(diagram_label(None), "Tables"); + } +} diff --git a/linux/crates/app/src/ui/diagram/draw.rs b/linux/crates/app/src/ui/diagram/draw.rs new file mode 100644 index 0000000000..7dbe284084 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/draw.rs @@ -0,0 +1,270 @@ +//! Draws the diagram into a snapshot, in diagram units: the screen and +//! an export go through the same calls. + +use std::collections::BTreeSet; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, graphene, gsk}; + +use super::card_text::{CardText, PAD_X, size}; +use super::model::SchemaDiagram; +use super::palette::Palette; +use super::scene::{Card, EdgePath, Rect}; + +const RADIUS: f32 = 8.0; +const LINE_WIDTH: f32 = 1.5; +const SELECTED_LINE_WIDTH: f32 = 2.5; +/// Below this zoom the rows' text is a few pixels tall, so only the +/// table names are drawn. +pub(crate) const DETAIL_ZOOM: f64 = 0.4; + +/// What one frame shows. +pub(crate) struct Frame<'a> { + pub diagram: &'a SchemaDiagram, + pub texts: &'a [CardText], + pub cards: &'a [Card], + pub edges: &'a [EdgePath], + pub order: &'a [usize], + pub selected: Option, + /// The card the keyboard is on, drawn with a focus ring. + pub focused: Option, + pub detail: bool, +} + +pub(crate) fn draw(snapshot: >k::Snapshot, frame: &Frame<'_>, visible: &Rect, palette: &Palette) { + let keyed = selected_rows(frame); + let touches = |relation: usize| { + frame.selected.is_some_and(|selected| { + frame + .diagram + .relations + .get(relation) + .is_some_and(|found| found.from == selected || found.to == selected) + }) + }; + // The selected table's lines go over the others. + for pass in [false, true] { + for (at, edge) in frame.edges.iter().enumerate() { + if touches(at) != pass || !edge.bounds().intersects(visible) { + continue; + } + let Some(relation) = frame.diagram.relations.get(at) else { + continue; + }; + let color = if pass { palette.accent } else { palette.line }; + let width = if pass { SELECTED_LINE_WIDTH } else { LINE_WIDTH }; + line(snapshot, edge, color, width); + markers(snapshot, edge, relation.optional, relation.unique, color, palette.card); + } + } + for &at in frame.order { + let (Some(card), Some(text)) = (frame.cards.get(at), frame.texts.get(at)) else { + continue; + }; + if !card.rect().intersects(visible) { + continue; + } + let rows = keyed.iter().filter(|(table, _)| *table == at).map(|(_, row)| *row); + draw_card( + snapshot, + CardDraw { + card, + text, + highlighted: rows.collect(), + selected: frame.selected == Some(at), + focused: frame.focused == Some(at), + detail: frame.detail, + }, + palette, + ); + } +} + +/// The (table, row) pairs of the selected table's keys, at both ends. +fn selected_rows(frame: &Frame<'_>) -> BTreeSet<(usize, usize)> { + let Some(selected) = frame.selected else { + return BTreeSet::new(); + }; + frame + .diagram + .relations + .iter() + .filter(|relation| relation.from == selected || relation.to == selected) + .flat_map(|relation| { + let from = relation.from_columns.iter().map(move |&row| (relation.from, row)); + let to = relation.to_columns.iter().map(move |&row| (relation.to, row)); + from.chain(to) + }) + .collect() +} + +fn point(x: f64, y: f64) -> graphene::Point { + graphene::Point::new(x as f32, y as f32) +} + +fn rect(rect: &Rect) -> graphene::Rect { + graphene::Rect::new(rect.x as f32, rect.y as f32, rect.width as f32, rect.height as f32) +} + +fn line(snapshot: >k::Snapshot, edge: &EdgePath, color: gdk::RGBA, width: f32) { + let builder = gsk::PathBuilder::new(); + builder.move_to(edge.start.x as f32, edge.start.y as f32); + builder.line_to(edge.start_stub.x as f32, edge.start_stub.y as f32); + builder.cubic_to( + edge.control_start.x as f32, + edge.control_start.y as f32, + edge.control_end.x as f32, + edge.control_end.y as f32, + edge.end_stub.x as f32, + edge.end_stub.y as f32, + ); + builder.line_to(edge.end.x as f32, edge.end.y as f32); + let stroke = gsk::Stroke::new(width); + stroke.set_line_cap(gsk::LineCap::Round); + snapshot.append_stroke(&builder.to_path(), &stroke, &color); +} + +/// Crow's foot notation. At the referenced table: one bar and a second +/// bar, or a circle when the key takes NULL (zero or one). At the table +/// that holds the key: a fork for many or a bar for at most one, and a +/// circle, since a referenced row need not be pointed at. +fn markers(snapshot: >k::Snapshot, edge: &EdgePath, optional: bool, unique: bool, color: gdk::RGBA, fill: gdk::RGBA) { + let lines = gsk::PathBuilder::new(); + let circles = gsk::PathBuilder::new(); + let bar = |at: f64, x: f64, y: f64, dir: f64| { + lines.move_to((x + dir * at) as f32, (y - 6.0) as f32); + lines.line_to((x + dir * at) as f32, (y + 6.0) as f32); + }; + let (ex, ey, edir) = (edge.end.x, edge.end.y, edge.end_dir); + bar(6.0, ex, ey, edir); + if optional { + circles.add_circle(&point(ex + edir * 13.0, ey), 3.5); + } else { + bar(11.0, ex, ey, edir); + } + let (sx, sy, sdir) = (edge.start.x, edge.start.y, edge.start_dir); + if unique { + bar(6.0, sx, sy, sdir); + } else { + for spread in [-6.0, 0.0, 6.0] { + lines.move_to((sx + sdir * 10.0) as f32, sy as f32); + lines.line_to(sx as f32, (sy + spread) as f32); + } + } + circles.add_circle(&point(sx + sdir * 14.0, sy), 3.5); + let stroke = gsk::Stroke::new(LINE_WIDTH); + stroke.set_line_cap(gsk::LineCap::Round); + let circles = circles.to_path(); + snapshot.append_fill(&circles, gsk::FillRule::Winding, &fill); + snapshot.append_stroke(&circles, &stroke, &color); + snapshot.append_stroke(&lines.to_path(), &stroke, &color); +} + +struct CardDraw<'a> { + card: &'a Card, + text: &'a CardText, + highlighted: BTreeSet, + selected: bool, + focused: bool, + detail: bool, +} + +fn draw_card(snapshot: >k::Snapshot, draw: CardDraw<'_>, palette: &Palette) { + let CardDraw { + card, + text, + highlighted, + selected, + focused, + detail, + } = draw; + let bounds = card.rect(); + let outline = gsk::RoundedRect::from_rect(rect(&bounds), RADIUS); + let header = Rect { + height: card.shape.header_height, + ..bounds + }; + snapshot.push_rounded_clip(&outline); + snapshot.append_color(&palette.card, &rect(&bounds)); + let header_color = if selected { palette.highlight } else { palette.header }; + snapshot.append_color(&header_color, &rect(&header)); + for &row in &highlighted { + if row < card.shape.rows { + snapshot.append_color(&palette.highlight, &rect(&card.row_rect(row))); + } + } + snapshot.append_color( + &palette.border, + &rect(&Rect { + y: header.bottom() - 1.0, + height: 1.0, + ..header + }), + ); + snapshot.pop(); + let (border, width) = if selected { + (palette.accent, 2.0) + } else { + (palette.border, 1.0) + }; + snapshot.append_border(&outline, &[width; 4], &[border; 4]); + if focused { + let ring = Rect { + x: bounds.x - 4.0, + y: bounds.y - 4.0, + width: bounds.width + 8.0, + height: bounds.height + 8.0, + }; + let mut ring_color = palette.accent; + ring_color.set_alpha(0.5); + snapshot.append_border( + &gsk::RoundedRect::from_rect(rect(&ring), RADIUS + 4.0), + &[2.0; 4], + &[ring_color; 4], + ); + } + let (_, header_height) = size(&text.header); + layout_at( + snapshot, + &text.header, + bounds.x + PAD_X, + bounds.y + (card.shape.header_height - header_height) / 2.0, + palette.text, + ); + if detail { + draw_rows(snapshot, card, text, palette); + } +} + +fn draw_rows(snapshot: >k::Snapshot, card: &Card, text: &CardText, palette: &Palette) { + for (at, row) in text.rows.iter().enumerate() { + let bounds = card.row_rect(at); + let middle = |layout: >k4::pango::Layout| bounds.y + (bounds.height - size(layout).1) / 2.0; + if let Some(badge) = &row.badge { + layout_at(snapshot, badge, bounds.x + PAD_X, middle(badge), palette.dim_text); + } + layout_at( + snapshot, + &row.name, + bounds.x + text.name_x, + middle(&row.name), + palette.text, + ); + let (type_width, _) = size(&row.type_name); + layout_at( + snapshot, + &row.type_name, + bounds.right() - PAD_X - type_width, + middle(&row.type_name), + palette.dim_text, + ); + } +} + +fn layout_at(snapshot: >k::Snapshot, layout: >k4::pango::Layout, x: f64, y: f64, color: gdk::RGBA) { + snapshot.save(); + snapshot.translate(&point(x, y)); + snapshot.append_layout(layout, &color); + snapshot.restore(); +} diff --git a/linux/crates/app/src/ui/diagram/engines.rs b/linux/crates/app/src/ui/diagram/engines.rs new file mode 100644 index 0000000000..71369814e2 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/engines.rs @@ -0,0 +1,456 @@ +//! The diagram read from real servers: a schema of 50 tables joined by +//! foreign keys, on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite, +//! through the app's own connector and session, as the tab reads it. +//! +//! `node_00` points nowhere. Every other `node_N` points at its parent +//! in a binary tree through a nullable `parent_id`; every tenth also at +//! `node_00` through a unique, NOT NULL `owner_id`; and `node_01` at +//! itself through `manager_id`. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use secrecy::SecretString; +use tablepro_core::credentials::CredentialInteraction; +use tablepro_core::meta::TableRef; +use tablepro_core::statement::{GuardedStatement, StatementBuilder}; +use tablepro_core::{ + CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, Connection, Credentials, DatabaseDriver, + FileEndpoint, ForeignKeyMode, LivenessPolicy, NetworkEndpoint, NetworkTarget, TlsConfig, TlsMode, + WorkspaceSettings, WriteBatch, WriteKind, +}; +use tablepro_net::DirectTcpTransport; +use tablepro_session::runtime::Tasks; +use tablepro_session::{ + ConnectRequest, ConnectServices, ConnectionMetadata, Connector, DefaultDeadlines, DriverConnector, + NoStoredCredentials, RequestTarget, Session, SessionDeps, +}; +use tablepro_ssh::{SshRuntimeCell, SshServices}; +use tablepro_test_fixtures::{ + FixtureCredentials, HbaMode, MssqlFixture, MysqlFixture, MysqlFlavour, MysqlOptions, PostgresFixture, +}; +use tokio::sync::watch; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +use super::fetch::{ReadProgress, read_diagram}; +use super::model::SchemaDiagram; + +const TABLES: usize = 50; +/// 49 parents, four owners and one manager. +const KEYS: usize = 54; +const CALL: Duration = Duration::from_secs(60); +/// The read of all 50 tables, two at a time, under Rosetta on a laptop. +const READ_LIMIT: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Engine { + Postgres, + Mysql, + Mariadb, + Mssql, + Sqlite, +} + +impl Engine { + fn quote(self, name: &str) -> String { + match self { + Self::Mysql | Self::Mariadb => format!("`{name}`"), + Self::Mssql => format!("[{name}]"), + Self::Postgres | Self::Sqlite => format!("\"{name}\""), + } + } +} + +fn node(at: usize) -> String { + format!("node_{at:02}") +} + +/// The tables, created in an order where each key's table exists first. +fn create_statements(engine: Engine, schema: Option<&str>) -> Vec { + let qualify = |name: &str| match schema { + Some(schema) => format!("{}.{}", engine.quote(schema), engine.quote(name)), + None => engine.quote(name), + }; + (0..TABLES) + .map(|at| { + let mut columns = vec!["id integer NOT NULL PRIMARY KEY".to_owned()]; + let mut keys = Vec::new(); + if at > 0 { + columns.push("parent_id integer NULL".to_owned()); + keys.push(format!( + "FOREIGN KEY (parent_id) REFERENCES {} (id)", + qualify(&node((at - 1) / 2)) + )); + } + if at > 0 && at % 10 == 0 { + columns.push("owner_id integer NOT NULL UNIQUE".to_owned()); + keys.push(format!("FOREIGN KEY (owner_id) REFERENCES {} (id)", qualify(&node(0)))); + } + if at == 1 { + columns.push("manager_id integer NULL".to_owned()); + keys.push(format!( + "FOREIGN KEY (manager_id) REFERENCES {} (id)", + qualify(&node(1)) + )); + } + columns.extend(keys); + format!("CREATE TABLE {} ({})", qualify(&node(at)), columns.join(", ")) + }) + .collect() +} + +/// What the case connects to, kept alive while it runs. +struct Server { + request: ConnectRequest, + /// The schema the diagram reads. + scope: Option, + /// The container, or the SQLite file's folder, which goes when this + /// does. + _fixture: Box, +} + +fn password(credentials: &FixtureCredentials) -> Credentials { + Credentials::Password { + username: credentials.username.clone(), + password: SecretString::from(credentials.password.clone()), + } +} + +fn require() -> TlsConfig { + TlsConfig { + mode: TlsMode::Require, + ..TlsConfig::disabled() + } +} + +struct NetworkServer { + driver: Arc, + host: String, + port: u16, + credentials: Credentials, + database: String, + tls: TlsConfig, +} + +impl NetworkServer { + fn endpoint(&self) -> NetworkEndpoint { + NetworkEndpoint::new(&self.host, self.port).expect("a valid endpoint") + } + + async fn admin(&self) -> Arc { + let liveness = LivenessPolicy::DESKTOP; + let options = ConnectOptions { + target: ConnectTarget::Network(NetworkTarget { + transport: Arc::new(DirectTcpTransport::new(self.endpoint(), &liveness)), + credentials: self.credentials.clone(), + database: Some(self.database.clone()), + tls: self.tls.clone(), + }), + workspace_settings: WorkspaceSettings::workspace(false), + liveness, + environment: ConnectEnvironment::new("diagram-fill"), + }; + self.driver + .connect(options, CancellationToken::new()) + .await + .expect("an admin connection") + } + + fn request(&self) -> ConnectRequest { + ConnectRequest { + metadata: metadata(self.driver.as_ref()), + driver: self.driver.clone(), + target: RequestTarget::Network { + endpoint: self.endpoint(), + ssh: None, + credentials: self.credentials.clone(), + database: Some(self.database.clone()), + tls: self.tls.clone(), + }, + workspace_settings: WorkspaceSettings::workspace(false), + safe_mode: tablepro_core::SafeModeLevel::Off, + } + } +} + +fn metadata(driver: &dyn DatabaseDriver) -> ConnectionMetadata { + ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name: "Diagram".to_owned(), + driver_id: driver.id().to_owned(), + } +} + +impl Server { + async fn start(engine: Engine) -> Self { + match engine { + Engine::Postgres => Self::postgres().await, + Engine::Mysql | Engine::Mariadb => Self::mysql(engine).await, + Engine::Mssql => Self::mssql().await, + Engine::Sqlite => Self::sqlite().await, + } + } + + /// The tables in a schema of their own, beside a table in `public` + /// whose key points into it: neither is in the diagram. + async fn postgres() -> Self { + let fixture = PostgresFixture::start(HbaMode::Password) + .await + .expect("a PostgreSQL container"); + let server = NetworkServer { + driver: Arc::new(tablepro_driver_postgres::PgDriver), + host: fixture.host().to_owned(), + port: fixture.port(), + credentials: password(&fixture.password_credentials()), + database: fixture.database().to_owned(), + tls: TlsConfig::disabled(), + }; + let mut statements = vec!["CREATE SCHEMA diagram".to_owned()]; + statements.extend(create_statements(Engine::Postgres, Some("diagram"))); + statements.push( + "CREATE TABLE public.outsider (id integer PRIMARY KEY, node_id integer REFERENCES diagram.node_00 (id))" + .to_owned(), + ); + apply(server.admin().await.as_ref(), &statements).await; + Self { + request: server.request(), + scope: Some("diagram".to_owned()), + _fixture: Box::new(fixture), + } + } + + async fn mysql(engine: Engine) -> Self { + let flavour = match engine { + Engine::Mariadb => MysqlFlavour::Mariadb, + _ => MysqlFlavour::Mysql, + }; + let fixture = MysqlFixture::start_with(MysqlOptions::new(flavour).allowing_plaintext(), None) + .await + .expect("a MySQL container"); + let server = NetworkServer { + driver: Arc::new(tablepro_driver_mysql::MysqlDriver), + host: fixture.host().to_owned(), + port: fixture.port(), + credentials: password(&fixture.password_credentials()), + database: fixture.database().to_owned(), + tls: TlsConfig::disabled(), + }; + apply(server.admin().await.as_ref(), &create_statements(engine, None)).await; + Self { + request: server.request(), + scope: Some(fixture.database().to_owned()), + _fixture: Box::new(fixture), + } + } + + async fn mssql() -> Self { + let fixture = MssqlFixture::start().await.expect("a SQL Server container"); + let server = NetworkServer { + driver: Arc::new(tablepro_driver_mssql::MssqlDriver), + host: fixture.host().to_owned(), + port: fixture.port(), + credentials: password(&fixture.password_credentials()), + database: "master".to_owned(), + tls: require(), + }; + apply( + server.admin().await.as_ref(), + &create_statements(Engine::Mssql, Some("dbo")), + ) + .await; + Self { + request: server.request(), + scope: Some("dbo".to_owned()), + _fixture: Box::new(fixture), + } + } + + /// The main schema of a new file, which the sidebar lists with no + /// schema name. + async fn sqlite() -> Self { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path: PathBuf = dir.path().join("diagram.sqlite3"); + let driver: Arc = Arc::new(tablepro_driver_sqlite::SqliteDriver); + let options = ConnectOptions { + target: ConnectTarget::File(FileEndpoint::create_new(path.clone()).expect("an absolute path")), + workspace_settings: WorkspaceSettings::workspace(false), + liveness: LivenessPolicy::DESKTOP, + environment: ConnectEnvironment::new("diagram-fill"), + }; + let admin = driver + .connect(options, CancellationToken::new()) + .await + .expect("the new file"); + apply(admin.as_ref(), &create_statements(Engine::Sqlite, None)).await; + admin.close().await.expect("the file closes"); + Self { + request: ConnectRequest { + metadata: metadata(driver.as_ref()), + driver, + target: RequestTarget::File(FileEndpoint::open_existing(path).expect("an absolute path")), + workspace_settings: WorkspaceSettings::workspace(false), + safe_mode: tablepro_core::SafeModeLevel::Off, + }, + scope: None, + _fixture: Box::new(dir), + } + } +} + +/// Run `statements` as one schema-change batch. +async fn apply(connection: &dyn Connection, statements: &[String]) { + let steps = statements + .iter() + .map(|sql| { + let mut builder = StatementBuilder::new(); + builder.push_sql(sql); + GuardedStatement::unguarded(builder.finish()) + }) + .collect(); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + TableRef::unqualified(node(0)), + steps, + ForeignKeyMode::Enforced, + ); + connection + .write_batch( + &batch, + CallOptions::new(Some(Instant::now() + CALL), CancellationToken::new()), + ) + .await + .expect("the tables are created"); +} + +async fn connect(request: &ConnectRequest, tasks: &Tasks) -> Arc { + let liveness = LivenessPolicy::DESKTOP; + let deadlines = Arc::new(DefaultDeadlines::new(liveness)); + let connector = Arc::new(DriverConnector::new( + ConnectServices { + environment: ConnectEnvironment::new("test"), + ssh: SshServices { + runtime: Arc::new(SshRuntimeCell::new(std::env::temp_dir().join("tablepro-diagram"))), + ssh_program: PathBuf::from("ssh"), + ssh_keygen_program: PathBuf::from("ssh-keygen"), + askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), + config_file: None, + }, + liveness, + }, + deadlines.clone(), + )); + let pending = connector + .establish( + request.clone(), + CredentialInteraction::Unattended, + CancellationToken::new(), + ) + .await + .unwrap_or_else(|error| panic!("the session did not connect: {error}")); + Session::start( + pending, + SessionDeps { + connector, + credentials: Arc::new(NoStoredCredentials), + deadlines, + tasks: tasks.clone(), + prompter: None, + }, + ) +} + +fn check(diagram: &SchemaDiagram, schema: Option<&str>) { + let names: Vec = diagram.tables.iter().map(|table| table.table.name.clone()).collect(); + let expected: Vec = (0..TABLES).map(node).collect(); + assert_eq!(names, expected, "the schema's tables and nothing else"); + for table in &diagram.tables { + assert_eq!(table.table.schema.as_deref(), schema, "{}", table.table.name); + let id = table.columns.first().expect("a column"); + assert!(id.name == "id" && id.primary_key, "{}: {id:?}", table.table.name); + } + assert_eq!(diagram.relations.len(), KEYS, "{:#?}", diagram.relations); + let named = |at: usize| diagram.tables[at].table.name.as_str(); + for relation in &diagram.relations { + let (from, to) = (named(relation.from), named(relation.to)); + let column = &diagram.tables[relation.from].columns[relation.from_columns[0]]; + match column.name.as_str() { + "parent_id" => assert!(relation.optional && !relation.unique, "{from} -> {to}"), + "owner_id" => { + assert_eq!(to, "node_00"); + assert!(!relation.optional && relation.unique, "{from} -> {to}"); + } + "manager_id" => assert_eq!((from, to), ("node_01", "node_01")), + other => panic!("a key on {from}.{other}"), + } + assert_eq!(diagram.tables[relation.to].columns[relation.to_columns[0]].name, "id"); + assert!(column.foreign_key); + } + let node_07 = diagram + .position_of(&TableRef::new(schema.map(str::to_owned), "node_07")) + .expect("node_07"); + assert_eq!( + diagram.neighbours(node_07), + (vec!["node_03"], vec!["node_15", "node_16"]) + ); +} + +async fn case(engine: Engine) { + let tasks = crate::test_support::paused_tasks(); + let server = Server::start(engine).await; + let session = connect(&server.request, &tasks).await; + let (progress, watching) = watch::channel(ReadProgress::default()); + + let read = tokio::time::timeout( + READ_LIMIT, + read_diagram( + session.clone(), + server.scope.clone(), + CancellationToken::new(), + progress, + ), + ) + .await + .unwrap_or_else(|_| panic!("{engine:?}: the read took longer than {READ_LIMIT:?}")); + + let diagram = read.unwrap_or_else(|failure| panic!("{engine:?}: {}", failure.message())); + check(&diagram, server.scope.as_deref()); + assert_eq!( + *watching.borrow(), + ReadProgress { + done: TABLES, + total: TABLES + } + ); + let _ = session.begin_shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn postgres_schema_of_50_tables_draws_its_keys() { + case(Engine::Postgres).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn mysql_database_of_50_tables_draws_its_keys() { + case(Engine::Mysql).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn mariadb_database_of_50_tables_draws_its_keys() { + case(Engine::Mariadb).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn sql_server_schema_of_50_tables_draws_its_keys() { + case(Engine::Mssql).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sqlite_file_of_50_tables_draws_its_keys() { + case(Engine::Sqlite).await; +} diff --git a/linux/crates/app/src/ui/diagram/export.rs b/linux/crates/app/src/ui/diagram/export.rs new file mode 100644 index 0000000000..30d487afe0 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/export.rs @@ -0,0 +1,239 @@ +//! The diagram as a PNG or SVG file. Both draw the canvas's render +//! nodes into a cairo surface, so a file shows what the screen shows, +//! whole, at 100%, whatever the zoom and scroll. + +use gtk4::gsk; + +/// A PNG has twice the pixels of the screen at 100%, so it stays sharp +/// on a high-resolution display or when printed. +const PNG_SCALE: f64 = 2.0; +/// Under cairo's own limit of 32,767 pixels a side. +const MAX_PNG_SIDE: f64 = 16_384.0; +/// About 160 MB of pixels while the file is encoded. A larger diagram's +/// PNG gets fewer pixels; its SVG has no such limit. +const MAX_PNG_PIXELS: f64 = 40_000_000.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ImageFormat { + Png, + Svg, +} + +impl ImageFormat { + pub(crate) fn from_name(name: &str) -> Option { + match name { + "png" => Some(Self::Png), + "svg" => Some(Self::Svg), + _ => None, + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Png => "png", + Self::Svg => "svg", + } + } + + pub(crate) fn mime_type(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Svg => "image/svg+xml", + } + } + + pub(crate) fn filter_name(self) -> String { + match self { + Self::Png => crate::i18n::gettext("PNG images"), + Self::Svg => crate::i18n::gettext("SVG images"), + } + } + + /// The file name the save dialog offers: the schema's, with what a + /// file name cannot hold taken out. + pub(crate) fn suggested_name(self, schema: Option<&str>) -> String { + let base: String = schema + .unwrap_or_default() + .chars() + .map(|c| { + if c == '/' || c == '\\' || c.is_control() { + '_' + } else { + c + } + }) + .collect(); + let base = base.trim_matches(|c: char| c == '.' || c.is_whitespace()); + match base.is_empty() { + true => format!("er-diagram.{}", self.name()), + false => format!("{base}-er-diagram.{}", self.name()), + } + } +} + +/// How many pixels a PNG of a `width` by `height` diagram gets per unit. +pub(crate) fn png_scale(width: f64, height: f64) -> f64 { + if width <= 0.0 || height <= 0.0 { + return PNG_SCALE; + } + PNG_SCALE + .min(MAX_PNG_SIDE / width) + .min(MAX_PNG_SIDE / height) + .min((MAX_PNG_PIXELS / (width * height)).sqrt()) +} + +/// A drawn PNG's pixels, which the encode takes off the GTK thread. +pub(crate) struct Pixels { + data: Vec, + width: i32, + height: i32, + stride: i32, +} + +/// The diagram drawn for `format`: a PNG's pixels still to encode, or an +/// SVG document. +pub(crate) enum Drawn { + Png(Pixels), + Svg(Vec), +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DrawError { + #[error("there is no diagram to draw")] + Empty, + #[error(transparent)] + Cairo(#[from] cairo::Error), + #[error("the surface was still in use")] + Borrow, + #[error("the SVG stream failed: {0}")] + Stream(String), + #[error("the PNG could not be written: {0}")] + Encode(String), +} + +/// Draw `node`, `width` by `height` units, for `format`. Runs on the GTK +/// thread, which the render nodes belong to. +pub(crate) fn draw(node: &gsk::RenderNode, width: f64, height: f64, format: ImageFormat) -> Result { + match format { + ImageFormat::Png => draw_png(node, width, height).map(Drawn::Png), + ImageFormat::Svg => draw_svg(node, width, height).map(Drawn::Svg), + } +} + +fn pixels(units: f64, scale: f64) -> i32 { + (units * scale).ceil().clamp(1.0, MAX_PNG_SIDE) as i32 +} + +fn draw_png(node: &gsk::RenderNode, width: f64, height: f64) -> Result { + let scale = png_scale(width, height); + let (pixel_width, pixel_height) = (pixels(width, scale), pixels(height, scale)); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, pixel_width, pixel_height)?; + { + let context = cairo::Context::new(&surface)?; + context.scale(scale, scale); + node.draw(&context); + context.status()?; + } + surface.flush(); + let stride = surface.stride(); + let data = surface.data().map_err(|_| DrawError::Borrow)?.to_vec(); + Ok(Pixels { + data, + width: pixel_width, + height: pixel_height, + stride, + }) +} + +fn draw_svg(node: &gsk::RenderNode, width: f64, height: f64) -> Result, DrawError> { + let mut surface = cairo::SvgSurface::for_stream(width, height, Vec::::new())?; + surface.set_document_unit(cairo::SvgUnit::Px); + { + let context = cairo::Context::new(&surface)?; + node.draw(&context); + context.status()?; + } + let stream = surface + .finish_output_stream() + .map_err(|error| DrawError::Stream(error.error.to_string()))?; + stream + .downcast::>() + .map(|bytes| *bytes) + .map_err(|_| DrawError::Stream("the stream held something else".to_owned())) +} + +/// The file's bytes. A PNG is encoded here, which for a large diagram +/// takes long enough that it runs off the GTK thread. +pub(crate) fn encode(drawn: Drawn) -> Result, DrawError> { + let pixels = match drawn { + Drawn::Svg(bytes) => return Ok(bytes), + Drawn::Png(pixels) => pixels, + }; + let surface = cairo::ImageSurface::create_for_data( + pixels.data, + cairo::Format::ARgb32, + pixels.width, + pixels.height, + pixels.stride, + )?; + let mut bytes = Vec::new(); + surface + .write_to_png(&mut bytes) + .map_err(|error| DrawError::Encode(error.to_string()))?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{gdk, graphene}; + + use super::*; + + fn square() -> gsk::RenderNode { + let snapshot = gtk4::Snapshot::new(); + snapshot.append_color( + &gdk::RGBA::new(0.2, 0.4, 0.6, 1.0), + &graphene::Rect::new(0.0, 0.0, 30.0, 20.0), + ); + snapshot.to_node().expect("a node") + } + + #[test] + fn a_png_is_twice_the_size_unless_that_is_too_big() { + assert_eq!(png_scale(1000.0, 800.0), 2.0); + let big = png_scale(20_000.0, 10_000.0); + assert!(20_000.0 * big <= MAX_PNG_SIDE + 1.0, "{big}"); + let many = png_scale(6000.0, 6000.0); + assert!(6000.0 * 6000.0 * many * many <= MAX_PNG_PIXELS * 1.001, "{many}"); + } + + #[test] + fn the_suggested_name_comes_from_the_schema() { + assert_eq!(ImageFormat::Png.suggested_name(Some("public")), "public-er-diagram.png"); + assert_eq!(ImageFormat::Svg.suggested_name(None), "er-diagram.svg"); + assert_eq!(ImageFormat::Svg.suggested_name(Some("a/b")), "a_b-er-diagram.svg"); + assert_eq!(ImageFormat::Png.suggested_name(Some("..")), "er-diagram.png"); + } + + #[gtk4::test] + fn a_png_export_is_a_png_at_twice_the_size() { + let bytes = encode(draw(&square(), 30.0, 20.0, ImageFormat::Png).expect("drawn")).expect("encoded"); + + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + let texture = gdk::Texture::from_bytes(>k4::glib::Bytes::from(&bytes)).expect("a PNG GTK reads"); + assert_eq!((texture.width(), texture.height()), (60, 40)); + } + + #[gtk4::test] + fn an_svg_export_is_an_svg_of_the_diagrams_size() { + let bytes = encode(draw(&square(), 30.0, 20.0, ImageFormat::Svg).expect("drawn")).expect("encoded"); + + let text = String::from_utf8(bytes).expect("SVG is text"); + assert!(text.contains(", + schema: Option, + cancel: CancellationToken, + progress: watch::Sender, +) -> Result { + let scope = match schema { + Some(schema) => ObjectScope::Schema(schema), + None => ObjectScope::DefaultSchema, + }; + let objects = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let scope = scope.clone(); + async move { Ok(lease.list_objects(&scope, options).await?) } + }) + .await + .map_err(|error| LoadFailure::Schema(Arc::new(error)))?; + let tables: Vec = objects + .iter() + .filter(|object| object.kind.is_structure_editable()) + .map(|object| TableRef::new(object.schema.clone(), object.name.clone())) + .collect(); + let total = tables.len(); + progress.send_replace(ReadProgress { done: 0, total }); + let mut reads = futures::stream::iter( + tables + .into_iter() + .map(|table| read_table(session.clone(), table, &cancel)), + ) + .buffer_unordered(READS_AT_ONCE); + let mut found = Vec::with_capacity(total); + while let Some(read) = reads.next().await { + found.push(read?); + progress.send_replace(ReadProgress { + done: found.len(), + total, + }); + } + Ok(SchemaDiagram::build(found)) +} + +async fn read_table( + session: Arc, + table: TableRef, + cancel: &CancellationToken, +) -> Result { + session + .run_cancellable(OperationKind::Metadata, cancel, move |lease, options| { + let table = table.clone(); + async move { + let columns = lease.fetch_columns(&table, options.clone()).await?; + let foreign_keys = lease.fetch_foreign_keys(&table, options.clone()).await?; + let indexes = lease.fetch_indexes(&table, options).await?; + Ok(TableRead { + table, + columns, + foreign_keys, + indexes, + }) + } + }) + .await + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "a table of the diagram could not be read"); + } + LoadFailure::Schema(Arc::new(error)) + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::meta::{ObjectInfo, ObjectKind}; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::super::model::tests::{column, key}; + use super::*; + use crate::test_support::{FakeSession, finish_on_main}; + + const WAIT: Duration = Duration::from_secs(10); + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("sales".to_owned()), + name: name.to_owned(), + kind, + } + } + + fn sales() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ + object("orders", ObjectKind::Table), + object("customers", ObjectKind::Table), + object("order_totals", ObjectKind::View), + ])); + script + .fetch_columns + .when_sql_contains( + "orders", + FakeReply::ok(vec![column("id", true, false), column("customer_id", false, false)]), + ) + .always(FakeReply::ok(vec![column("id", true, false)])); + script + .fetch_foreign_keys + .when_sql_contains( + "orders", + FakeReply::ok(vec![key( + "orders_customer", + &["customer_id"], + Some("sales"), + "customers", + &["id"], + )]), + ) + .always(FakeReply::ok(Vec::new())); + script.fetch_indexes.always(FakeReply::ok(Vec::new())); + script + } + + fn read(script: ConnectionScript, cancel: CancellationToken) -> (Result, ReadProgress) { + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let (progress, watching) = watch::channel(ReadProgress::default()); + let running = runtime.tasks().spawn_task(read_diagram( + fake.session.clone(), + Some("sales".to_owned()), + cancel, + progress, + )); + let outcome = finish_on_main(WAIT, running).expect("the read ends"); + let last = *watching.borrow(); + runtime.shutdown(); + (outcome.expect("the read ran to its end"), last) + } + + #[gtk4::test] + fn the_schemas_tables_are_read_with_their_keys_and_views_are_left_out() { + let (diagram, progress) = read(sales(), CancellationToken::new()); + + let diagram = diagram.expect("a diagram"); + let names: Vec<&str> = diagram.tables.iter().map(|table| table.table.name.as_str()).collect(); + assert_eq!(names, ["customers", "orders"]); + assert_eq!(diagram.relations.len(), 1); + assert_eq!(progress, ReadProgress { done: 2, total: 2 }); + } + + #[gtk4::test] + fn one_table_that_fails_fails_the_read() { + let mut script = sales(); + script.fetch_indexes = Default::default(); + script + .fetch_indexes + .when_sql_contains("customers", FakeReply::err(DriverError::server("permission denied"))) + .always(FakeReply::ok(Vec::new())); + + let (diagram, _) = read(script, CancellationToken::new()); + + let failure = diagram.expect_err("a failed read"); + assert!(failure.message().contains("permission denied"), "{}", failure.message()); + } + + #[gtk4::test] + fn stop_ends_the_read_with_nothing() { + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (diagram, _) = read(sales(), cancel); + + assert!( + matches!(diagram, Err(LoadFailure::Schema(ref error)) if **error == OperationError::Cancelled), + "{diagram:?}" + ); + } + + /// Stop reaches the server's listing too, not only the table reads. + #[gtk4::test] + fn stop_ends_a_listing_still_running_on_the_server() { + let mut script = sales(); + script.list_objects = Default::default(); + script.list_objects.always(FakeReply::UntilStopped); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let cancel = CancellationToken::new(); + let (progress, _watching) = watch::channel(ReadProgress::default()); + let running = runtime.tasks().spawn_task(read_diagram( + fake.session.clone(), + Some("sales".to_owned()), + cancel.clone(), + progress, + )); + let listed = || tablepro_test_support::CallCounters::get(&fake.connection.counters().list_objects); + crate::test_support::wait_until(WAIT, || listed() > 0).expect("the listing to start"); + + cancel.cancel(); + let outcome = finish_on_main(WAIT, running) + .expect("the read ends") + .expect("the read ran"); + + assert!( + matches!(outcome, Err(LoadFailure::Schema(ref error)) if **error == OperationError::Cancelled), + "{outcome:?}" + ); + let calls = fake.connection.observed_cancels("list_objects"); + assert!( + calls.last().is_some_and(CancellationToken::is_cancelled), + "the listing kept running" + ); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/ui/diagram/layout.rs b/linux/crates/app/src/ui/diagram/layout.rs new file mode 100644 index 0000000000..2257f76e8f --- /dev/null +++ b/linux/crates/app/src/ui/diagram/layout.rs @@ -0,0 +1,431 @@ +//! The automatic layout: each group of tables joined by foreign keys in +//! columns, the tables a key points at to the left of the tables that +//! hold it, then the groups packed in rows and the tables with no key +//! in a grid after them. + +use std::collections::{BTreeMap, VecDeque}; + +/// Room between two columns, wide enough for an edge's markers and curve. +pub(crate) const COLUMN_GAP: f64 = 96.0; +/// Room between two tables in a column. +pub(crate) const ROW_GAP: f64 = 40.0; +/// Room between two groups of tables. +pub(crate) const GROUP_GAP: f64 = 120.0; + +/// Barycenter passes over a group's columns. Four settle the order of +/// the schemas the tests use; more change little. +const ORDER_PASSES: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Size { + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Point { + pub x: f64, + pub y: f64, +} + +/// Where each table goes, by index, with the top-left corner of the +/// whole layout at the origin. `edges` are (holder, referenced) pairs. +pub(crate) fn arrange(sizes: &[Size], edges: &[(usize, usize)]) -> Vec { + let mut positions = vec![Point { x: 0.0, y: 0.0 }; sizes.len()]; + let groups = groups(sizes.len(), edges); + let total_area: f64 = sizes.iter().map(|size| size.width * size.height).sum(); + let mut blocks: Vec = Vec::new(); + let mut lone: Vec = Vec::new(); + for group in groups { + match group.as_slice() { + [single] => lone.push(*single), + _ => blocks.push(layered(&group, sizes, edges)), + } + } + if !lone.is_empty() { + blocks.push(grid(&lone, sizes, (total_area.sqrt() * 1.6).max(1.0))); + } + let row_width = blocks + .iter() + .map(|block| block.width) + .fold((total_area.sqrt() * 1.6).max(1.0), f64::max); + let (mut x, mut y, mut row_height) = (0.0_f64, 0.0_f64, 0.0_f64); + for block in blocks { + if x > 0.0 && x + block.width > row_width { + x = 0.0; + y += row_height + GROUP_GAP; + row_height = 0.0; + } + for (table, at) in block.places { + if let Some(position) = positions.get_mut(table) { + *position = Point { + x: x + at.x, + y: y + at.y, + }; + } + } + x += block.width + GROUP_GAP; + row_height = row_height.max(block.height); + } + positions +} + +/// Tables placed relative to their group's corner. +struct Block { + places: Vec<(usize, Point)>, + width: f64, + height: f64, +} + +/// The tables joined by any chain of keys, each group in index order, +/// the groups in order of their first table. +fn groups(count: usize, edges: &[(usize, usize)]) -> Vec> { + let mut parent: Vec = (0..count).collect(); + fn root(parent: &mut [usize], mut at: usize) -> usize { + while parent[at] != at { + parent[at] = parent[parent[at]]; + at = parent[at]; + } + at + } + for &(from, to) in edges { + if from >= count || to >= count { + continue; + } + let (a, b) = (root(&mut parent, from), root(&mut parent, to)); + if a != b { + parent[a.max(b)] = a.min(b); + } + } + let mut groups: BTreeMap> = BTreeMap::new(); + for table in 0..count { + let group = root(&mut parent, table); + groups.entry(group).or_default().push(table); + } + let mut groups: Vec> = groups.into_values().collect(); + // The largest group first, where the eye lands. + groups.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.first().cmp(&b.first()))); + groups +} + +/// A group in columns by how far each table is from one that references +/// nothing, ordered so keys cross as little as a few passes manage. +fn layered(group: &[usize], sizes: &[Size], edges: &[(usize, usize)]) -> Block { + let local: BTreeMap = group.iter().enumerate().map(|(at, &table)| (table, at)).collect(); + let links: Vec<(usize, usize)> = edges + .iter() + .filter(|(from, to)| from != to) + .filter_map(|(from, to)| Some((*local.get(from)?, *local.get(to)?))) + .collect(); + let layers = layers_of(group.len(), &links); + let mut columns: Vec> = Vec::new(); + for (node, &layer) in layers.iter().enumerate() { + if columns.len() <= layer { + columns.resize_with(layer + 1, Vec::new); + } + columns[layer].push(node); + } + order(&mut columns, &links); + let size_of = |node: usize| { + sizes.get(group[node]).copied().unwrap_or(Size { + width: 0.0, + height: 0.0, + }) + }; + let area: f64 = (0..group.len()) + .map(|node| size_of(node).width * size_of(node).height) + .sum(); + let tallest = (0..group.len()).map(|node| size_of(node).height).fold(0.0, f64::max); + let limit = (area.sqrt() * 1.2).max(tallest); + let columns = wrap(columns, limit, &size_of); + let heights: Vec = columns.iter().map(|column| stacked_height(column, &size_of)).collect(); + let height = heights.iter().copied().fold(0.0, f64::max); + let mut places = Vec::with_capacity(group.len()); + let mut x = 0.0; + for (column, column_height) in columns.iter().zip(&heights) { + let width = column.iter().map(|&node| size_of(node).width).fold(0.0, f64::max); + let mut y = (height - column_height) / 2.0; + for &node in column { + places.push((group[node], Point { x, y })); + y += size_of(node).height + ROW_GAP; + } + x += width + COLUMN_GAP; + } + Block { + places, + width: (x - COLUMN_GAP).max(0.0), + height, + } +} + +fn stacked_height(column: &[usize], size_of: &impl Fn(usize) -> Size) -> f64 { + let tables: f64 = column.iter().map(|&node| size_of(node).height).sum(); + tables + ROW_GAP * column.len().saturating_sub(1) as f64 +} + +/// Each node's column: 0 for a node whose keys point nowhere in the +/// group, else one past the furthest table it points at. A cycle of keys +/// is broken at the key that closes it, found by a walk in index order. +fn layers_of(count: usize, links: &[(usize, usize)]) -> Vec { + let mut out: Vec> = vec![Vec::new(); count]; + for &(from, to) in links { + if let Some(targets) = out.get_mut(from) + && !targets.contains(&to) + { + targets.push(to); + } + } + let acyclic = without_cycles(&out); + // Kahn's order from the referenced side: a node's layer is known once + // every table it points at has one. + let mut pending: Vec = acyclic.iter().map(Vec::len).collect(); + let mut incoming: Vec> = vec![Vec::new(); count]; + for (from, targets) in acyclic.iter().enumerate() { + for &to in targets { + incoming[to].push(from); + } + } + let mut layer = vec![0usize; count]; + let mut ready: VecDeque = (0..count).filter(|&node| pending[node] == 0).collect(); + while let Some(node) = ready.pop_front() { + for &holder in &incoming[node] { + layer[holder] = layer[holder].max(layer[node] + 1); + pending[holder] -= 1; + if pending[holder] == 0 { + ready.push_back(holder); + } + } + } + layer +} + +/// `out` with each key that closes a cycle taken out, walking from each +/// node in index order. Iterative, so a long chain of keys cannot +/// overflow the stack. +fn without_cycles(out: &[Vec]) -> Vec> { + #[derive(Clone, Copy, PartialEq)] + enum Mark { + New, + Open, + Done, + } + let mut marks = vec![Mark::New; out.len()]; + let mut kept: Vec> = vec![Vec::new(); out.len()]; + for start in 0..out.len() { + if marks[start] != Mark::New { + continue; + } + let mut stack: Vec<(usize, usize)> = vec![(start, 0)]; + marks[start] = Mark::Open; + while let Some((node, next)) = stack.pop() { + let Some(&to) = out[node].get(next) else { + marks[node] = Mark::Done; + continue; + }; + stack.push((node, next + 1)); + match marks[to] { + Mark::Open => {} + Mark::Done => kept[node].push(to), + Mark::New => { + kept[node].push(to); + marks[to] = Mark::Open; + stack.push((to, 0)); + } + } + } + } + kept +} + +/// Barycenter ordering: each column sorted by the mean place of the +/// tables it is joined to in the other columns, sweeping right then +/// left. Ties keep their order, so the result is the same every time. +fn order(columns: &mut [Vec], links: &[(usize, usize)]) { + let count = columns.iter().map(Vec::len).sum::(); + let mut neighbours: Vec> = vec![Vec::new(); count]; + for &(from, to) in links { + if from < count && to < count { + neighbours[from].push(to); + neighbours[to].push(from); + } + } + let mut place = vec![0.0_f64; count]; + let mark = |columns: &[Vec], place: &mut [f64]| { + for column in columns { + let len = column.len().max(1) as f64; + for (at, &node) in column.iter().enumerate() { + place[node] = (at as f64 + 0.5) / len; + } + } + }; + mark(columns, &mut place); + for pass in 0..ORDER_PASSES { + let sweep: Vec = match pass % 2 { + 0 => (1..columns.len()).collect(), + _ => (0..columns.len().saturating_sub(1)).rev().collect(), + }; + for at in sweep { + let column = &mut columns[at]; + let mut keyed: Vec<(f64, usize)> = column + .iter() + .map(|&node| { + let others = &neighbours[node]; + let center = if others.is_empty() { + place[node] + } else { + others.iter().map(|&other| place[other]).sum::() / others.len() as f64 + }; + (center, node) + }) + .collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + *column = keyed.into_iter().map(|(_, node)| node).collect(); + mark(std::slice::from_ref(column), &mut place); + } + } +} + +/// A column taller than `limit` goes on in a column beside it, so one +/// table that many others point at does not make a tower. +fn wrap(columns: Vec>, limit: f64, size_of: &impl Fn(usize) -> Size) -> Vec> { + let mut wrapped = Vec::with_capacity(columns.len()); + for column in columns { + let mut current: Vec = Vec::new(); + let mut height = 0.0; + for node in column { + let next = size_of(node).height; + if !current.is_empty() && height + next > limit { + wrapped.push(std::mem::take(&mut current)); + height = 0.0; + } + height += next + ROW_GAP; + current.push(node); + } + if !current.is_empty() { + wrapped.push(current); + } + } + wrapped +} + +/// Tables with no key in rows, in index order, each row as wide as +/// `width` allows. +fn grid(tables: &[usize], sizes: &[Size], width: f64) -> Block { + let mut places = Vec::with_capacity(tables.len()); + let (mut x, mut y, mut row_height, mut widest) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64); + for &table in tables { + let size = sizes.get(table).copied().unwrap_or(Size { + width: 0.0, + height: 0.0, + }); + if x > 0.0 && x + size.width > width { + x = 0.0; + y += row_height + ROW_GAP; + row_height = 0.0; + } + places.push((table, Point { x, y })); + x += size.width + COLUMN_GAP; + widest = widest.max(x - COLUMN_GAP); + row_height = row_height.max(size.height); + } + Block { + places, + width: widest, + height: y + row_height, + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::*; + + fn sizes(count: usize) -> Vec { + (0..count) + .map(|at| Size { + width: 160.0 + (at % 5) as f64 * 20.0, + height: 60.0 + (at % 7) as f64 * 25.0, + }) + .collect() + } + + fn overlapping(sizes: &[Size], positions: &[Point]) -> Option<(usize, usize)> { + for a in 0..sizes.len() { + for b in (a + 1)..sizes.len() { + let (pa, pb, sa, sb) = (positions[a], positions[b], sizes[a], sizes[b]); + let apart = pa.x + sa.width <= pb.x + || pb.x + sb.width <= pa.x + || pa.y + sa.height <= pb.y + || pb.y + sb.height <= pa.y; + if !apart { + return Some((a, b)); + } + } + } + None + } + + #[test] + fn a_referenced_table_sits_left_of_the_tables_that_hold_its_key() { + let sizes = sizes(3); + // orders -> customers, items -> orders. + let positions = arrange(&sizes, &[(1, 0), (2, 1)]); + + assert!(positions[0].x + sizes[0].width <= positions[1].x, "{positions:?}"); + assert!(positions[1].x + sizes[1].width <= positions[2].x, "{positions:?}"); + } + + #[test] + fn no_two_tables_overlap() { + let sizes = sizes(60); + let edges: Vec<(usize, usize)> = (1..40).map(|at| (at, at / 3)).chain([(45, 46), (46, 45)]).collect(); + + let positions = arrange(&sizes, &edges); + + assert_eq!(overlapping(&sizes, &positions), None); + assert!(positions.iter().all(|point| point.x >= 0.0 && point.y >= 0.0)); + } + + #[test] + fn cycles_and_self_references_are_laid_out() { + let sizes = sizes(4); + + let positions = arrange(&sizes, &[(0, 1), (1, 2), (2, 0), (3, 3), (3, 0)]); + + assert_eq!(overlapping(&sizes, &positions), None); + } + + #[test] + fn the_same_schema_lays_out_the_same_way() { + let sizes = sizes(30); + let edges: Vec<(usize, usize)> = (1..30).map(|at| (at, (at * 7) % at.max(1))).collect(); + + assert_eq!(arrange(&sizes, &edges), arrange(&sizes, &edges)); + } + + #[test] + fn a_table_many_point_at_does_not_make_a_tower() { + let sizes = sizes(61); + let edges: Vec<(usize, usize)> = (1..61).map(|at| (at, 0)).collect(); + + let positions = arrange(&sizes, &edges); + + let height = (0..61).map(|at| positions[at].y + sizes[at].height).fold(0.0, f64::max); + let width = (0..61).map(|at| positions[at].x + sizes[at].width).fold(0.0, f64::max); + assert!(height < width * 3.0, "{width} wide, {height} tall"); + assert_eq!(overlapping(&sizes, &positions), None); + } + + #[test] + fn five_hundred_tables_lay_out_quickly() { + let sizes = sizes(500); + let edges: Vec<(usize, usize)> = (1..500).filter(|at| at % 4 != 0).map(|at| (at, at / 2)).collect(); + + let started = Instant::now(); + let positions = arrange(&sizes, &edges); + + assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed()); + assert_eq!(positions.len(), 500); + } +} diff --git a/linux/crates/app/src/ui/diagram/mod.rs b/linux/crates/app/src/ui/diagram/mod.rs new file mode 100644 index 0000000000..3be39533cb --- /dev/null +++ b/linux/crates/app/src/ui/diagram/mod.rs @@ -0,0 +1,31 @@ +//! A schema's entity-relationship diagram: each table as a card of its +//! columns, each foreign key as a line between the rows it joins. +//! +//! The tab reads the schema's tables in the background with Stop, lays +//! them out by their keys, and draws them on a canvas that pans, zooms, +//! moves cards and exports PNG and SVG. A focusable widget stands where +//! each card is drawn, so the keyboard and a screen reader move from +//! table to table and hear each one's columns and the tables it joins. + +mod canvas; +mod canvas_imp; +mod canvas_input; +mod card_text; +mod describe; +mod draw; +#[cfg(test)] +mod engines; +mod export; +mod fetch; +mod layout; +mod model; +mod node; +mod palette; +mod scene; +mod tab; +mod tab_imp; +#[cfg(test)] +mod tests; +mod zoom; + +pub(crate) use self::tab::{DiagramTab, DiagramTabInit}; diff --git a/linux/crates/app/src/ui/diagram/model.rs b/linux/crates/app/src/ui/diagram/model.rs new file mode 100644 index 0000000000..b4510c8546 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/model.rs @@ -0,0 +1,428 @@ +use std::collections::{BTreeSet, HashMap}; + +use tablepro_core::meta::{IndexInfo, IndexKey, TableRef}; +use tablepro_core::{ColumnInfo, ForeignKeyInfo}; + +/// One table as the read brought it back: its columns, its own foreign +/// keys and its indexes, from one connection lease. +#[derive(Debug, Clone)] +pub(crate) struct TableRead { + pub table: TableRef, + pub columns: Vec, + pub foreign_keys: Vec, + pub indexes: Vec, +} + +/// A schema's tables and the foreign keys between them. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct SchemaDiagram { + /// Sorted by name, so the same schema always draws the same way. + pub tables: Vec, + pub relations: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DiagramTable { + pub table: TableRef, + pub columns: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DiagramColumn { + pub name: String, + pub type_name: String, + pub primary_key: bool, + pub foreign_key: bool, + pub nullable: bool, +} + +/// A foreign key from `from` to `to`, both indexes into `tables`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiagramRelation { + pub name: String, + pub from: usize, + pub from_columns: Vec, + pub to: usize, + pub to_columns: Vec, + /// A key column takes NULL, so a row need not point anywhere. + pub optional: bool, + /// The key columns are the table's primary key or a unique index, so + /// at most one row points at each referenced row. + pub unique: bool, +} + +impl SchemaDiagram { + /// The diagram of what the read found. A table with no column the + /// account can read was dropped after the list was read, so it is + /// left out. A key to a table outside the read is not drawn: the + /// diagram shows one schema. + pub(crate) fn build(mut reads: Vec) -> Self { + reads.retain(|read| !read.columns.is_empty()); + reads.sort_by(|a, b| { + a.table + .name + .cmp(&b.table.name) + .then_with(|| a.table.schema.cmp(&b.table.schema)) + }); + let index: HashMap<&TableRef, usize> = reads.iter().enumerate().map(|(at, read)| (&read.table, at)).collect(); + let mut relations = Vec::new(); + for (from, read) in reads.iter().enumerate() { + for key in &read.foreign_keys { + let target = TableRef::new( + key.ref_schema.clone().or_else(|| read.table.schema.clone()), + key.ref_table.clone(), + ); + let Some(&to) = index.get(&target) else { + continue; + }; + let Some(referenced) = reads.get(to) else { + continue; + }; + relations.push(DiagramRelation { + name: key.name.clone(), + from, + from_columns: positions(&read.columns, &key.columns), + to, + to_columns: positions(&referenced.columns, &key.ref_columns), + optional: key.columns.iter().any(|name| { + read.columns + .iter() + .any(|column| &column.name == name && column.nullable) + }), + unique: is_unique(read, &key.columns), + }); + } + } + let tables = reads + .into_iter() + .map(|read| { + let keyed: BTreeSet<&str> = read + .foreign_keys + .iter() + .flat_map(|key| key.columns.iter().map(String::as_str)) + .collect(); + let columns = read + .columns + .iter() + .map(|column| DiagramColumn { + name: column.name.clone(), + type_name: column.column_type.name.clone(), + primary_key: column.primary_key, + foreign_key: keyed.contains(column.name.as_str()), + nullable: column.nullable, + }) + .collect(); + DiagramTable { + table: read.table, + columns, + } + }) + .collect(); + Self { tables, relations } + } + + /// The tables `table` points at and the tables that point at it, each + /// once, by name. + pub(crate) fn neighbours(&self, table: usize) -> (Vec<&str>, Vec<&str>) { + let names = |indexes: BTreeSet| -> Vec<&str> { + let mut names: Vec<&str> = indexes + .into_iter() + .filter_map(|at| self.tables.get(at)) + .map(|found| found.table.name.as_str()) + .collect(); + names.sort_unstable(); + names + }; + let references = self + .relations + .iter() + .filter(|relation| relation.from == table && relation.to != table) + .map(|relation| relation.to) + .collect(); + let referenced_by = self + .relations + .iter() + .filter(|relation| relation.to == table && relation.from != table) + .map(|relation| relation.from) + .collect(); + (names(references), names(referenced_by)) + } + + /// Where `table` is, by schema and name. + pub(crate) fn position_of(&self, table: &TableRef) -> Option { + self.tables.iter().position(|found| &found.table == table) + } +} + +fn positions(columns: &[ColumnInfo], names: &[String]) -> Vec { + names + .iter() + .filter_map(|name| columns.iter().position(|column| &column.name == name)) + .collect() +} + +/// Whether no two rows can hold the same key: its columns take in the +/// primary key, or the columns of a unique index that covers every row. +fn is_unique(read: &TableRead, key: &[String]) -> bool { + let key: BTreeSet<&str> = key.iter().map(String::as_str).collect(); + if key.is_empty() { + return false; + } + let primary: BTreeSet<&str> = read + .columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect(); + if !primary.is_empty() && primary.is_subset(&key) { + return true; + } + read.indexes.iter().any(|index| { + if !index.unique || index.predicate.is_some() { + return false; + } + let mut columns = BTreeSet::new(); + for part in &index.keys { + match part { + IndexKey::Column { name, .. } => { + columns.insert(name.as_str()); + } + IndexKey::Expression { .. } => return false, + } + } + !columns.is_empty() && columns.is_subset(&key) + }) +} + +#[cfg(test)] +pub(crate) mod tests { + use tablepro_core::column::ColumnType; + + use super::*; + + pub(crate) fn column(name: &str, primary_key: bool, nullable: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("integer"), + nullable, + primary_key, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } + } + + pub(crate) fn key( + name: &str, + columns: &[&str], + schema: Option<&str>, + table: &str, + refs: &[&str], + ) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: columns.iter().map(|&column| column.to_owned()).collect(), + ref_schema: schema.map(str::to_owned), + ref_table: table.to_owned(), + ref_columns: refs.iter().map(|&column| column.to_owned()).collect(), + on_delete: None, + on_update: None, + } + } + + pub(crate) fn read(name: &str, columns: Vec, foreign_keys: Vec) -> TableRead { + TableRead { + table: TableRef::new(Some("public".to_owned()), name), + columns, + foreign_keys, + indexes: Vec::new(), + } + } + + fn shop() -> Vec { + vec![ + read( + "orders", + vec![column("id", true, false), column("customer_id", false, true)], + vec![key( + "orders_customer", + &["customer_id"], + Some("public"), + "customers", + &["id"], + )], + ), + read("customers", vec![column("id", true, false)], Vec::new()), + read( + "audit", + vec![column("id", true, false), column("order_id", false, false)], + vec![ + key("audit_order", &["order_id"], None, "orders", &["id"]), + key("audit_user", &["order_id"], Some("auth"), "users", &["id"]), + ], + ), + ] + } + + #[test] + fn tables_sort_by_name_and_keys_resolve_to_them() { + let diagram = SchemaDiagram::build(shop()); + + let names: Vec<&str> = diagram.tables.iter().map(|table| table.table.name.as_str()).collect(); + assert_eq!(names, ["audit", "customers", "orders"]); + assert_eq!(diagram.relations.len(), 2, "{:?}", diagram.relations); + let orders = &diagram.relations[1]; + assert_eq!((orders.from, orders.to), (2, 1)); + assert_eq!(orders.from_columns, [1]); + assert_eq!(orders.to_columns, [0]); + assert!(orders.optional); + assert!(!orders.unique); + } + + #[test] + fn a_key_to_another_schema_is_not_drawn_but_its_column_is_marked() { + let diagram = SchemaDiagram::build(shop()); + + assert!( + diagram.relations.iter().all(|relation| relation.name != "audit_user"), + "a key to auth.users was drawn" + ); + let audit = &diagram.tables[0]; + assert!(audit.columns[1].foreign_key); + assert!(!audit.columns[0].foreign_key); + assert!(audit.columns[0].primary_key); + } + + #[test] + fn a_key_without_a_schema_points_into_the_tables_own_schema() { + let diagram = SchemaDiagram::build(shop()); + + let audit = diagram + .relations + .iter() + .find(|relation| relation.name == "audit_order") + .expect("the key to orders"); + assert_eq!(diagram.tables[audit.to].table.name, "orders"); + assert!(!audit.optional, "order_id is NOT NULL"); + } + + #[test] + fn a_table_dropped_since_the_list_was_read_is_left_out() { + let mut reads = shop(); + reads.push(read("gone", Vec::new(), Vec::new())); + + let diagram = SchemaDiagram::build(reads); + + assert!(diagram.tables.iter().all(|table| table.table.name != "gone")); + } + + #[test] + fn a_key_on_the_primary_key_or_a_unique_index_is_one_to_one() { + let profile = read( + "profiles", + vec![column("user_id", true, false)], + vec![key("profile_user", &["user_id"], None, "users", &["id"])], + ); + let mut badge = read( + "badges", + vec![column("id", true, false), column("user_id", false, false)], + vec![key("badge_user", &["user_id"], None, "users", &["id"])], + ); + badge.indexes.push(IndexInfo { + name: "badges_user".to_owned(), + keys: vec![IndexKey::Column { + name: "user_id".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: None, + }); + let mut partial = badge.clone(); + partial.table = TableRef::new(Some("public".to_owned()), "partial"); + partial.indexes[0].predicate = Some("user_id > 0".to_owned()); + let users = read("users", vec![column("id", true, false)], Vec::new()); + + let diagram = SchemaDiagram::build(vec![profile, badge, partial, users]); + + let unique = |name: &str| { + diagram + .relations + .iter() + .find(|relation| diagram.tables[relation.from].table.name == name) + .map(|relation| relation.unique) + }; + assert_eq!(unique("profiles"), Some(true)); + assert_eq!(unique("badges"), Some(true)); + assert_eq!(unique("partial"), Some(false), "a partial index leaves rows out"); + } + + #[test] + fn a_composite_key_holding_a_unique_column_is_one_to_one() { + let mut seats = read( + "seats", + vec![ + column("id", true, false), + column("tenant_id", false, false), + column("user_id", false, false), + ], + vec![key( + "seat_user", + &["tenant_id", "user_id"], + None, + "members", + &["tenant_id", "id"], + )], + ); + seats.indexes.push(IndexInfo { + name: "seats_user".to_owned(), + keys: vec![IndexKey::Column { + name: "user_id".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: None, + }); + let mut shared = seats.clone(); + shared.table = TableRef::new(Some("public".to_owned()), "shared"); + shared.indexes.clear(); + let members = read( + "members", + vec![column("tenant_id", true, false), column("id", true, false)], + Vec::new(), + ); + + let diagram = SchemaDiagram::build(vec![seats, shared, members]); + + let unique: Vec<(&str, bool)> = diagram + .relations + .iter() + .map(|relation| (diagram.tables[relation.from].table.name.as_str(), relation.unique)) + .collect(); + assert_eq!(unique, [("seats", true), ("shared", false)]); + } + + #[test] + fn a_self_reference_is_a_relation_but_not_a_neighbour() { + let employees = read( + "employees", + vec![column("id", true, false), column("manager_id", false, true)], + vec![key("employee_manager", &["manager_id"], None, "employees", &["id"])], + ); + + let diagram = SchemaDiagram::build(vec![employees]); + + assert_eq!(diagram.relations.len(), 1); + assert_eq!(diagram.neighbours(0), (Vec::new(), Vec::new())); + } + + #[test] + fn neighbours_name_each_table_once() { + let diagram = SchemaDiagram::build(shop()); + + assert_eq!(diagram.neighbours(2), (vec!["customers"], vec!["audit"])); + assert_eq!(diagram.neighbours(1), (Vec::new(), vec!["orders"])); + } +} diff --git a/linux/crates/app/src/ui/diagram/node.rs b/linux/crates/app/src/ui/diagram/node.rs new file mode 100644 index 0000000000..0c7c1720b7 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/node.rs @@ -0,0 +1,78 @@ +use std::cell::Cell; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct DiagramNode { + pub(super) index: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for DiagramNode { + const NAME: &'static str = "TableProDiagramNode"; + type Type = super::DiagramNode; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_css_name("tp-diagram-node"); + klass.set_accessible_role(gtk::AccessibleRole::ListItem); + } + } + + impl ObjectImpl for DiagramNode { + fn constructed(&self) { + self.parent_constructed(); + let node = self.obj(); + node.set_focusable(true); + // The canvas reads the pointer itself, so a press lands on it + // whatever card is under the pointer. + node.set_can_target(false); + } + } + + impl WidgetImpl for DiagramNode {} +} + +glib::wrapper! { + /// One table of the diagram as the keyboard and a screen reader meet + /// it. The canvas draws the card; this widget sits where the card is, + /// takes the focus, and carries the table's name and description, so + /// Tab, the arrow keys and Orca move from table to table the way they + /// move between any widgets. + pub struct DiagramNode(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl DiagramNode { + pub(crate) fn new(index: usize, label: &str, description: &str, count: usize) -> Self { + let node: Self = glib::Object::new(); + node.imp().index.set(index); + node.update_property(&[ + gtk::accessible::Property::Label(label), + gtk::accessible::Property::Description(description), + ]); + let position = i32::try_from(index + 1).unwrap_or(i32::MAX); + let size = i32::try_from(count).unwrap_or(i32::MAX); + node.update_relation(&[ + gtk::accessible::Relation::PosInSet(position), + gtk::accessible::Relation::SetSize(size), + ]); + node.set_selected(false); + node + } + + pub(crate) fn index(&self) -> usize { + self.imp().index.get() + } + + pub(crate) fn set_selected(&self, selected: bool) { + self.update_state(&[gtk::accessible::State::Selected(Some(selected))]); + } +} diff --git a/linux/crates/app/src/ui/diagram/palette.rs b/linux/crates/app/src/ui/diagram/palette.rs new file mode 100644 index 0000000000..110b36d7bf --- /dev/null +++ b/linux/crates/app/src/ui/diagram/palette.rs @@ -0,0 +1,106 @@ +use gtk4::gdk; + +/// The colours the diagram draws with, all from the style the window +/// uses, so light, dark, high contrast and the accent colour follow the +/// desktop. Cards are opaque, so a line that runs under one is hidden. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Palette { + pub background: gdk::RGBA, + pub card: gdk::RGBA, + pub header: gdk::RGBA, + pub border: gdk::RGBA, + pub text: gdk::RGBA, + pub dim_text: gdk::RGBA, + pub line: gdk::RGBA, + pub accent: gdk::RGBA, + /// Behind the rows of the selected table's keys. + pub highlight: gdk::RGBA, +} + +/// What the theme gives the diagram, before it is made opaque. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct ThemeColors { + pub window_bg: gdk::RGBA, + pub card_bg: gdk::RGBA, + pub card_fg: gdk::RGBA, + pub border: gdk::RGBA, + pub accent: gdk::RGBA, +} + +/// libadwaita dims secondary text to this opacity (`.dimmed`). +const DIMMED: f32 = 0.55; + +impl Palette { + pub(crate) fn from_theme(theme: ThemeColors) -> Self { + let background = opaque(theme.window_bg); + let card = over(theme.card_bg, background); + Self { + background, + card, + header: over(with_alpha(theme.card_fg, 0.06), card), + border: theme.border, + text: theme.card_fg, + dim_text: with_alpha(theme.card_fg, theme.card_fg.alpha() * DIMMED), + line: with_alpha(theme.card_fg, theme.card_fg.alpha() * DIMMED), + accent: theme.accent, + highlight: over(with_alpha(theme.accent, 0.16), card), + } + } +} + +fn with_alpha(color: gdk::RGBA, alpha: f32) -> gdk::RGBA { + gdk::RGBA::new(color.red(), color.green(), color.blue(), alpha.clamp(0.0, 1.0)) +} + +fn opaque(color: gdk::RGBA) -> gdk::RGBA { + with_alpha(color, 1.0) +} + +/// `top` painted over the opaque `bottom`. +pub(crate) fn over(top: gdk::RGBA, bottom: gdk::RGBA) -> gdk::RGBA { + let alpha = top.alpha().clamp(0.0, 1.0); + let mix = |a: f32, b: f32| a * alpha + b * (1.0 - alpha); + gdk::RGBA::new( + mix(top.red(), bottom.red()), + mix(top.green(), bottom.green()), + mix(top.blue(), bottom.blue()), + 1.0, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rgba(red: f32, green: f32, blue: f32, alpha: f32) -> gdk::RGBA { + gdk::RGBA::new(red, green, blue, alpha) + } + + #[test] + fn a_translucent_card_is_made_opaque_over_the_window() { + let card = over(rgba(1.0, 1.0, 1.0, 0.5), rgba(0.0, 0.0, 0.0, 1.0)); + + assert_eq!(card, rgba(0.5, 0.5, 0.5, 1.0)); + } + + #[test] + fn every_fill_is_opaque_and_text_keeps_the_theme_colour() { + let palette = Palette::from_theme(ThemeColors { + window_bg: rgba(0.13, 0.13, 0.15, 1.0), + card_bg: rgba(1.0, 1.0, 1.0, 0.08), + card_fg: rgba(1.0, 1.0, 1.0, 1.0), + border: rgba(1.0, 1.0, 1.0, 0.15), + accent: rgba(0.47, 0.68, 0.93, 1.0), + }); + + for fill in [palette.background, palette.card, palette.header, palette.highlight] { + assert_eq!(fill.alpha(), 1.0, "{fill:?}"); + } + assert_eq!(palette.text, rgba(1.0, 1.0, 1.0, 1.0)); + assert!( + palette.card.red() > palette.background.red(), + "a card stands out from the window" + ); + assert!((palette.dim_text.alpha() - DIMMED).abs() < f32::EPSILON); + } +} diff --git a/linux/crates/app/src/ui/diagram/scene.rs b/linux/crates/app/src/ui/diagram/scene.rs new file mode 100644 index 0000000000..e91e336307 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/scene.rs @@ -0,0 +1,338 @@ +//! Where each table's card and each key's line are, in diagram units. +//! At 100% one unit is one logical pixel. + +use super::layout::{Point, Size}; + +/// The straight part of a line at each card, where its markers go. +pub(crate) const STUB: f64 = 18.0; +/// How far a line's curve leaves a card before it turns. +const BEND: f64 = 48.0; +/// The empty margin around the diagram, on screen and in an export. +pub(crate) const MARGIN: f64 = 40.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl Rect { + pub(crate) fn right(&self) -> f64 { + self.x + self.width + } + + pub(crate) fn bottom(&self) -> f64 { + self.y + self.height + } + + pub(crate) fn contains(&self, point: Point) -> bool { + point.x >= self.x && point.x < self.right() && point.y >= self.y && point.y < self.bottom() + } + + pub(crate) fn intersects(&self, other: &Rect) -> bool { + self.x < other.right() && other.x < self.right() && self.y < other.bottom() && other.y < self.bottom() + } + + fn around(points: &[Point]) -> Rect { + let (mut left, mut top) = (f64::INFINITY, f64::INFINITY); + let (mut right, mut bottom) = (f64::NEG_INFINITY, f64::NEG_INFINITY); + for point in points { + left = left.min(point.x); + top = top.min(point.y); + right = right.max(point.x); + bottom = bottom.max(point.y); + } + Rect { + x: left, + y: top, + width: (right - left).max(0.0), + height: (bottom - top).max(0.0), + } + } +} + +/// A card's shape: its header and the height of each column's row. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct CardShape { + pub width: f64, + pub header_height: f64, + pub row_height: f64, + pub rows: usize, + /// Below the last row, so it does not touch the card's edge. + pub footer: f64, +} + +impl CardShape { + pub(crate) fn size(&self) -> Size { + Size { + width: self.width, + height: self.header_height + self.row_height * self.rows as f64 + self.footer, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Card { + pub at: Point, + pub shape: CardShape, +} + +impl Card { + pub(crate) fn rect(&self) -> Rect { + let size = self.shape.size(); + Rect { + x: self.at.x, + y: self.at.y, + width: size.width, + height: size.height, + } + } + + pub(crate) fn row_rect(&self, row: usize) -> Rect { + Rect { + x: self.at.x, + y: self.at.y + self.shape.header_height + self.shape.row_height * row as f64, + width: self.shape.width, + height: self.shape.row_height, + } + } + + /// Where a line meets the card: the middle of the first of `rows`, + /// or of the header when the key names no column the card shows. + fn anchor_y(&self, rows: &[usize]) -> f64 { + match rows.first() { + Some(&row) if row < self.shape.rows => { + let rect = self.row_rect(row); + rect.y + rect.height / 2.0 + } + _ => self.at.y + self.shape.header_height / 2.0, + } + } +} + +/// A key's line: straight out of each card for its markers, and a curve +/// between. `start` is at the card that holds the key. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct EdgePath { + pub start: Point, + pub start_stub: Point, + pub control_start: Point, + pub control_end: Point, + pub end_stub: Point, + pub end: Point, + /// +1 when the line leaves `start` to the right, -1 to the left. + pub start_dir: f64, + pub end_dir: f64, +} + +impl EdgePath { + pub(crate) fn bounds(&self) -> Rect { + Rect::around(&[ + self.start, + self.start_stub, + self.control_start, + self.control_end, + self.end_stub, + self.end, + ]) + } +} + +/// The line from the holder's `from_rows` to the referenced card's +/// `to_rows`. Cards side by side join their facing edges; cards above +/// one another, and a card that points at itself, join on the right. +pub(crate) fn route(from: &Card, from_rows: &[usize], to: &Card, to_rows: &[usize]) -> EdgePath { + let (a, b) = (from.rect(), to.rect()); + let (start_y, end_y) = (from.anchor_y(from_rows), to.anchor_y(to_rows)); + let (start_dir, end_dir) = if b.right() + 2.0 * STUB <= a.x { + (-1.0, 1.0) + } else if a.right() + 2.0 * STUB <= b.x { + (1.0, -1.0) + } else { + (1.0, 1.0) + }; + let start = Point { + x: if start_dir > 0.0 { a.right() } else { a.x }, + y: start_y, + }; + let end = Point { + x: if end_dir > 0.0 { b.right() } else { b.x }, + y: end_y, + }; + let start_stub = Point { + x: start.x + start_dir * STUB, + y: start.y, + }; + let end_stub = Point { + x: end.x + end_dir * STUB, + y: end.y, + }; + let (control_start, control_end) = if start_dir == end_dir { + // Out past the wider of the two cards, then back. + let outer = start_stub.x.max(end_stub.x) + BEND; + // A key on the row it points at would close on itself. + let lift = match (start.y - end.y).abs() < from.shape.row_height / 2.0 { + true => from.shape.row_height, + false => 0.0, + }; + ( + Point { + x: outer, + y: start_stub.y - lift, + }, + Point { + x: outer, + y: end_stub.y + lift, + }, + ) + } else { + let reach = BEND.max((end_stub.x - start_stub.x).abs() / 2.0); + ( + Point { + x: start_stub.x + start_dir * reach, + y: start_stub.y, + }, + Point { + x: end_stub.x + end_dir * reach, + y: end_stub.y, + }, + ) + }; + EdgePath { + start, + start_stub, + control_start, + control_end, + end_stub, + end, + start_dir, + end_dir, + } +} + +/// Everything the diagram covers, with `MARGIN` round it, or `None` +/// with nothing in it. +pub(crate) fn extent(cards: &[Card], edges: &[EdgePath]) -> Option { + let mut corners: Vec = Vec::with_capacity((cards.len() + edges.len()) * 2); + for rect in cards.iter().map(Card::rect).chain(edges.iter().map(EdgePath::bounds)) { + corners.push(Point { x: rect.x, y: rect.y }); + corners.push(Point { + x: rect.right(), + y: rect.bottom(), + }); + } + if corners.is_empty() { + return None; + } + let inner = Rect::around(&corners); + Some(Rect { + x: inner.x - MARGIN, + y: inner.y - MARGIN, + width: inner.width + 2.0 * MARGIN, + height: inner.height + 2.0 * MARGIN, + }) +} + +/// The card under `point`, the one drawn last when cards overlap. +/// `order` is the drawing order. +pub(crate) fn hit(cards: &[Card], order: &[usize], point: Point) -> Option { + order + .iter() + .rev() + .copied() + .find(|&at| cards.get(at).is_some_and(|card| card.rect().contains(point))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card(x: f64, y: f64) -> Card { + Card { + at: Point { x, y }, + shape: CardShape { + width: 200.0, + header_height: 32.0, + row_height: 24.0, + rows: 4, + footer: 4.0, + }, + } + } + + #[test] + fn a_line_joins_the_facing_edges_at_the_key_rows() { + let orders = card(400.0, 0.0); + let customers = card(0.0, 100.0); + + let path = route(&orders, &[2], &customers, &[0]); + + assert_eq!( + path.start, + Point { + x: 400.0, + y: 32.0 + 48.0 + 12.0 + } + ); + assert_eq!( + path.end, + Point { + x: 200.0, + y: 100.0 + 32.0 + 12.0 + } + ); + assert_eq!((path.start_dir, path.end_dir), (-1.0, 1.0)); + assert_eq!(path.start_stub.x, 400.0 - STUB); + assert_eq!(path.end_stub.x, 200.0 + STUB); + } + + #[test] + fn stacked_cards_and_a_self_reference_join_on_the_right() { + let upper = card(0.0, 0.0); + let lower = card(20.0, 300.0); + + let stacked = route(&lower, &[1], &upper, &[0]); + let own = route(&upper, &[0], &upper, &[0]); + + assert_eq!((stacked.start_dir, stacked.end_dir), (1.0, 1.0)); + assert!(stacked.control_start.x > lower.rect().right()); + assert_eq!((own.start_dir, own.end_dir), (1.0, 1.0)); + assert_ne!(own.control_start.y, own.control_end.y, "a loop on one row would vanish"); + } + + #[test] + fn a_key_with_no_shown_column_meets_the_header() { + let orders = card(400.0, 0.0); + let customers = card(0.0, 0.0); + + let path = route(&orders, &[], &customers, &[9]); + + assert_eq!(path.start.y, 16.0); + assert_eq!(path.end.y, 16.0); + } + + #[test] + fn the_extent_holds_cards_and_lines_with_a_margin() { + let cards = [card(0.0, 0.0), card(0.0, 300.0)]; + let edges = [route(&cards[1], &[0], &cards[0], &[0])]; + + let extent = extent(&cards, &edges).expect("an extent"); + + assert_eq!((extent.x, extent.y), (-MARGIN, -MARGIN)); + assert!(extent.right() >= edges[0].control_start.x + MARGIN); + assert_eq!(extent.bottom(), 300.0 + cards[1].rect().height + MARGIN); + assert_eq!(super::extent(&[], &[]), None); + } + + #[test] + fn a_hit_finds_the_card_drawn_last() { + let cards = [card(0.0, 0.0), card(100.0, 50.0)]; + + assert_eq!(hit(&cards, &[0, 1], Point { x: 150.0, y: 60.0 }), Some(1)); + assert_eq!(hit(&cards, &[1, 0], Point { x: 150.0, y: 60.0 }), Some(0)); + assert_eq!(hit(&cards, &[0, 1], Point { x: 50.0, y: 10.0 }), Some(0)); + assert_eq!(hit(&cards, &[0, 1], Point { x: 900.0, y: 10.0 }), None); + } +} diff --git a/linux/crates/app/src/ui/diagram/tab.rs b/linux/crates/app/src/ui/diagram/tab.rs new file mode 100644 index 0000000000..dd112a1429 --- /dev/null +++ b/linux/crates/app/src/ui/diagram/tab.rs @@ -0,0 +1,696 @@ +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use tablepro_core::meta::TableRef; +use tablepro_session::runtime::{Tasks, WatchForwarder}; +use tablepro_session::{LinkGeneration, OperationError, Session}; +use tokio::sync::watch; +use uuid::Uuid; + +use super::describe::diagram_label; +use super::fetch::{ReadProgress, read_diagram}; +use super::layout::Point; +use super::model::SchemaDiagram; +use super::tab_imp as imp; +use super::{export, zoom}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; +use crate::ui::export::{ExportLedger, ImageExport}; +use crate::ui::tab_load::{ + ContentLoad, ContentPage, FirstLoad, LoadFailure, LoadPresentation, StaleBanner, start_first_load, +}; +use crate::workspace::DiagramPlace; + +/// How far Alt+arrow moves a table, in diagram units. +const MOVE_STEP: f64 = 20.0; + +glib::wrapper! { + /// A schema's entity-relationship diagram, in a tab of its own. + pub struct DiagramTab(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) struct DiagramTabInit { + /// `None` draws the default schema of an engine without schemas. + pub schema: Option, + pub session: Arc, + pub tasks: Tasks, + /// The workspace's exports, which an image is written through. + pub exports: Rc, + /// The workspace's id for the tab, which hears how an export ended. + pub tab_id: Uuid, + pub first_load: FirstLoad, + /// Where the user put tables before, for a restored or reopened tab. + pub places: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum DiagramRequest { + Tables, +} + +pub(crate) struct DiagramModel { + schema: Option, + session: Arc, + tasks: Tasks, + exports: Rc, + tab_id: Uuid, + pub(super) sequencer: RequestSequencer, + failed: FailedRequests, + /// The connection the running read was issued on. + issued_on: Option, + link_generation: LinkGeneration, + load: ContentLoad, + /// Stop ended the read before anything was drawn. + stopped: bool, + /// What the tab bar was last told. + tab_loading: bool, + /// Where the user had put the tables of a restored or reopened tab, + /// until its first diagram lands; from then on the canvas holds them. + kept: Option>, + /// A table changed while the running read was under way, so what it + /// brings may be out of date already. + changed_during_read: bool, + stale_banner: StaleBanner, +} + +impl DiagramTab { + pub(crate) fn new(init: DiagramTabInit) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + imp.canvas + .update_property(&[gtk::accessible::Property::Label(&diagram_label(init.schema.as_deref()))]); + let link_changes = init.session.link_changes(); + let weak = tab.downgrade(); + let stale_banner = StaleBanner::new(imp.stale_banner.get(), move || { + if let Some(tab) = weak.upgrade() { + tab.refresh(); + } + }); + imp.model.replace(Some(DiagramModel { + schema: init.schema, + link_generation: init.session.link_generation(), + session: init.session, + tasks: init.tasks, + exports: init.exports, + tab_id: init.tab_id, + sequencer: RequestSequencer::new(), + failed: FailedRequests::new(), + issued_on: None, + load: ContentLoad::default(), + stopped: false, + tab_loading: false, + kept: points(&init.places), + changed_during_read: false, + stale_banner, + })); + tab.install_actions(); + tab.connect_canvas(); + tab.add_controller(local_shortcut_controller(ShortcutOwner::DiagramTab)); + imp.canvas + .add_controller(local_shortcut_controller(ShortcutOwner::DiagramCanvas)); + let weak = tab.downgrade(); + imp.link_watch.replace(Some(WatchForwarder::new( + link_changes, + move |generation: LinkGeneration| { + if let Some(tab) = weak.upgrade() { + tab.on_link_changed(generation); + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + tab.present(ContentLoad::default().presentation()); + let weak = tab.downgrade(); + start_first_load(&tab, init.first_load, move || { + if let Some(tab) = weak.upgrade() { + tab.read(); + } + }); + tab + } + + #[cfg(test)] + pub(crate) fn schema(&self) -> Option { + self.with_model(|model| model.schema.clone()).flatten() + } + + pub(crate) fn canvas(&self) -> super::canvas::DiagramCanvas { + self.imp().canvas.get() + } + + /// Read the schema's tables again, keeping the diagram on screen + /// until the new one is drawn. + pub(crate) fn refresh(&self) { + self.read(); + } + + /// A table of the schema changed elsewhere, so the diagram may be out + /// of date. Reading hundreds of tables again on every change would + /// keep the pool busy, so the banner offers it. + pub(crate) fn schema_changed(&self) { + let imp = self.imp(); + self.with_model(|model| { + if model.load.is_running() { + model.changed_during_read = true; + } + }); + if imp.canvas.has_diagram() { + imp.outdated_banner.set_revealed(true); + } + } + + /// Stop the read and follow no more reconnects, now rather than at + /// dispose. + pub fn shutdown(&self) { + let imp = self.imp(); + imp.link_watch.take(); + imp.progress_watch.take(); + if let Ok(mut model) = imp.model.try_borrow_mut() + && let Some(model) = model.as_mut() + { + model.sequencer.cancel_all(); + } + } + + /// A read of the tables is running. + pub fn is_loading(&self) -> bool { + self.with_model(|model| model.tab_loading).unwrap_or(false) + } + + pub fn connect_open_table(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "open-table", + false, + glib::closure_local!(move |tab: &Self, schema: Option, name: String| { + f(tab, TableRef::new(schema, name)); + }), + ) + } + + pub fn connect_show_structure(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "show-structure", + false, + glib::closure_local!(move |tab: &Self, schema: Option, name: String| { + f(tab, TableRef::new(schema, name)); + }), + ) + } + + /// The user moved a table, or put them all back, so the tab's record + /// changed. + pub fn connect_layout_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("layout-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + /// Where the user put the tables, for the tab's record: none until one + /// is moved, so the automatic layout places them all again. + pub(crate) fn places(&self) -> Vec { + let canvas = self.canvas(); + let kept = match canvas.has_diagram() { + true => canvas.kept_places(), + false => self.with_model(|model| model.kept.clone()).flatten(), + }; + let mut places: Vec = kept + .unwrap_or_default() + .into_iter() + .map(|(table, at)| DiagramPlace { + table, + x: whole(at.x), + y: whole(at.y), + }) + .collect(); + places.sort_by(|a, b| a.table.cmp(&b.table)); + places + } + + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + + fn with_model(&self, f: impl FnOnce(&mut DiagramModel) -> R) -> Option { + let mut model = self.imp().model.try_borrow_mut().ok()?; + model.as_mut().map(f) + } + + fn read(&self) { + let started = self.with_model(|model| { + let presentation = model.load.begin(); + model.stopped = false; + model.changed_during_read = false; + model.issued_on = Some(model.session.link_generation()); + let RequestTicket { stamp, cancel } = model.sequencer.issue(DiagramRequest::Tables); + let (progress, watching) = watch::channel(ReadProgress::default()); + let work = read_diagram(model.session.clone(), model.schema.clone(), cancel, progress); + (presentation, stamp, work, watching, model.tasks.clone()) + }); + let Some((presentation, stamp, work, watching, tasks)) = started else { + return; + }; + let weak = self.downgrade(); + self.imp().progress_watch.replace(Some(WatchForwarder::new( + watching, + move |progress: ReadProgress| { + if let Some(tab) = weak.upgrade() { + tab.show_progress(progress); + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + let weak = self.downgrade(); + drop(crate::runtime::spawn_and_deliver(&tasks, work, move |joined| { + let value = + joined.unwrap_or_else(|failure| Err(LoadFailure::Schema(Arc::new(OperationError::from(failure))))); + if let Some(tab) = weak.upgrade() { + tab.on_read(Stamped { stamp, value }); + } + })); + self.present(presentation); + } + + fn on_read(&self, reply: Stamped>) { + let Stamped { stamp, value } = reply; + let imp = self.imp(); + let loaded = imp.canvas.has_diagram(); + let accepted = self.with_model(|model| { + if !model.sequencer.accept(DiagramRequest::Tables, stamp) { + return None; + } + model.failed.record(DiagramRequest::Tables, &value); + // A failed read leaves a restored tab's places for the next. + let restored = (value.is_ok() && !loaded).then(|| model.kept.take()).flatten(); + Some((restored, model.changed_during_read)) + }); + let Some(Some((restored, changed))) = accepted else { + return; + }; + imp.progress_watch.take(); + match value { + Ok(diagram) => { + let tables = diagram.tables.len(); + // Where the tables are now, so a move or a Reset Layout made + // while the read ran stands. + let kept = match loaded { + true => imp.canvas.kept_places(), + false => restored, + }; + let had_focus = crate::ui::a11y::holds_focus(&*imp.canvas); + imp.canvas.set_diagram(diagram, kept); + // A schema read again with no tables left has no card to + // keep the keyboard on. + if had_focus && tables == 0 { + imp.refresh_button.grab_focus(); + } + imp.outdated_banner.set_revealed(changed); + if let Some(presentation) = self.with_model(|model| model.load.succeed()) { + self.present(presentation); + } + crate::ui::a11y::announce( + self, + &crate::i18n::ngettext_f( + "Drew {n} table", + "Drew {n} tables", + u32::try_from(tables).unwrap_or(u32::MAX), + &[("n", &tables.to_string())], + ), + gtk::AccessibleAnnouncementPriority::Medium, + ); + } + Err(failure) => { + let retry = self.with_model(|model| { + let presentation = model.load.fail(failure); + let moved_on = model + .issued_on + .is_some_and(|issued| model.session.link_generation() > issued); + (presentation, moved_on) + }); + let Some((presentation, moved_on)) = retry else { + return; + }; + self.present(presentation); + if moved_on { + self.retry_failed(); + } + } + } + } + + /// Stop the read. A diagram on screen stays; with none, the tab says + /// the read stopped. + fn stop(&self) { + let presentation = self.with_model(|model| { + model.sequencer.cancel(DiagramRequest::Tables); + // A stopped read is not asked again on the next connection. + model.failed.take(); + let presentation = model.load.abandon(); + model.stopped = !model.load.has_content(); + presentation + }); + self.imp().progress_watch.take(); + if let Some(presentation) = presentation { + self.present(presentation); + } + } + + pub(super) fn on_link_changed(&self, generation: LinkGeneration) { + let newer = self.with_model(|model| { + let newer = generation > model.link_generation; + if newer { + model.link_generation = generation; + } + newer + }); + if newer == Some(true) { + self.retry_failed(); + } + } + + fn retry_failed(&self) { + let failed = self.with_model(|model| model.failed.take().contains(&DiagramRequest::Tables)); + if failed == Some(true) { + self.read(); + } + } + + /// Show where the read stands: the progress, the diagram, or why it + /// is not there. + fn present(&self, presentation: LoadPresentation) { + let imp = self.imp(); + let Some((stopped, running)) = self.with_model(|model| (model.stopped, model.load.is_running())) else { + return; + }; + let page = match &presentation.page { + ContentPage::Spinner if stopped && !running => "stopped", + ContentPage::Spinner => "loading", + ContentPage::Content if imp.canvas.table_count() == 0 => "empty", + ContentPage::Content => "diagram", + ContentPage::Error(failure) => { + imp.error_page + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + "error" + } + }; + self.show_page(page); + // An empty schema's page stays up while it is read again, so the + // bar holds Stop for it too. + let refreshing = running && imp.canvas.has_diagram(); + imp.refresh_box.set_visible(refreshing); + let changed = self.with_model(|model| { + model.stale_banner.show(presentation.stale.as_ref()); + let changed = model.tab_loading != presentation.tab_loading; + model.tab_loading = presentation.tab_loading; + changed + }); + if changed == Some(true) { + self.emit_by_name::<()>("loading-changed", &[&presentation.tab_loading]); + } + // Stop is on before the button takes it and off only after the + // button lets it go: a button on a disabled action turns + // insensitive and drops the keyboard focus. + if refreshing { + self.sync_actions(); + self.label_refresh_button(true); + } else { + self.label_refresh_button(false); + self.sync_actions(); + } + } + + /// Refresh, or Stop while a refresh runs. One button, so the keyboard + /// stays on it either way. + pub(super) fn label_refresh_button(&self, refreshing: bool) { + let button = self.imp().refresh_button.get(); + let (icon, label, action) = match refreshing { + true => ( + crate::ui::icons::PROCESS_STOP, + crate::i18n::gettext("Stop"), + "diagram.stop", + ), + false => ( + crate::ui::icons::VIEW_REFRESH, + crate::i18n::gettext("Refresh"), + "diagram.refresh", + ), + }; + if button.action_name().as_deref() == Some(action) { + return; + } + button.set_icon_name(icon); + button.set_action_name(Some(action)); + button.reset_property(gtk::AccessibleProperty::KeyShortcuts); + crate::ui::a11y::describe_icon_button(&button, &label, Some(action)); + } + + fn show_page(&self, page: &str) { + let imp = self.imp(); + if imp.inner_stack.visible_child_name().as_deref() == Some(page) { + return; + } + // The page with the focus goes, so the focus goes somewhere a + // keyboard user can carry on from rather than to the window. + let had_focus = imp + .inner_stack + .visible_child() + .is_some_and(|child| crate::ui::a11y::holds_focus(&child)); + imp.inner_stack.set_visible_child_name(page); + if !had_focus { + return; + } + match page { + "diagram" => { + let first = imp.canvas.selected().unwrap_or(0); + imp.canvas.focus_table(first); + } + _ => { + imp.refresh_button.grab_focus(); + } + } + } + + fn show_progress(&self, progress: ReadProgress) { + let imp = self.imp(); + let text = match progress.total { + 0 => crate::i18n::gettext("Reading the list of tables…"), + total => crate::i18n::ngettext_f( + "{done} of {total} table read", + "{done} of {total} tables read", + u32::try_from(total).unwrap_or(u32::MAX), + &[("done", &progress.done.to_string()), ("total", &total.to_string())], + ), + }; + imp.loading_page.set_description(Some(&glib::markup_escape_text(&text))); + imp.refresh_label.set_label(&text); + let fraction = match progress.total { + 0 => 0.0, + total => progress.done as f64 / total as f64, + }; + imp.loading_progress.set_fraction(fraction); + } + + fn connect_canvas(&self) { + let canvas = self.imp().canvas.get(); + let weak = self.downgrade(); + canvas.connect_zoom_changed(move |canvas| { + if let Some(tab) = weak.upgrade() { + tab.imp() + .zoom_reset_button + .set_label(&format!("{}%", zoom::percent(canvas.zoom()))); + tab.sync_actions(); + } + }); + let weak = self.downgrade(); + canvas.connect_selection_changed(move |_| { + if let Some(tab) = weak.upgrade() { + tab.sync_actions(); + } + }); + let weak = self.downgrade(); + canvas.connect_layout_changed(move |_| { + if let Some(tab) = weak.upgrade() { + tab.emit_by_name::<()>("layout-changed", &[]); + } + }); + let weak = self.downgrade(); + canvas.connect_table_activated(move |canvas, index| { + if let (Some(tab), Some(table)) = (weak.upgrade(), canvas.table(index)) { + tab.emit_table("open-table", &table); + } + }); + } + + fn emit_table(&self, signal: &str, table: &TableRef) { + self.emit_by_name::<()>(signal, &[&table.schema, &table.name]); + } + + fn install_actions(&self) { + let group = gio::SimpleActionGroup::new(); + let add = |name: &str, act: fn(&DiagramTab)| { + let action = gio::SimpleAction::new(name, None); + let weak = self.downgrade(); + action.connect_activate(move |_, _| { + if let Some(tab) = weak.upgrade() { + act(&tab); + } + }); + group.add_action(&action); + }; + add("zoom-in", |tab| tab.canvas().zoom_in()); + add("zoom-out", |tab| tab.canvas().zoom_out()); + add("zoom-reset", |tab| tab.canvas().zoom_reset()); + add("zoom-fit", |tab| tab.canvas().zoom_fit()); + add("refresh", DiagramTab::refresh); + add("stop", DiagramTab::stop); + add("reset-layout", |tab| tab.canvas().reset_layout()); + add("open-table", |tab| tab.emit_selected("open-table")); + add("show-structure", |tab| tab.emit_selected("show-structure")); + add("context-menu", |tab| { + let canvas = tab.canvas(); + if let Some(bounds) = canvas.selected().and_then(|index| canvas.card_bounds(index)) { + super::canvas_input::popup_menu(&canvas, bounds); + } + }); + add("move-table-left", |tab| tab.canvas().move_selected(-MOVE_STEP, 0.0)); + add("move-table-right", |tab| tab.canvas().move_selected(MOVE_STEP, 0.0)); + add("move-table-up", |tab| tab.canvas().move_selected(0.0, -MOVE_STEP)); + add("move-table-down", |tab| tab.canvas().move_selected(0.0, MOVE_STEP)); + let export = gio::SimpleAction::new("export", Some(glib::VariantTy::STRING)); + let weak = self.downgrade(); + export.connect_activate(move |_, target| { + let format = target + .and_then(|target| target.get::()) + .and_then(|name| export::ImageFormat::from_name(&name)); + if let (Some(tab), Some(format)) = (weak.upgrade(), format) { + tab.export(format); + } + }); + group.add_action(&export); + self.insert_action_group("diagram", Some(&group)); + self.imp().actions.replace(Some(group)); + } + + fn emit_selected(&self, signal: &str) { + if let Some(table) = self.canvas().selected_table() { + self.emit_table(signal, &table); + } + } + + /// Turn on what applies to the diagram on screen and its selection. + fn sync_actions(&self) { + let imp = self.imp(); + let shown = imp.inner_stack.visible_child_name().as_deref() == Some("diagram"); + let selected = shown && imp.canvas.selected().is_some(); + let running = self.with_model(|model| model.load.is_running()).unwrap_or(false); + let zoom = imp.canvas.zoom(); + let states = [ + ("zoom-in", shown && zoom < zoom::MAX), + ("zoom-out", shown && zoom > zoom::LEVELS[0]), + ("zoom-reset", shown), + ("zoom-fit", shown), + ("reset-layout", shown), + ("export", shown), + ("stop", running), + ("open-table", selected), + ("show-structure", selected), + ("context-menu", selected), + ("move-table-left", selected), + ("move-table-right", selected), + ("move-table-up", selected), + ("move-table-down", selected), + ]; + let Some(group) = imp.actions.borrow().clone() else { + return; + }; + for (name, enabled) in states { + if let Some(action) = group.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } + } + } + + fn export(&self, format: export::ImageFormat) { + let Some((schema, tasks, exports, tab_id)) = self.with_model(|model| { + ( + model.schema.clone(), + model.tasks.clone(), + model.exports.clone(), + model.tab_id, + ) + }) else { + return; + }; + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&format.filter_name())); + filter.add_mime_type(format.mime_type()); + filter.add_suffix(format.name()); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Diagram")) + .modal(true) + .initial_name(format.suggested_name(schema.as_deref())) + .default_filter(&filter) + .filters(&filters) + .build(); + let window = self.root().and_downcast::(); + let weak = self.downgrade(); + glib::spawn_future_local(async move { + // Cancelling the dialog is the only way it fails without a file. + let Ok(file) = dialog.save_future(window.as_ref()).await else { + return; + }; + let Some(tab) = weak.upgrade() else { + return; + }; + // Drawn as the diagram is now, after the dialog closed. The + // render nodes belong to the GTK thread; the encoding does not. + let drawn = tab + .canvas() + .render() + .ok_or(export::DrawError::Empty) + .and_then(|(node, width, height)| export::draw(&node, width, height, format)); + let bytes = + tasks.spawn_blocking_task(move || drawn.and_then(export::encode).map_err(|error| error.to_string())); + let _started = exports.begin_image(ImageExport { + destination: file, + bytes, + tab: Some(tab_id), + }); + }); + } +} + +/// A record's places as the canvas takes them, or `None` with none. +fn points(places: &[DiagramPlace]) -> Option> { + (!places.is_empty()).then(|| { + places + .iter() + .map(|place| { + let at = Point { + x: f64::from(place.x), + y: f64::from(place.y), + }; + (place.table.clone(), at) + }) + .collect() + }) +} + +/// A place in whole units, as the record keeps it. +fn whole(value: f64) -> i32 { + value.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 +} diff --git a/linux/crates/app/src/ui/diagram/tab_imp.rs b/linux/crates/app/src/ui/diagram/tab_imp.rs new file mode 100644 index 0000000000..5f351ce96f --- /dev/null +++ b/linux/crates/app/src/ui/diagram/tab_imp.rs @@ -0,0 +1,133 @@ +use std::cell::RefCell; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::runtime::WatchForwarder; + +use super::canvas::DiagramCanvas; +use super::tab::DiagramModel; +use crate::ui::a11y::describe_icon_button; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/diagram-tab.ui")] +pub struct DiagramTab { + #[template_child] + pub stale_banner: TemplateChild, + #[template_child] + pub outdated_banner: TemplateChild, + #[template_child] + pub inner_stack: TemplateChild, + #[template_child] + pub loading_page: TemplateChild, + #[template_child] + pub loading_progress: TemplateChild, + #[template_child] + pub loading_stop_button: TemplateChild, + #[template_child] + pub scroller: TemplateChild, + #[template_child] + pub canvas: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub zoom_out_button: TemplateChild, + #[template_child] + pub zoom_reset_button: TemplateChild, + #[template_child] + pub zoom_in_button: TemplateChild, + #[template_child] + pub zoom_fit_button: TemplateChild, + #[template_child] + pub refresh_box: TemplateChild, + #[template_child] + pub refresh_label: TemplateChild, + #[template_child] + pub more_button: TemplateChild, + #[template_child] + pub refresh_button: TemplateChild, + + pub(super) model: RefCell>, + /// The tab's `diagram` actions, turned on and off as what is on + /// screen changes. + pub(super) actions: RefCell>, + /// Delivers each reconnect, for as long as the tab is open. + pub(super) link_watch: RefCell>, + /// Delivers the running read's progress. + pub(super) progress_watch: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DiagramTab { + const NAME: &'static str = "TableProDiagramTab"; + type Type = super::DiagramTab; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + DiagramCanvas::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for DiagramTab { + fn constructed(&self) { + self.parent_constructed(); + let gettext = crate::i18n::gettext; + describe_icon_button( + &self.zoom_out_button.get(), + &gettext("Zoom Out"), + Some("diagram.zoom-out"), + ); + describe_icon_button(&self.zoom_in_button.get(), &gettext("Zoom In"), Some("diagram.zoom-in")); + describe_icon_button( + &self.zoom_fit_button.get(), + &gettext("Best Fit"), + Some("diagram.zoom-fit"), + ); + let reset = self.zoom_reset_button.get(); + reset.set_tooltip_text(Some(&gettext("Reset Zoom"))); + reset.update_property(&[gtk::accessible::Property::Description(&gettext("Reset Zoom"))]); + crate::ui::a11y::announce_key_shortcuts(&reset, "diagram.zoom-reset"); + self.obj().label_refresh_button(false); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("open-table") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("show-structure") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("layout-changed").build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), + ] + }) + } + + fn dispose(&self) { + self.link_watch.take(); + self.progress_watch.take(); + if let Ok(mut model) = self.model.try_borrow_mut() + && let Some(mut model) = model.take() + { + model.sequencer.cancel_all(); + } + } +} + +impl WidgetImpl for DiagramTab {} +impl BinImpl for DiagramTab {} diff --git a/linux/crates/app/src/ui/diagram/tests.rs b/linux/crates/app/src/ui/diagram/tests.rs new file mode 100644 index 0000000000..346f55ad0b --- /dev/null +++ b/linux/crates/app/src/ui/diagram/tests.rs @@ -0,0 +1,676 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::meta::{ObjectInfo, ObjectKind, TableRef}; +use tablepro_core::{ColumnInfo, DriverError, ForeignKeyInfo}; +use tablepro_session::runtime::CountingHold; +use tablepro_test_support::{ConnectionScript, FakeReply}; +use tokio_util::sync::CancellationToken; + +use super::canvas::DiagramCanvas; +use super::model::tests::{column, key}; +use super::model::{SchemaDiagram, TableRead}; +use super::*; +use crate::runtime::AppRuntime; +use crate::test_support::{FakeSession, OwnedWindow, assert_labelled, drain_main_context, test_runtime, wait_until}; +use crate::ui::tab_load::FirstLoad; + +const WAIT: Duration = Duration::from_secs(10); + +fn object(name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some("sales".to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } +} + +fn columns(name: &str) -> Vec { + match name { + "orders" => vec![column("id", true, false), column("customer_id", false, false)], + "order_items" => vec![column("id", true, false), column("order_id", false, false)], + _ => vec![column("id", true, false), column("name", false, true)], + } +} + +fn keys(name: &str) -> Vec { + match name { + "orders" => vec![key( + "orders_customer", + &["customer_id"], + Some("sales"), + "customers", + &["id"], + )], + "order_items" => vec![key("items_order", &["order_id"], Some("sales"), "orders", &["id"])], + _ => Vec::new(), + } +} + +/// customers <- orders <- order_items. A fake answers by the table name +/// a call is for, which "orders" also matches in "order_items", so that +/// rule comes first. +fn shop() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ + object("customers"), + object("orders"), + object("order_items"), + ])); + for name in ["order_items", "orders"] { + script + .fetch_columns + .when_sql_contains(name, FakeReply::ok(columns(name))); + script + .fetch_foreign_keys + .when_sql_contains(name, FakeReply::ok(keys(name))); + } + script.fetch_columns.always(FakeReply::ok(columns("customers"))); + script.fetch_foreign_keys.always(FakeReply::ok(Vec::new())); + script.fetch_indexes.always(FakeReply::ok(Vec::new())); + script +} + +/// A diagram tab in a window of its own, over a fake session. The tab +/// and the window go before the runtime does. +struct Shown { + window: Option>, + tab: Option, + fake: Option, + runtime: Option, +} + +impl Shown { + fn new(script: ConnectionScript) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let window = gtk::Window::builder().default_width(900).default_height(600).build(); + let toasts = adw::ToastOverlay::new(); + let exports = crate::ui::export::ExportLedger::new(crate::ui::export::ExportLedgerInit { + tasks: runtime.tasks(), + hold: Rc::new(CountingHold::new()), + reporter: crate::ui::export::ExportReporter::new( + &window, + &toasts, + Rc::new(crate::ui::file_reveal::RecordingRevealer::succeeding()), + ), + inhibitor: crate::workspace::WindowInhibitor::new(crate::workspace::ApplicationInhibit::new(&window)), + }); + let tab = DiagramTab::new(DiagramTabInit { + schema: Some("sales".to_owned()), + session: fake.session.clone(), + tasks: runtime.tasks(), + exports, + tab_id: uuid::Uuid::new_v4(), + first_load: FirstLoad::Now, + places: Vec::new(), + }); + toasts.set_child(Some(&tab)); + window.set_child(Some(&toasts)); + let window = OwnedWindow::presented(window); + Self { + window: Some(window), + tab: Some(tab), + fake: Some(fake), + runtime: Some(runtime), + } + } + + fn tab(&self) -> &DiagramTab { + self.tab.as_ref().expect("the tab") + } + + fn canvas(&self) -> DiagramCanvas { + self.tab().canvas() + } + + fn page(&self) -> String { + self.tab() + .imp() + .inner_stack + .visible_child_name() + .map(|name| name.to_string()) + .unwrap_or_default() + } + + fn drawn(&self) -> &Self { + wait_until(WAIT, || self.page() == "diagram" && self.canvas().table_count() == 3) + .unwrap_or_else(|_| panic!("the diagram, not {}", self.page())); + wait_until(WAIT, || self.canvas().width() > 0).expect("the canvas on screen"); + self + } + + fn activate(&self, action: &str) { + WidgetExt::activate_action(self.tab(), action, None).unwrap_or_else(|_| panic!("{action}")); + drain_main_context(); + } +} + +impl Drop for Shown { + fn drop(&mut self) { + if let Some(tab) = self.tab.take() { + tab.shutdown(); + } + self.window.take(); + self.fake.take(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn index_of(canvas: &DiagramCanvas, name: &str) -> usize { + (0..canvas.table_count()) + .find(|&at| canvas.table(at).is_some_and(|table| table.name == name)) + .unwrap_or_else(|| panic!("no {name} in the diagram")) +} + +#[gtk4::test] +fn every_table_of_the_schema_is_drawn_and_named_for_a_screen_reader() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + + assert_eq!(canvas.accessible_role(), gtk::AccessibleRole::List); + for at in 0..canvas.table_count() { + let node = canvas.node(at).expect("a node per table"); + assert_eq!(node.accessible_role(), gtk::AccessibleRole::ListItem); + assert!(node.is_focusable()); + assert_labelled(&node).unwrap_or_else(|problem| panic!("{problem:?}")); + assert!(gtk::test_accessible_has_property( + &node, + gtk::AccessibleProperty::Description + )); + } + let orders = index_of(&canvas, "orders"); + assert_eq!( + super::describe::table_description(&SchemaDiagram::build(reads()), orders), + "2 columns. References customers. Referenced by order_items. Columns: id, integer, primary key; \ + customer_id, integer, foreign key." + ); +} + +fn reads() -> Vec { + ["customers", "orders", "order_items"] + .iter() + .map(|&name| TableRead { + table: TableRef::new(Some("sales".to_owned()), name), + columns: columns(name), + foreign_keys: keys(name), + indexes: Vec::new(), + }) + .collect() +} + +#[gtk4::test] +fn the_keyboard_selects_a_table_and_opens_it() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + let opened: Rc>> = Rc::default(); + let sink = opened.clone(); + shown + .tab() + .connect_open_table(move |_, table| sink.borrow_mut().push(table)); + let orders = index_of(&canvas, "orders"); + + canvas.node(orders).expect("the orders node").grab_focus(); + drain_main_context(); + assert_eq!(canvas.selected(), Some(orders), "the focus selects the table"); + shown.activate("diagram.open-table"); + + assert_eq!( + opened.borrow().as_slice(), + [TableRef::new(Some("sales".to_owned()), "orders")] + ); + let before = canvas.selected(); + assert!( + canvas.child_focus(gtk::DirectionType::TabForward), + "Tab reaches the next table" + ); + drain_main_context(); + assert_ne!(canvas.selected(), before, "the next table is selected"); +} + +#[gtk4::test] +fn a_table_moved_by_the_keyboard_keeps_its_place_through_a_refresh() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + let customers = index_of(&canvas, "customers"); + canvas.focus_table(customers); + let place = |canvas: &DiagramCanvas| canvas.kept_places().and_then(|places| places.get("customers").copied()); + assert_eq!(place(&canvas), None, "nothing was moved yet"); + + shown.activate("diagram.move-table-right"); + shown.activate("diagram.move-table-down"); + let moved = place(&canvas).expect("the table was moved"); + shown.activate("diagram.refresh"); + wait_until(WAIT, || !shown.tab().is_loading()).expect("the refresh to end"); + + assert_eq!(place(&shown.canvas()), Some(moved)); + assert_eq!( + shown.canvas().selected_table().map(|table| table.name), + Some("customers".to_owned()) + ); +} + +#[gtk4::test] +fn zoom_steps_fits_and_resets() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + shown.activate("diagram.zoom-reset"); + assert_eq!(canvas.zoom(), 1.0); + + shown.activate("diagram.zoom-in"); + assert_eq!(canvas.zoom(), 1.1); + wait_until(WAIT, || { + shown.tab().imp().zoom_reset_button.label().as_deref() == Some("110%") + }) + .expect("the label follows the zoom"); + shown.activate("diagram.zoom-out"); + shown.activate("diagram.zoom-out"); + assert_eq!(canvas.zoom(), 0.9); + shown.activate("diagram.zoom-fit"); + assert!(canvas.zoom() <= 1.0); +} + +#[gtk4::test] +fn stop_ends_the_read_and_says_so() { + let mut script = shop(); + let release = CancellationToken::new(); + script.fetch_indexes = Default::default(); + script.fetch_indexes.always(FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(Vec::new()), + }); + let shown = Shown::new(script); + wait_until(WAIT, || shown.tab().is_loading() && shown.page() == "loading").expect("the read to run"); + + shown.activate("diagram.stop"); + + wait_until(WAIT, || shown.page() == "stopped").expect("the tab says the read stopped"); + assert!(!shown.tab().is_loading()); + release.cancel(); +} + +#[gtk4::test] +fn a_failed_refresh_keeps_the_diagram_under_a_banner() { + let mut script = shop(); + script.fetch_indexes = Default::default(); + for _ in 0..3 { + script.fetch_indexes.once(FakeReply::ok(Vec::new())); + } + script + .fetch_indexes + .always(FakeReply::err(DriverError::server("permission denied"))); + let shown = Shown::new(script); + shown.drawn(); + + shown.activate("diagram.refresh"); + wait_until(WAIT, || !shown.tab().is_loading()).expect("the refresh to end"); + + assert_eq!(shown.page(), "diagram"); + assert!( + shown.tab().imp().stale_banner.is_revealed(), + "the banner says the diagram is the last one read" + ); +} + +#[gtk4::test] +fn a_change_to_the_schema_offers_a_refresh() { + let shown = Shown::new(shop()); + shown.drawn(); + assert!(!shown.tab().imp().outdated_banner.is_revealed()); + + shown.tab().schema_changed(); + + assert!(shown.tab().imp().outdated_banner.is_revealed()); + shown.activate("diagram.refresh"); + wait_until(WAIT, || !shown.tab().is_loading()).expect("the refresh to end"); + assert!( + !shown.tab().imp().outdated_banner.is_revealed(), + "a diagram read again is up to date" + ); +} + +#[gtk4::test] +fn a_schema_with_no_tables_says_so() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(Vec::new())); + + let shown = Shown::new(script); + + wait_until(WAIT, || shown.page() == "empty").expect("the empty page"); +} + +/// A schema of hundreds of tables lays out and draws without holding the +/// window for long. The bound is far above a debug build's time, so it +/// catches a layout that grows out of hand, not a slow machine. +#[gtk4::test] +fn five_hundred_tables_lay_out_and_draw_in_bounded_time() { + let shown = Shown::new(shop()); + shown.drawn(); + let reads: Vec = (0..500) + .map(|at| TableRead { + table: TableRef::new(Some("sales".to_owned()), format!("table_{at:03}")), + columns: (0..10).map(|c| column(&format!("column_{c}"), c == 0, c > 2)).collect(), + foreign_keys: match at { + 0 => Vec::new(), + _ => vec![key( + "parent", + &["column_1"], + None, + &format!("table_{:03}", at / 3), + &["column_0"], + )], + }, + indexes: Vec::new(), + }) + .collect(); + let canvas = shown.canvas(); + + let started = Instant::now(); + canvas.set_diagram(SchemaDiagram::build(reads), None); + drain_main_context(); + let laid_out = started.elapsed(); + let started = Instant::now(); + let drawn = canvas.render(); + let rendered = started.elapsed(); + + assert_eq!(canvas.table_count(), 500); + assert!(drawn.is_some()); + assert!(laid_out < Duration::from_secs(10), "laid out in {laid_out:?}"); + assert!(rendered < Duration::from_secs(10), "drawn in {rendered:?}"); +} + +/// The first read answers at once; every read after it waits for +/// `release`. +fn held_after_first_read(release: &CancellationToken) -> ConnectionScript { + let mut script = shop(); + script.fetch_indexes = Default::default(); + for _ in 0..3 { + script.fetch_indexes.once(FakeReply::ok(Vec::new())); + } + script.fetch_indexes.always(FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(Vec::new()), + }); + script +} + +#[gtk4::test] +fn a_table_moved_while_a_refresh_runs_keeps_its_new_place() { + let release = CancellationToken::new(); + let shown = Shown::new(held_after_first_read(&release)); + shown.drawn(); + let canvas = shown.canvas(); + canvas.focus_table(index_of(&canvas, "orders")); + shown.activate("diagram.refresh"); + wait_until(WAIT, || shown.tab().is_loading()).expect("the refresh to run"); + + shown.activate("diagram.move-table-down"); + let moved = canvas.kept_places().and_then(|places| places.get("orders").copied()); + release.cancel(); + wait_until(WAIT, || !shown.tab().is_loading()).expect("the refresh to end"); + + assert!(moved.is_some()); + assert_eq!( + shown + .canvas() + .kept_places() + .and_then(|places| places.get("orders").copied()), + moved + ); +} + +#[gtk4::test] +fn reset_layout_after_a_stopped_refresh_saves_no_places() { + let release = CancellationToken::new(); + let shown = Shown::new(held_after_first_read(&release)); + shown.drawn(); + let canvas = shown.canvas(); + canvas.focus_table(index_of(&canvas, "orders")); + shown.activate("diagram.move-table-right"); + assert_eq!(shown.tab().places().len(), 3, "a moved table saves every place"); + shown.activate("diagram.refresh"); + shown.activate("diagram.stop"); + + shown.activate("diagram.reset-layout"); + + assert!(shown.tab().places().is_empty(), "{:?}", shown.tab().places()); + release.cancel(); +} + +#[gtk4::test] +fn a_change_while_the_tables_are_read_leaves_the_diagram_marked_out_of_date() { + let release = CancellationToken::new(); + let mut script = shop(); + script.fetch_indexes = Default::default(); + script.fetch_indexes.always(FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(Vec::new()), + }); + let shown = Shown::new(script); + wait_until(WAIT, || shown.tab().is_loading()).expect("the read to run"); + + shown.tab().schema_changed(); + release.cancel(); + shown.drawn(); + + assert!(shown.tab().imp().outdated_banner.is_revealed()); +} + +/// The scrolled area keeps the room a moved card left, but Best Fit and +/// an export show only where the cards are now. +#[gtk4::test] +fn best_fit_and_export_ignore_where_a_moved_table_was() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + shown.activate("diagram.zoom-fit"); + let fitted = canvas.zoom(); + let (_, width, height) = canvas.render().expect("a diagram"); + let customers = index_of(&canvas, "customers"); + let home = canvas.card_position(customers); + + canvas.place_card( + customers, + super::layout::Point { + x: home.x + 5_000.0, + y: home.y + 3_000.0, + }, + ); + canvas.settle_move(); + canvas.place_card(customers, home); + canvas.settle_move(); + shown.activate("diagram.zoom-fit"); + + assert_eq!(canvas.zoom(), fitted); + let (_, after_width, after_height) = canvas.render().expect("a diagram"); + assert_eq!((after_width, after_height), (width, height)); +} + +#[gtk4::test] +fn a_refresh_leaves_the_view_where_it_was() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + let customers = index_of(&canvas, "customers"); + let home = canvas.card_position(customers); + canvas.place_card( + customers, + super::layout::Point { + x: home.x - 3_000.0, + y: home.y - 2_000.0, + }, + ); + canvas.settle_move(); + canvas.place_card(customers, home); + canvas.settle_move(); + drain_main_context(); + let orders = index_of(&canvas, "orders"); + let before = canvas.card_bounds(orders); + + shown.activate("diagram.refresh"); + wait_until(WAIT, || !shown.tab().is_loading()).expect("the refresh to end"); + drain_main_context(); + + assert_eq!(shown.canvas().card_bounds(index_of(&shown.canvas(), "orders")), before); +} + +#[gtk4::test] +fn a_stopped_read_is_not_read_again_on_reconnect() { + let release = CancellationToken::new(); + let mut script = shop(); + script.fetch_indexes = Default::default(); + script + .fetch_indexes + .once(FakeReply::err(DriverError::server("connection reset"))); + script.fetch_indexes.always(FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(Vec::new()), + }); + let shown = Shown::new(script); + wait_until(WAIT, || shown.page() == "error").expect("the failed read"); + shown.activate("diagram.refresh"); + wait_until(WAIT, || shown.tab().is_loading()).expect("the read again"); + shown.activate("diagram.stop"); + + shown + .tab() + .on_link_changed(tablepro_session::LinkGeneration::FIRST.next().next()); + drain_main_context(); + + assert!(!shown.tab().is_loading(), "a reconnect read the stopped schema again"); + assert_eq!(shown.page(), "stopped"); + release.cancel(); +} + +#[gtk4::test] +fn an_export_draws_the_whole_diagram_at_twice_its_size() { + let shown = Shown::new(shop()); + shown.drawn(); + let canvas = shown.canvas(); + shown.activate("diagram.zoom-in"); + + let (node, width, height) = canvas.render().expect("a diagram"); + let draw = |format| super::export::encode(super::export::draw(&node, width, height, format).expect("drawn")); + let png = draw(super::export::ImageFormat::Png).expect("a PNG"); + let svg = draw(super::export::ImageFormat::Svg).expect("an SVG"); + + let texture = gtk::gdk::Texture::from_bytes(>k::glib::Bytes::from(&png)).expect("a PNG GTK reads"); + assert_eq!( + (texture.width(), texture.height()), + ((width * 2.0).ceil() as i32, (height * 2.0).ceil() as i32), + "the whole diagram at twice its size, whatever the zoom" + ); + assert!(String::from_utf8(svg).expect("SVG is text").contains(" f64 { + if zoom.is_finite() { zoom.clamp(MIN, MAX) } else { 1.0 } +} + +/// The next level up from `zoom`. +pub(crate) fn step_in(zoom: f64) -> f64 { + LEVELS.iter().copied().find(|level| *level > zoom + 1e-6).unwrap_or(MAX) +} + +/// The next level down from `zoom`, or the lowest one. +pub(crate) fn step_out(zoom: f64) -> f64 { + LEVELS + .iter() + .rev() + .copied() + .find(|level| *level < zoom - 1e-6) + .unwrap_or(LEVELS[0]) +} + +/// The zoom that shows `content` whole in `viewport`, never past 100%. +pub(crate) fn fit(content: (f64, f64), viewport: (f64, f64)) -> f64 { + let (content_width, content_height) = content; + let (width, height) = viewport; + if content_width <= 0.0 || content_height <= 0.0 || width <= 0.0 || height <= 0.0 { + return 1.0; + } + clamp((width / content_width).min(height / content_height).min(1.0)) +} + +/// The zoom as the label shows it, rounded to a whole percent. +pub(crate) fn percent(zoom: f64) -> u32 { + let rounded = (zoom * 100.0).round(); + if rounded.is_finite() && rounded >= 0.0 && rounded <= f64::from(u32::MAX) { + rounded as u32 + } else { + 100 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zoom_steps_through_the_levels_and_stops_at_the_ends() { + assert_eq!(step_in(1.0), 1.1); + assert_eq!(step_out(1.0), 0.9); + assert_eq!(step_in(1.05), 1.1, "between levels goes to the next one up"); + assert_eq!(step_out(1.05), 1.0); + assert_eq!(step_in(MAX), MAX); + assert_eq!(step_out(0.1), 0.1); + assert_eq!(step_in(0.03), 0.1, "from below the levels, a fit's zoom"); + } + + #[test] + fn fit_shows_the_whole_diagram_and_never_zooms_past_100() { + assert_eq!(fit((2000.0, 1000.0), (1000.0, 1000.0)), 0.5); + assert_eq!(fit((200.0, 100.0), (1000.0, 1000.0)), 1.0); + assert_eq!(fit((1_000_000.0, 10.0), (100.0, 100.0)), MIN); + assert_eq!(fit((0.0, 0.0), (100.0, 100.0)), 1.0); + } + + #[test] + fn percent_rounds_and_clamp_refuses_nonsense() { + assert_eq!(percent(0.666), 67); + assert_eq!(percent(1.0), 100); + assert_eq!(clamp(f64::NAN), 1.0); + assert_eq!(clamp(99.0), MAX); + assert_eq!(clamp(0.0), MIN); + } +} diff --git a/linux/crates/app/src/ui/export/export_failure_text.rs b/linux/crates/app/src/ui/export/export_failure_text.rs index 717a8f3d53..6ac1eef582 100644 --- a/linux/crates/app/src/ui/export/export_failure_text.rs +++ b/linux/crates/app/src/ui/export/export_failure_text.rs @@ -78,6 +78,7 @@ pub(crate) fn failure_text(failure: &ExportFailure, file: Option, "An export to {name} is already running. Wait for it to finish, or export to another file.", &[("name", name)], ), + ExportFailure::Image(_) => gettext("The image couldn't be drawn."), ExportFailure::Task(TaskFailure::Panicked(_) | TaskFailure::Aborted) | ExportFailure::Stream(ExportStreamFailure::Task(_)) => gettext("The export stopped unexpectedly."), }; @@ -180,8 +181,12 @@ impl ExportReporter { pub(crate) fn report(&self, file: &gio::File, name: &str, outcome: &ExportOutcome) { match outcome { - ExportOutcome::Exported { rows } => { - let toast = crate::ui::plain_toast(&exported_text(*rows, name)); + ExportOutcome::Exported { .. } | ExportOutcome::Saved => { + let text = match outcome { + ExportOutcome::Exported { rows } => exported_text(*rows, name), + _ => gettext_f("Exported {name}", &[("name", name)]), + }; + let toast = crate::ui::plain_toast(&text); toast.set_button_label(Some(&gettext("Show in Files"))); let (file, reporter) = (file.clone(), self.clone()); toast.connect_button_clicked(move |_| reporter.show_in_files(&file)); diff --git a/linux/crates/app/src/ui/export/export_job_item.rs b/linux/crates/app/src/ui/export/export_job_item.rs index 702d0e860d..d14ac683b4 100644 --- a/linux/crates/app/src/ui/export/export_job_item.rs +++ b/linux/crates/app/src/ui/export/export_job_item.rs @@ -21,6 +21,9 @@ mod imp { pub rows: Cell, pub expected: Cell>, pub cancel: OnceCell, + /// The file is made whole before it is written, so it has no rows + /// to count. + pub whole: Cell, } #[glib::object_subclass] @@ -46,6 +49,17 @@ impl ExportJobItem { item } + /// A job that writes a file made whole first, such as an image. + pub(crate) fn whole_file(title: &str, cancel: CancellationToken) -> Self { + let item = Self::new(title, None, cancel); + item.imp().whole.set(true); + item + } + + pub(crate) fn is_whole_file(&self) -> bool { + self.imp().whole.get() + } + pub(crate) fn expected(&self) -> Option { self.imp().expected.get() } diff --git a/linux/crates/app/src/ui/export/export_ledger/mod.rs b/linux/crates/app/src/ui/export/export_ledger/mod.rs index 8840c8c9bc..c9bec9168e 100644 --- a/linux/crates/app/src/ui/export/export_ledger/mod.rs +++ b/linux/crates/app/src/ui/export/export_ledger/mod.rs @@ -27,6 +27,9 @@ use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhi /// Chunks in flight between the encoder and the file. Few, so a slow /// disk holds the encoder back within a few hundred kilobytes. const CHANNEL_CHUNKS: usize = 4; +/// An image is written this much at a time, so Stop is heard on a slow +/// destination, as a row export's chunks are. +const IMAGE_CHUNK: usize = 64 * 1024; type EndedListener = Rc; @@ -68,6 +71,15 @@ pub(crate) struct ExportLedgerInit { pub inhibitor: WindowInhibitor, } +/// A file made whole before it is written, such as a diagram's image: +/// its bytes come from a task already running. +pub(crate) struct ImageExport { + pub destination: gio::File, + pub bytes: TaskResult, String>>, + /// The tab the image came from, which hears how the export ended. + pub tab: Option, +} + /// A job the ledger started. Dropping it leaves the job running, and /// the ledger reports how it ended either way. pub(crate) struct ExportJobHandle { @@ -111,13 +123,41 @@ impl ExportLedger { /// Start `job`, unless the workspace has shut down. A file chooser /// still open when the window closed can hand one over after that. pub(crate) fn begin(self: &Rc, job: ExportJob) -> Option { + let cancel = CancellationToken::new(); + let item = ExportJobItem::new(&file_name(&job.destination), job.expected_rows, cancel.clone()); + let (destination, tab) = (job.destination.clone(), job.tab); + let tasks = self.tasks.clone(); + let running = item.clone(); + self.start(item, cancel.clone(), destination, tab, async move { + run(&tasks, job, &cancel, &running).await + }) + } + + /// Start writing `image`, as `begin` starts an export of rows: listed + /// with Stop, holding the process and the session, and asked about + /// when the window closes. + pub(crate) fn begin_image(self: &Rc, image: ImageExport) -> Option { + let cancel = CancellationToken::new(); + let item = ExportJobItem::whole_file(&file_name(&image.destination), cancel.clone()); + let (destination, tab) = (image.destination.clone(), image.tab); + let running = item.clone(); + self.start(item, cancel.clone(), destination, tab, async move { + run_image(image, &cancel, &running).await + }) + } + + fn start( + self: &Rc, + item: ExportJobItem, + cancel: CancellationToken, + destination: gio::File, + tab: Option, + work: impl Future + 'static, + ) -> Option { if self.state.borrow().shut_down { tracing::debug!("an export began after its workspace shut down, and was not started"); return None; } - let cancel = CancellationToken::new(); - let name = file_name(&job.destination); - let item = ExportJobItem::new(&name, job.expected_rows, cancel.clone()); let id = { let mut state = self.state.borrow_mut(); state.next += 1; @@ -125,7 +165,7 @@ impl ExportLedger { state.running.insert( id, Running { - cancel: cancel.clone(), + cancel, _hold: self.hold.acquire(), }, ); @@ -137,9 +177,7 @@ impl ExportLedger { let (reply, outcome) = oneshot::channel(); let ledger = self.clone(); glib::spawn_future_local(async move { - let destination = job.destination.clone(); - let tab = job.tab; - let ended = run(&ledger.tasks, job, &cancel, &item).await; + let ended = work.await; ledger.settle(id, &item); ledger.reporter.report(&destination, &item.title(), &ended); if let Some(tab) = tab { @@ -362,6 +400,49 @@ async fn run(tasks: &Tasks, job: ExportJob, cancel: &CancellationToken, item: &E } } +/// Write an image's bytes once its task has made them. +async fn run_image(image: ImageExport, cancel: &CancellationToken, item: &ExportJobItem) -> ExportOutcome { + let writer = match ReplaceFileWriter::open(&image.destination).await { + Ok(writer) => writer, + Err(error) => { + return ExportOutcome::Failed { + failure: error.into(), + file: None, + }; + } + }; + show_display_name(&image.destination, item).await; + let Some(made) = until_cancelled(cancel, image.bytes).await else { + return ExportOutcome::Stopped(writer.abort().await); + }; + let bytes = match made { + Ok(Ok(bytes)) => bytes, + Ok(Err(reason)) => return failed(writer, ExportFailure::Image(reason)).await, + Err(failure) => return failed(writer, failure.into()).await, + }; + // Each write runs to its end, as a row export's chunks do: a write + // dropped mid-flight leaves the stream busy, and the abort could not + // close it. Stop is heard between chunks. + for chunk in bytes.chunks(IMAGE_CHUNK) { + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + if let Err(error) = writer.write(chunk.to_vec()).await { + return failed(writer, error.into()).await; + } + } + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + match writer.finish().await { + Ok(()) => ExportOutcome::Saved, + Err(FinishError { error, file }) => ExportOutcome::Failed { + failure: error.into(), + file: Some(file), + }, + } +} + /// `work`'s output, or `None` once the job is stopped. async fn until_cancelled(cancel: &CancellationToken, work: impl Future) -> Option { let stopped = pin!(cancel.cancelled()); diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs index da884b4be7..367c4f8167 100644 --- a/linux/crates/app/src/ui/export/export_ledger/tests.rs +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -780,3 +780,101 @@ fn a_file_name_with_an_ampersand_shows_in_its_toast() { }; wait_until(WAIT, shown).expect("the toast to name the file"); } + +fn image(harness: &Harness, path: &Path, bytes: Result, String>) -> ImageExport { + let tasks = harness.runtime.as_ref().expect("the runtime").tasks(); + ImageExport { + destination: gio::File::for_path(path), + bytes: tasks.spawn_blocking_task(move || bytes), + tab: None, + } +} + +/// An image goes through the ledger as rows do: held and listed while it +/// writes, and said in a toast once it is written. +#[gtk4::test] +fn an_image_is_written_whole_and_reported() { + let harness = Harness::new(); + let path = harness.path("public-er-diagram.png"); + + let handle = harness + .ledger + .begin_image(image(&harness, &path, Ok(b"\x89PNG image".to_vec()))) + .expect("started"); + assert_eq!(harness.hold.live(), 1, "the image was not held"); + let listed = harness + .ledger + .jobs() + .item(0) + .and_downcast::() + .expect("a job"); + assert!(listed.is_whole_file()); + let outcome = harness.finish(handle); + + assert!(matches!(outcome, ExportOutcome::Saved), "{outcome:?}"); + assert_eq!(std::fs::read(&path).expect("the image"), b"\x89PNG image"); + wait_until(WAIT, || harness.hold.live() == 0).expect("the hold to end"); + assert_eq!(harness.ledger.jobs().n_items(), 0); +} + +#[gtk4::test] +fn an_image_that_could_not_be_drawn_leaves_no_file_and_says_why() { + let harness = Harness::new(); + let path = harness.path("er-diagram.svg"); + + let outcome = harness.finish( + harness + .ledger + .begin_image(image(&harness, &path, Err("no surface".to_owned()))) + .expect("started"), + ); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Image(_), + .. + } + ), + "{outcome:?}" + ); + assert!(!path.exists(), "a half-made image was left behind"); + let alert = visible_alert(&harness.window).expect("an alert"); + assert!(alert.body().contains("couldn't be drawn"), "{}", alert.body()); +} + +#[gtk4::test] +fn a_window_closing_while_an_image_writes_asks_first() { + let harness = Harness::new(); + let path = harness.path("er-diagram.png"); + let release = CancellationToken::new(); + let waiting = release.clone(); + let tasks = harness.runtime.as_ref().expect("the runtime").tasks(); + let bytes = tasks.spawn_blocking_task(move || { + while !waiting.is_cancelled() { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(b"late".to_vec()) + }); + let handle = harness + .ledger + .begin_image(ImageExport { + destination: gio::File::for_path(&path), + bytes, + tab: None, + }) + .expect("started"); + + assert!( + harness.ledger.blocking().is_some(), + "the close was not asked about the image" + ); + let stopped = CloseBlocker::stop(&*harness.ledger); + let outcome = harness.finish(handle); + release.cancel(); + finish_on_main(WAIT, stopped).expect("the stop to settle"); + + assert!(matches!(outcome, ExportOutcome::Stopped(_)), "{outcome:?}"); + assert!(!path.exists(), "a stopped image was left behind"); +} diff --git a/linux/crates/app/src/ui/export/export_operations_button.rs b/linux/crates/app/src/ui/export/export_operations_button.rs index f7a06880a4..907071d765 100644 --- a/linux/crates/app/src/ui/export/export_operations_button.rs +++ b/linux/crates/app/src/ui/export/export_operations_button.rs @@ -144,6 +144,11 @@ fn job_row(item: &ExportJobItem) -> gtk::Widget { /// A bar that fills when the total is known, and pulses with each /// chunk written when it is not. fn show_progress(item: &ExportJobItem, progress: >k::ProgressBar, subtitle: >k::Label) { + if item.is_whole_file() { + progress.pulse(); + subtitle.set_label(&gettext("Writing the file…")); + return; + } match item.fraction() { Some(fraction) => progress.set_fraction(fraction), None if item.rows() > 0 => progress.pulse(), diff --git a/linux/crates/app/src/ui/export/export_outcome.rs b/linux/crates/app/src/ui/export/export_outcome.rs index 996c9c89b2..06affba881 100644 --- a/linux/crates/app/src/ui/export/export_outcome.rs +++ b/linux/crates/app/src/ui/export/export_outcome.rs @@ -12,6 +12,9 @@ pub(crate) enum ExportOutcome { Exported { rows: u64, }, + /// A file made whole before it was written, such as an image, which + /// has no rows to count. + Saved, Stopped(AbortOutcome), /// `file` says what became of the file, when the export had opened /// it. @@ -24,7 +27,7 @@ pub(crate) enum ExportOutcome { impl From<&ExportOutcome> for WorkOutcome { fn from(outcome: &ExportOutcome) -> Self { match outcome { - ExportOutcome::Exported { .. } => Self::Succeeded, + ExportOutcome::Exported { .. } | ExportOutcome::Saved => Self::Succeeded, ExportOutcome::Stopped(_) => Self::Cancelled, ExportOutcome::Failed { .. } => Self::Failed, } @@ -48,6 +51,9 @@ pub(crate) enum ExportFailure { Statement(OperationError), #[error("another export is writing to this file")] InUse, + /// The image could not be drawn or encoded. + #[error("the image could not be made: {0}")] + Image(String), } impl From for ExportFailure { diff --git a/linux/crates/app/src/ui/export/mod.rs b/linux/crates/app/src/ui/export/mod.rs index b9f246bfb7..36fce8319b 100644 --- a/linux/crates/app/src/ui/export/mod.rs +++ b/linux/crates/app/src/ui/export/mod.rs @@ -29,7 +29,7 @@ pub(crate) use export_failure_text::{ExportReporter, failure_text}; pub(crate) use export_format::{DocumentFormat, ExportFormat, InsertTarget, RowFormat}; pub(crate) use export_job::{ExpectedRows, ExportJob, ExportJobId}; pub(crate) use export_job_item::ExportJobItem; -pub(crate) use export_ledger::{ExportLedger, ExportLedgerInit}; +pub(crate) use export_ledger::{ExportLedger, ExportLedgerInit, ImageExport}; pub(crate) use export_message::{ExportMessage, ExportStreamFailure}; pub(crate) use export_operations_button::ExportOperationsButton; pub(crate) use export_outcome::{ExportFailure, ExportOutcome}; diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 2c3b80bded..328f033340 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -60,6 +60,9 @@ pub const VIEW_SORT_ASCENDING: &str = "view-sort-ascending-symbolic"; pub const WEATHER_OVERCAST: &str = "weather-overcast-symbolic"; pub const WEB_BROWSER: &str = "web-browser-symbolic"; pub const WINDOW_CLOSE: &str = "window-close-symbolic"; +pub const ZOOM_FIT_BEST: &str = "zoom-fit-best-symbolic"; +pub const ZOOM_IN: &str = "zoom-in-symbolic"; +pub const ZOOM_OUT: &str = "zoom-out-symbolic"; #[cfg_attr( not(test), @@ -127,6 +130,9 @@ pub const ALL: &[&str] = &[ WEATHER_OVERCAST, WEB_BROWSER, WINDOW_CLOSE, + ZOOM_FIT_BEST, + ZOOM_IN, + ZOOM_OUT, ]; /// The theme's icon for each icon a connection can carry. diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 8e1a391ed4..403825d167 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -7,6 +7,7 @@ mod connection_appearance; mod connection_list_banner; pub(crate) mod credential_prompt; mod delete_connection_dialog; +mod diagram; mod discard_alert; mod editor; mod editor_font_provider; diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs index 65fe149f41..7289ffd635 100644 --- a/linux/crates/app/src/ui/tab_load/load_failure.rs +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -16,6 +16,8 @@ pub(crate) enum LoadFailure { Page(Arc), /// The table's definition, for the structure tab. Definition(Arc), + /// A schema's tables or one of them, for the diagram. + Schema(Arc), /// The key a key tab shows is gone: deleted, renamed or expired. KeyMissing, } @@ -25,7 +27,7 @@ impl LoadFailure { /// shows it must not read it as markup. pub(crate) fn message(&self) -> String { match self { - Self::Columns(error) | Self::Page(error) | Self::Definition(error) => { + Self::Columns(error) | Self::Page(error) | Self::Definition(error) | Self::Schema(error) => { crate::ui::error_text::operation_error_message(error) } Self::NoColumns => crate::i18n::gettext( @@ -44,7 +46,9 @@ impl LoadFailure { /// server's text. pub(crate) fn category(&self) -> Option { match self { - Self::Columns(error) | Self::Page(error) | Self::Definition(error) => error.category(), + Self::Columns(error) | Self::Page(error) | Self::Definition(error) | Self::Schema(error) => { + error.category() + } Self::NoColumns | Self::ColumnsChanged | Self::KeyMissing => None, } } diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs index 9266770162..71cb5e858a 100644 --- a/linux/crates/app/src/ui/workspace/close_flow.rs +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -23,7 +23,9 @@ fn take_editor_sessions(tabs: &WorkspaceTabs) -> Vec> { .values() .filter_map(|tab| match tab { WorkspaceTab::Editor(slot) => slot.editor.take_editor_session(), - WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) | WorkspaceTab::Key(_) => None, + WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) | WorkspaceTab::Key(_) | WorkspaceTab::Diagram(_) => { + None + } }) .collect() } @@ -72,7 +74,10 @@ impl WorkspaceModel { .filter(|(tab, _)| id.is_none_or(|id| id == **tab)) .filter_map(|(_, tab)| match tab { WorkspaceTab::Table(slot) => Some(slot.browse.clone()), - WorkspaceTab::Editor(_) | WorkspaceTab::Structure(_) | WorkspaceTab::Key(_) => None, + WorkspaceTab::Editor(_) + | WorkspaceTab::Structure(_) + | WorkspaceTab::Key(_) + | WorkspaceTab::Diagram(_) => None, }) .collect() } diff --git a/linux/crates/app/src/ui/workspace/diagram.rs b/linux/crates/app/src/ui/workspace/diagram.rs new file mode 100644 index 0000000000..ed844713dc --- /dev/null +++ b/linux/crates/app/src/ui/workspace/diagram.rs @@ -0,0 +1,39 @@ +use super::tab_spec::TabSpec; +use super::{WorkspaceModel, WorkspaceTab}; +use crate::workspace::DiagramPlace; + +impl WorkspaceModel { + /// Bring the schema's diagram to the front, or open one with `places`, + /// where a reopened tab's tables were put: two tabs of one schema would + /// read every table twice. + pub(super) fn show_diagram(&mut self, schema: Option, places: Vec) { + let existing = self.workspace_tabs.borrow().values().find_map(|tab| match tab { + WorkspaceTab::Diagram(slot) if slot.schema == schema => Some(slot.page.clone()), + _ => None, + }); + match existing { + Some(page) => self.tab_view.set_selected_page(&page), + None => { + self.open_tab(TabSpec::Diagram { schema, places }); + } + } + } + + /// A table changed. Each diagram it may be in says it may be out of + /// date: those of its schema, or every one when the change cannot + /// say which tables it touched. + pub(super) fn diagrams_out_of_date(&self, schema: Option<&str>, named: bool) { + let diagrams: Vec<_> = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Diagram(slot) if !named || slot.schema.as_deref() == schema => Some(slot.diagram.clone()), + _ => None, + }) + .collect(); + for diagram in diagrams { + diagram.schema_changed(); + } + } +} diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index a8181c1e8d..3a61de3783 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -3,6 +3,7 @@ //! disconnect drops it and the window shows the connection picker. mod close_flow; +mod diagram; mod editor_label; mod finished_work; mod health_banner; @@ -239,6 +240,16 @@ fn draft_failure_watch( ) } +/// What the tab view's parts are built from. +struct TabsInit<'a> { + key: &'a SessionKey, + session: &'a Arc, + services: &'a AppServices, + context: &'a Rc, + drafts: &'a Rc, + exports: &'a Rc, +} + struct WorkspaceTabParts { tab_view: adw::TabView, index: Rc, @@ -271,6 +282,10 @@ pub enum ClosedTabDescriptor { schema: Option, table: String, }, + Diagram { + schema: Option, + places: Vec, + }, Key { key: String, }, @@ -304,6 +319,13 @@ pub struct StructureTabSlot { pub mode: crate::ui::structure_tab::StructureMode, } +/// A tab showing a schema's ER diagram. +pub struct DiagramTabSlot { + pub page: adw::TabPage, + pub schema: Option, + pub diagram: crate::ui::diagram::DiagramTab, +} + /// A tab showing one table's rows. The table's structure opens in a /// tab of its own: one TabPage per surface, as GNOME Files and Builder /// do. @@ -329,10 +351,12 @@ pub struct KeyTabSlot { /// table. /// - **Structure**: the DDL editor, for a new table's draft or an /// existing table. +/// - **Diagram**: a schema's ER diagram. pub enum WorkspaceTab { Editor(EditorTabSlot), Structure(StructureTabSlot), Table(TableTabSlot), + Diagram(DiagramTabSlot), Key(KeyTabSlot), } @@ -359,7 +383,7 @@ impl WorkspaceTab { match self { WorkspaceTab::Table(s) => Some(tablepro_core::meta::TableRef::new(s.schema.clone(), s.table.clone())), WorkspaceTab::Key(s) => Some(tablepro_core::meta::TableRef::unqualified(s.tab.key())), - WorkspaceTab::Editor(_) | WorkspaceTab::Structure(_) => None, + WorkspaceTab::Editor(_) | WorkspaceTab::Structure(_) | WorkspaceTab::Diagram(_) => None, } } @@ -368,6 +392,7 @@ impl WorkspaceTab { WorkspaceTab::Editor(slot) => &slot.page, WorkspaceTab::Structure(slot) => &slot.page, WorkspaceTab::Table(slot) => &slot.page, + WorkspaceTab::Diagram(slot) => &slot.page, WorkspaceTab::Key(slot) => &slot.page, } } @@ -385,6 +410,7 @@ impl WorkspaceTab { slot.schema.clone(), slot.table.clone(), )), + WorkspaceTab::Diagram(slot) => TabIdentity::Diagram(slot.schema.clone()), WorkspaceTab::Key(slot) => TabIdentity::Key(slot.tab.key().to_owned()), } } @@ -547,6 +573,10 @@ pub(crate) enum WorkspaceInput { NewTableTab { schema: Option, }, + /// The schema's ER diagram: its tab, or a new one. + ShowDiagram { + schema: Option, + }, EditStructureTab { schema: Option, table: String, @@ -619,18 +649,19 @@ impl Workspace { let imports = import_ledger::ImportLedger::new(context.hold.clone(), window_inhibitor(&window)); let keys = holds_keys(&context, &session); let sidebar = sidebar::build(&workspace, &input, &session, &tasks, keys); - let WorkspaceTabParts { - tab_view, - index, - workspace_tabs, - change_trackers, - structure_trackers, - naming, - factory, - workspace_handle, - } = workspace.build_tabs(&key, &session, &services, &context, &drafts, &input); + let tabs = workspace.build_tabs( + TabsInit { + key: &key, + session: &session, + services: &services, + context: &context, + drafts: &drafts, + exports: &exports, + }, + &input, + ); let mut actions = workspace_actions::install(&window, &input); - let shell = shell::install(&workspace, &window, &factory, &services, &input); + let shell = shell::install(&workspace, &window, &tabs.factory, &services, &input); actions.push(shell::SHOW_SIDEBAR); let history_gate = workspace_actions::spawn_history_gate(&window, history.availability()); let watches = SessionWatches::new(&session, &input); @@ -670,14 +701,14 @@ impl Workspace { ssh: services.ssh(), file_revealer: services.file_revealer.clone(), reviewing_host_key: false, - tab_view, - index, - factory, - naming, - workspace_tabs, - change_trackers, - structure_trackers, - workspace_handle, + tab_view: tabs.tab_view, + index: tabs.index, + factory: tabs.factory, + naming: tabs.naming, + workspace_tabs: tabs.workspace_tabs, + change_trackers: tabs.change_trackers, + structure_trackers: tabs.structure_trackers, + workspace_handle: tabs.workspace_handle, close_guards, exports: exports.clone(), imports: imports.clone(), @@ -732,15 +763,15 @@ impl Workspace { /// The tab view's registries, the factory that builds every tab into /// them, and the handle that saves and settles their changes. - fn build_tabs( - &self, - key: &SessionKey, - session: &Arc, - services: &AppServices, - context: &Rc, - drafts: &Rc, - input: &WorkspaceSender, - ) -> WorkspaceTabParts { + fn build_tabs(&self, init: TabsInit<'_>, input: &WorkspaceSender) -> WorkspaceTabParts { + let TabsInit { + key, + session, + services, + context, + drafts, + exports, + } = init; let change_trackers = Rc::new(ChangeTrackerRegistry::default()); let structure_trackers = Rc::new(StructureTrackerRegistry::default()); let workspace_tabs: workspace_persistence::WorkspaceTabs = Rc::new(RefCell::new(HashMap::new())); @@ -783,6 +814,7 @@ impl Workspace { draft_scope: crate::workspace::WorkspaceFileName::for_key(key).draft_scope(), change_trackers: change_trackers.clone(), structure_trackers: structure_trackers.clone(), + exports: exports.clone(), read_only: session.read_only(), naming: naming.clone(), }); @@ -1088,6 +1120,7 @@ impl WorkspaceModel { WorkspaceInput::ShowToast(msg) => self.show_toast(&msg), WorkspaceInput::TabDirtyChanged => self.refresh_unsaved(), WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), + WorkspaceInput::ShowDiagram { schema } => self.show_diagram(schema, Vec::new()), WorkspaceInput::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table), WorkspaceInput::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table), WorkspaceInput::ShowCreateTableLoaded(sql) => self.open_editor_tab(Some(sql)), diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs index 36d99c20f9..3974f8cf3f 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/mod.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -133,6 +133,7 @@ fn workspace_input(event: SidebarEvent) -> WorkspaceInput { table: table.name, }, SidebarEvent::NewTable(schema) => WorkspaceInput::NewTableTab { schema }, + SidebarEvent::ShowDiagram(schema) => WorkspaceInput::ShowDiagram { schema }, } } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs index ca7c294729..dc71d6d3b2 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs @@ -12,4 +12,6 @@ pub(crate) enum SidebarEvent { DropTable(TableRef), /// A new table in this schema, or the default one. NewTable(Option), + /// This schema's ER diagram, or the default one's. + ShowDiagram(Option), } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs index e6eedc7031..36d59eacf5 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs @@ -3,14 +3,15 @@ use std::rc::Weak; use gtk4 as gtk; use gtk4::prelude::*; -use super::sidebar_menu::{GROUP, NEW_TABLE}; +use super::sidebar_menu::{GROUP, NEW_TABLE, SHOW_SCHEMA_DIAGRAM}; use super::sidebar_object::SidebarObject; use super::sidebar_view::SidebarInner; use crate::ui::a11y::describe_icon_button; -/// A schema's heading, with its New Table button where the connection -/// creates tables. ClickHouse does not: a table there needs an engine and -/// a sorting key the structure tab has no place for, so the button would +/// A schema's heading, with its ER Diagram button where the connection +/// holds tables, and its New Table button where the connection creates +/// them. ClickHouse does not: a table there needs an engine and a +/// sorting key the structure tab has no place for, so the button would /// only fail. pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFactory { let factory = gtk::SignalListItemFactory::new(); @@ -30,16 +31,9 @@ pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFac // GtkPlacesSidebar's section header: small, bold and dim. title.add_css_class("caption-heading"); title.add_css_class("dimmed"); - let add = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .valign(gtk::Align::Center) - .build(); - add.add_css_class("flat"); - // The target first, or GTK warns that the action takes one. - add.set_action_target_value(Some(&None::.to_variant())); - add.set_action_name(Some(&format!("{GROUP}.{NEW_TABLE}"))); row.append(&title); - row.append(&add); + row.append(&header_button(crate::ui::icons::TABLE_RELATION, SHOW_SCHEMA_DIAGRAM)); + row.append(&header_button(crate::ui::icons::LIST_ADD, NEW_TABLE)); header.set_child(Some(&row)); }); factory.connect_bind(move |_, object| { @@ -50,15 +44,26 @@ pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFac return; }; let title = object.section_title(); - if let Some(label) = row.first_child().and_downcast::() { - label.set_label(&title); - } - let Some(add) = row.last_child().and_downcast::() else { + let Some(label) = row.first_child().and_downcast::() else { + return; + }; + label.set_label(&title); + let (Some(diagram), Some(add)) = ( + label.next_sibling().and_downcast::(), + row.last_child().and_downcast::(), + ) else { return; }; + let schema = object.schema(); + diagram.set_visible(!view.upgrade().is_some_and(|view| view.holds_keys())); + diagram.set_action_target_value(Some(&schema.to_variant())); + let diagram_label = match schema { + Some(_) => crate::i18n::gettext_f("Show ER Diagram of {schema}", &[("schema", &title)]), + None => crate::i18n::gettext("Show ER Diagram"), + }; + describe_icon_button(&diagram, &diagram_label, None); let offered = view.upgrade().is_some_and(|view| view.affordances().offers_new_table()); add.set_visible(offered); - let schema = object.schema(); add.set_action_target_value(Some(&schema.to_variant())); // The label says which schema the table goes in. let label = match schema { @@ -69,3 +74,15 @@ pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFac }); factory } + +fn header_button(icon: &str, action: &str) -> gtk::Button { + let button = gtk::Button::builder() + .icon_name(icon) + .valign(gtk::Align::Center) + .build(); + button.add_css_class("flat"); + // The target first, or GTK warns that the action takes one. + button.set_action_target_value(Some(&None::.to_variant())); + button.set_action_name(Some(&format!("{GROUP}.{action}"))); + button +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs index 1916a04d7c..bf67cab46a 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs @@ -10,6 +10,10 @@ pub(super) const SHOW_CREATE_TABLE: &str = "show-create-table"; pub(super) const IMPORT_CSV: &str = "import-csv"; pub(super) const DROP_TABLE: &str = "drop-table"; pub(super) const NEW_TABLE: &str = "new-table"; +pub(super) const SHOW_DIAGRAM: &str = "show-diagram"; +/// The section header's button, whose target is the schema, as New +/// Table's is. +pub(super) const SHOW_SCHEMA_DIAGRAM: &str = "show-schema-diagram"; pub(super) const CONTEXT_MENU: &str = "context-menu"; pub(super) const GROUP: &str = "sidebar"; @@ -22,11 +26,13 @@ pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: Sideba let menu = gio::Menu::new(); let open = gio::Menu::new(); open.append_item(&item(&crate::i18n::gettext("Open in New Tab"), OPEN_IN_NEW_TAB, key)); - menu.append_section(None, &open); - // A key has no SQL to show and no structure to change. + // A key has no SQL to show, no structure to change and no diagram. if !kind.is_browsable() { + menu.append_section(None, &open); return menu; } + open.append_item(&item(&crate::i18n::gettext("Show ER Diagram"), SHOW_DIAGRAM, key)); + menu.append_section(None, &open); let table = kind.is_structure_editable(); let structure = gio::Menu::new(); if table && affordances.can_edit_structure { @@ -56,7 +62,7 @@ pub(super) fn offers(action: &str, kind: ObjectKind, affordances: SidebarAfforda let table = kind.is_structure_editable(); match action { OPEN_IN_NEW_TAB => true, - SHOW_CREATE_TABLE => kind.is_browsable(), + SHOW_CREATE_TABLE | SHOW_DIAGRAM => kind.is_browsable(), EDIT_STRUCTURE => table && affordances.can_edit_structure, IMPORT_CSV | DROP_TABLE => table && !affordances.read_only, _ => false, @@ -113,7 +119,7 @@ mod tests { let menu = object_menu(&key, ObjectKind::Table, all()); let found = items(menu.upcast_ref()); - assert_eq!(found.len(), 5); + assert_eq!(found.len(), 6); for (action, target) in found { let target = target.unwrap_or_else(|| panic!("{action} has no target")); assert_eq!(ObjectKey::from_variant(&target).as_ref(), Some(&key), "{action}"); @@ -132,6 +138,7 @@ mod tests { actions(&object_menu(&key, ObjectKind::Table, read_only)), [ "sidebar.open-in-new-tab", + "sidebar.show-diagram", "sidebar.edit-structure", "sidebar.show-create-table" ] @@ -158,7 +165,7 @@ mod tests { let kind = ObjectKind::Key(tablepro_core::meta::KeyKind::Hash); assert_eq!(actions(&object_menu(&key, kind, all())), ["sidebar.open-in-new-tab"]); - for action in [SHOW_CREATE_TABLE, EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { + for action in [SHOW_CREATE_TABLE, SHOW_DIAGRAM, EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { assert!(!offers(action, kind, all()), "{action}"); } } @@ -169,7 +176,14 @@ mod tests { let offered = actions(&object_menu(&key, ObjectKind::View, all())); - assert_eq!(offered, ["sidebar.open-in-new-tab", "sidebar.show-create-table"]); + assert_eq!( + offered, + [ + "sidebar.open-in-new-tab", + "sidebar.show-diagram", + "sidebar.show-create-table" + ] + ); for action in [EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { assert!(!offers(action, ObjectKind::View, all()), "{action}"); assert!(offers(action, ObjectKind::PartitionedTable, all()), "{action}"); diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs index fafb411b63..328d8b60d0 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs @@ -16,7 +16,7 @@ use super::sidebar_header_factory::header_factory; use super::sidebar_index::SidebarIndex; use super::sidebar_menu::{ CONTEXT_MENU, DROP_TABLE, EDIT_STRUCTURE, GROUP, IMPORT_CSV, NEW_TABLE, OPEN_IN_NEW_TAB, SHOW_CREATE_TABLE, - object_menu, offers, + SHOW_DIAGRAM, SHOW_SCHEMA_DIAGRAM, object_menu, offers, }; use super::sidebar_model::{RowsAsked, RowsWorkedOut, SidebarModel}; use super::sidebar_row_factory::row_factory; @@ -279,6 +279,10 @@ impl SidebarInner { self.affordances.get() } + pub(super) fn holds_keys(&self) -> bool { + self.keys.get() + } + pub(super) fn track_row(&self, item: >k::ListItem) { let mut rows = self.rows.borrow_mut(); // A search makes two hundred rows at once, so the rows gone are @@ -416,6 +420,7 @@ impl SidebarInner { SHOW_CREATE_TABLE => SidebarEvent::ShowCreateTable(table), IMPORT_CSV => SidebarEvent::ImportCsv(table), DROP_TABLE => SidebarEvent::DropTable(table), + SHOW_DIAGRAM => SidebarEvent::ShowDiagram(table.schema), _ => return, }; (self.events)(event); @@ -432,6 +437,14 @@ impl SidebarInner { (self.events)(SidebarEvent::NewTable(schema.filter(|schema| !schema.is_empty()))); } + fn show_schema_diagram(&self, target: Option<&glib::Variant>) { + let Some(schema) = target.and_then(|target| target.get::>()) else { + tracing::warn!("a diagram was asked for without a schema"); + return; + }; + (self.events)(SidebarEvent::ShowDiagram(schema.filter(|schema| !schema.is_empty()))); + } + fn show_rows(&self, worked_out: RowsWorkedOut) { let previous = self.model.shown_index(); if let Err(overtaken) = self.model.show(worked_out) { @@ -518,6 +531,7 @@ fn work_out_rows(inner: &Rc, asked: RowsAsked) { fn install_actions(inner: &Rc) { for name in [ OPEN_IN_NEW_TAB, + SHOW_DIAGRAM, EDIT_STRUCTURE, SHOW_CREATE_TABLE, IMPORT_CSV, @@ -540,6 +554,14 @@ fn install_actions(inner: &Rc) { } }); inner.actions.add_action(&new_table); + let diagram = gio::SimpleAction::new(SHOW_SCHEMA_DIAGRAM, Some(&Option::::static_variant_type())); + let view = Rc::downgrade(inner); + diagram.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.show_schema_diagram(target); + } + }); + inner.actions.add_action(&diagram); let menu = gio::SimpleAction::new(CONTEXT_MENU, None); let view = Rc::downgrade(inner); menu.connect_activate(move |_, _| { diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs index cbe78efc1a..a6382c745a 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tests.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -520,9 +520,8 @@ fn the_new_table_button_follows_the_dialect() { let shown = Shown::new(&["orders"], every_affordance()); wait_until(WAIT, || !headers(&shown.list_view()).is_empty()).expect("a section header"); assert_eq!(headers(&shown.list_view()), [("public".to_owned(), true)]); - let button = descendants(&shown.list_view()) - .into_iter() - .find_map(|widget| widget.downcast::().ok()) + let button = crate::test_support::find_by_action_name(&shown.list_view(), "sidebar.new-table") + .and_downcast::() .expect("the New Table button"); assert_eq!(button.tooltip_text().as_deref(), Some("New Table in public…")); button.emit_clicked(); @@ -536,6 +535,52 @@ fn the_new_table_button_follows_the_dialect() { .expect("the button hidden once the server cannot create tables"); } +/// Every engine draws a diagram, so the button stays where New Table +/// goes away. +#[gtk4::test] +fn each_schema_heading_shows_its_er_diagram() { + let shown = Shown::new(&["orders"], every_affordance()); + wait_until(WAIT, || !headers(&shown.list_view()).is_empty()).expect("a section header"); + let button = crate::test_support::find_by_action_name(&shown.list_view(), "sidebar.show-schema-diagram") + .and_downcast::() + .expect("the ER Diagram button"); + assert_eq!(button.tooltip_text().as_deref(), Some("Show ER Diagram of public")); + crate::test_support::assert_labelled(&button).expect("the ER Diagram button is named"); + + button.emit_clicked(); + + assert_eq!(shown.taken(), [SidebarEvent::ShowDiagram(Some("public".to_owned()))]); + shown.view.set_affordances(SidebarAffordances { + can_create_table: false, + ..every_affordance() + }); + wait_until(WAIT, || headers(&shown.list_view()) == [("public".to_owned(), false)]).expect("New Table hidden"); + let button = crate::test_support::find_by_action_name(&shown.list_view(), "sidebar.show-schema-diagram") + .expect("the ER Diagram button"); + assert!(button.is_visible()); +} + +#[gtk4::test] +fn a_heading_of_keys_offers_no_er_diagram() { + let shown = Shown::new(&[], every_affordance()); + shown.view.hold_keys(); + let key = ObjectInfo { + schema: None, + name: "user:1".to_owned(), + kind: ObjectKind::Key(tablepro_core::meta::KeyKind::Hash), + }; + shown.view.show_keys( + super::sidebar_index::SidebarIndex::keys(&[key], "db0".to_owned()), + false, + ); + wait_until(WAIT, || !headers(&shown.list_view()).is_empty()).expect("the keys' heading"); + + let button = crate::test_support::find_by_action_name(&shown.list_view(), "sidebar.show-schema-diagram") + .expect("the heading's ER Diagram button"); + + assert!(!button.is_visible(), "keys have no diagram"); +} + #[gtk4::test] fn sync_selection_follows_the_tab_and_survives_a_new_list() { let shown = Shown::new(&["orders", "users"], every_affordance()); diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs index 96157ccd26..d435aaee24 100644 --- a/linux/crates/app/src/ui/workspace/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -361,6 +361,7 @@ impl WorkspaceModel { let Some(session) = self.context.active.get() else { return; }; + self.diagrams_out_of_date(schema.as_deref(), table.is_some()); if let Some(table) = table { session.invalidate_table(&TableRef::new(schema.clone(), table.clone())); let (browse_tabs, structure_tabs): (Vec<_>, Vec<_>) = self diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 3ed6486751..28f2c02886 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -18,8 +18,8 @@ use super::tab_title::resolve_tab_title; use super::table_naming::TableNaming; use super::workspace_persistence::WorkspaceTabs; use super::{ - EditorTabSlot, KeyTabSlot, StructureTabSlot, TableTabSlot, WorkspaceInput, WorkspaceSender, WorkspaceTab, - apply_tab_title, + DiagramTabSlot, EditorTabSlot, KeyTabSlot, OpenMode, StructureTabSlot, TableTabSlot, WorkspaceInput, + WorkspaceSender, WorkspaceTab, apply_tab_title, }; use crate::runtime::DeliverInput; use crate::services::change_tracker_registry::ChangeTrackerRegistry; @@ -27,6 +27,7 @@ use crate::services::history_service::HistoryService; use crate::services::session_context::SessionContext; use crate::services::structure_tracker_registry::StructureTrackerRegistry; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, TableFilter}; +use crate::ui::diagram::{DiagramTab, DiagramTabInit}; use crate::ui::editor::{SqlEditor, SqlEditorInit}; use crate::ui::key_tab::{KeyTab, KeyTabEvent, KeyTabInit}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; @@ -49,6 +50,8 @@ pub(super) struct TabFactoryParts { pub draft_scope: DraftScope, pub change_trackers: Rc, pub structure_trackers: Rc, + /// The exports a diagram writes its images through. + pub exports: Rc, pub read_only: bool, /// Whether a tab's title names the table's schema. pub naming: Rc>, @@ -102,6 +105,14 @@ impl TabFactory { }, )?, TabSpec::Structure { target } => self.structure(id, target, first_load)?, + TabSpec::Diagram { schema, places } => self.diagram( + id, + DiagramOpening { + schema, + places, + first_load, + }, + )?, TabSpec::Key { key } => self.key(id, key, first_load)?, }; Some(self.insert(id, slot)) @@ -250,6 +261,30 @@ impl TabFactory { })) } + fn diagram(&self, id: Uuid, opening: DiagramOpening) -> Option { + let parts = &self.parts; + let session = parts.context.active.get()?; + let DiagramOpening { + schema, + places, + first_load, + } = opening; + let diagram = DiagramTab::new(DiagramTabInit { + schema: schema.clone(), + session, + tasks: parts.context.tasks.clone(), + exports: parts.exports.clone(), + tab_id: id, + first_load, + places, + }); + connect_diagram(&diagram, &parts.input); + let page = self.append(&diagram, id, None); + let follow = follow_loading(&page, diagram.is_loading()); + diagram.connect_loading_changed(move |_, loading| follow(loading)); + Some(WorkspaceTab::Diagram(DiagramTabSlot { page, schema, diagram })) + } + fn key(&self, id: Uuid, key: String, first_load: FirstLoad) -> Option { let parts = &self.parts; let session = parts.context.active.get()?; @@ -288,6 +323,13 @@ impl TabFactory { } } +/// What a diagram tab opens on. +struct DiagramOpening { + schema: Option, + places: Vec, + first_load: FirstLoad, +} + /// What a table tab opens on, and where its page goes. struct TableOpening<'a> { table: TableRef, @@ -338,6 +380,28 @@ fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &WorkspaceSe structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); } +/// The diagram's signals, as the workspace's inputs: a table it names +/// opens in a table or structure tab of its own. +fn connect_diagram(diagram: &DiagramTab, input: &WorkspaceSender) { + let open = input.clone(); + diagram.connect_open_table(move |_, table| { + open.deliver(WorkspaceInput::SelectTable { + schema: table.schema, + name: table.name, + open_mode: OpenMode::SwitchOrAppend, + }); + }); + let structure = input.clone(); + diagram.connect_show_structure(move |_, table| { + structure.deliver(WorkspaceInput::EditStructureTab { + schema: table.schema, + table: table.name, + }); + }); + let layout = input.clone(); + diagram.connect_layout_changed(move |_| layout.deliver(WorkspaceInput::WorkspaceTabsChanged)); +} + /// The tab bar spins on a tab's page while the tab reads. The tab /// started reading before it had a page, so the page starts from where /// the read stands. The tab holds the handler and the page holds the diff --git a/linux/crates/app/src/ui/workspace/tab_identity.rs b/linux/crates/app/src/ui/workspace/tab_identity.rs index a60578d1de..e9a31c8730 100644 --- a/linux/crates/app/src/ui/workspace/tab_identity.rs +++ b/linux/crates/app/src/ui/workspace/tab_identity.rs @@ -9,6 +9,8 @@ pub(crate) enum TabIdentity { Table(TableRef), Structure(StructureTarget), Editor(EditorLabel), + /// The ER diagram of a schema. + Diagram(Option), /// A key-value engine's key, by the name the sidebar lists it as. Key(String), } diff --git a/linux/crates/app/src/ui/workspace/tab_spec.rs b/linux/crates/app/src/ui/workspace/tab_spec.rs index 8bdb711f53..3872f9995c 100644 --- a/linux/crates/app/src/ui/workspace/tab_spec.rs +++ b/linux/crates/app/src/ui/workspace/tab_spec.rs @@ -3,7 +3,7 @@ use tablepro_storage::DraftId; use super::structure_target::StructureTarget; use crate::ui::browse_tab::TableFilter; -use crate::workspace::EditorFileRecord; +use crate::workspace::{DiagramPlace, EditorFileRecord}; /// A tab to open. Every way a tab opens builds one of these: the sidebar, /// the overview's new-tab button, a restore and a reopen. @@ -25,6 +25,13 @@ pub(crate) enum TabSpec { Structure { target: StructureTarget, }, + /// The ER diagram of a schema, or of the default one of an engine + /// without schemas. + Diagram { + schema: Option, + /// Where the user put tables, for a restored or reopened tab. + places: Vec, + }, /// A key-value engine's key, by the name the sidebar lists it as. Key { key: String, diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs index f6d0ce251c..91b48c28bf 100644 --- a/linux/crates/app/src/ui/workspace/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -34,6 +34,13 @@ pub(crate) fn resolve_tab_title(identity: &TabIdentity, naming: TableNaming) -> label: crate::i18n::gettext("New Table"), tooltip: None, }, + TabIdentity::Diagram(schema) => TabTitle { + label: match schema { + Some(schema) => crate::i18n::gettext_f("{schema} · ER Diagram", &[("schema", schema)]), + None => crate::i18n::gettext("ER Diagram"), + }, + tooltip: None, + }, TabIdentity::Editor(EditorLabel::Untitled(ordinal)) => TabTitle { label: numbered_query(*ordinal), tooltip: None, @@ -122,6 +129,16 @@ mod tests { assert_eq!(title.tooltip, None); } + #[test] + fn a_diagram_is_named_for_its_schema() { + let public = resolve_tab_title(&TabIdentity::Diagram(Some("public".to_owned())), TableNaming::Bare); + let unnamed = resolve_tab_title(&TabIdentity::Diagram(None), TableNaming::SchemaQualified); + + assert_eq!(public.label, "public · ER Diagram"); + assert_eq!(unnamed.label, "ER Diagram"); + assert_eq!(public.tooltip, None); + } + #[test] fn blank_table_name_resolves_to_new_table() { let identity = TabIdentity::Structure(StructureTarget::of(Some("public"), "")); diff --git a/linux/crates/app/src/ui/workspace/tests/diagram.rs b/linux/crates/app/src/ui/workspace/tests/diagram.rs new file mode 100644 index 0000000000..2245c2fb6d --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tests/diagram.rs @@ -0,0 +1,273 @@ +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::meta::{ObjectInfo, ObjectKind}; +use tablepro_core::{ColumnInfo, ForeignKeyInfo}; +use tablepro_test_support::{ConnectionScript, FakeReply}; + +use super::super::sidebar::SidebarView; +use super::super::{WorkspaceInput, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::test_support::{drain_main_context, first_descendant_of_type, wait_until}; +use crate::ui::diagram::DiagramTab; +use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; +use crate::workspace::{DiagramPlace, TabRecord, WorkspaceRecord}; + +fn column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("integer"), + nullable: false, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +/// `sales` holds customers and orders, with orders' key to customers. +fn sales(script: &mut ConnectionScript) { + let object = |name: &str| ObjectInfo { + schema: Some("sales".to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + }; + script + .list_objects + .always(FakeReply::ok(vec![object("customers"), object("orders")])); + script.fetch_columns.when_sql_contains( + "orders", + FakeReply::ok(vec![column("id", true), column("customer_id", false)]), + ); + script.fetch_columns.always(FakeReply::ok(vec![column("id", true)])); + script.fetch_foreign_keys.when_sql_contains( + "orders", + FakeReply::ok(vec![ForeignKeyInfo { + name: "orders_customer".to_owned(), + columns: vec!["customer_id".to_owned()], + ref_schema: Some("sales".to_owned()), + ref_table: "customers".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }]), + ); + script.fetch_foreign_keys.always(FakeReply::ok(Vec::new())); + script.fetch_indexes.always(FakeReply::ok(Vec::new())); +} + +fn diagrams(test: &TestWindow) -> Vec { + test.workspace() + .read(|model| { + model + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Diagram(slot) => Some(slot.diagram.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default() +} + +fn show_diagram(test: &TestWindow, schema: &str) { + test.workspace() + .input() + .expect("the workspace's input") + .deliver(WorkspaceInput::ShowDiagram { + schema: Some(schema.to_owned()), + }); +} + +fn drawn(diagram: &DiagramTab) { + wait_until(WAIT, || diagram.canvas().table_count() == 2).expect("the diagram's tables"); +} + +#[gtk4::test] +fn a_schema_has_one_diagram_tab_titled_for_it() { + let test = TestWindow::connected_with("DiagramOneTab", sales); + let view = test.workspace().imp().tab_view.get(); + + show_diagram(&test, "sales"); + wait_until(WAIT, || diagrams(&test).len() == 1).expect("a diagram tab"); + let diagram = diagrams(&test).remove(0); + drawn(&diagram); + let page = view.selected_page().expect("the diagram in front"); + assert_eq!(page.child(), diagram.clone().upcast::()); + assert_eq!(page.title(), "sales · ER Diagram"); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("a new editor tab"); + drain_main_context(); + + show_diagram(&test, "sales"); + drain_main_context(); + + assert_eq!(diagrams(&test).len(), 1, "a second Show brings the first tab forward"); + assert_eq!(view.selected_page().map(|page| page.child()), Some(diagram.upcast())); +} + +#[gtk4::test] +fn the_sidebar_shows_a_tables_schema_as_a_diagram() { + let test = TestWindow::connected_with("DiagramFromSidebar", sales); + let sidebar: SidebarView = test + .workspace() + .read(|model| model.sidebar.clone()) + .expect("the sidebar"); + wait_until(WAIT, || sidebar.shown() == 2).expect("the sidebar's tables"); + let list: gtk4::ListView = first_descendant_of_type(sidebar.widget()).expect("the sidebar's list"); + let key = (Some("sales".to_owned()), "orders".to_owned()).to_variant(); + + WidgetExt::activate_action(&list, "sidebar.show-diagram", Some(&key)).expect("the menu's Show ER Diagram"); + + wait_until(WAIT, || diagrams(&test).len() == 1).expect("a diagram tab"); + let diagram = diagrams(&test).remove(0); + assert_eq!(diagram.schema().as_deref(), Some("sales")); + WidgetExt::activate_action(&list, "sidebar.show-schema-diagram", Some(&Some("sales").to_variant())) + .expect("the section header's button"); + drain_main_context(); + assert_eq!(diagrams(&test).len(), 1); +} + +#[gtk4::test] +fn the_diagram_opens_a_tables_rows_and_structure() { + let test = TestWindow::connected_with("DiagramOpensTables", sales); + show_diagram(&test, "sales"); + wait_until(WAIT, || diagrams(&test).len() == 1).expect("a diagram tab"); + let diagram = diagrams(&test).remove(0); + drawn(&diagram); + let canvas = diagram.canvas(); + let orders = (0..2) + .find(|&at| canvas.table(at).is_some_and(|table| table.name == "orders")) + .expect("orders"); + canvas.focus_table(orders); + + WidgetExt::activate_action(&diagram, "diagram.open-table", None).expect("Open Table"); + let opened = |test: &TestWindow| { + test.workspace() + .read(|model| { + model.workspace_tabs.borrow().values().any(|tab| { + matches!(tab, WorkspaceTab::Table(slot) if slot.table == "orders" && slot.schema.as_deref() == Some("sales")) + }) + }) + .unwrap_or_default() + }; + wait_until(WAIT, || opened(&test)).expect("the table's tab"); + WidgetExt::activate_action(&diagram, "diagram.show-structure", None).expect("Show Structure"); + let structure = |test: &TestWindow| { + test.workspace() + .read(|model| { + model + .workspace_tabs + .borrow() + .values() + .any(|tab| matches!(tab, WorkspaceTab::Structure(slot) if slot.table == "orders")) + }) + .unwrap_or_default() + }; + wait_until(WAIT, || structure(&test)).expect("the structure tab"); +} + +#[gtk4::test] +fn a_closed_diagram_reopens_and_a_saved_one_comes_back() { + let test = TestWindow::with_script( + "DiagramRestore", + Script { + connection: { + let mut script = ConnectionScript::default(); + sales(&mut script); + script + }, + ..Default::default() + }, + ); + let store = test.services.storage.workspaces().clone(); + store + .schedule( + &test.key(), + WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Diagram { + schema: Some("sales".to_owned()), + places: vec![placed()], + }], + }, + ) + .expect("the record to queue"); + crate::test_support::finish_on_main(WAIT, store.flush(&test.key())) + .expect("the flush to end") + .expect("the record written"); + + test.window + .send(crate::ui::SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + wait_until(WAIT, || diagrams(&test).len() == 1).expect("the saved diagram"); + drawn(&diagrams(&test)[0]); + assert!( + diagrams(&test)[0].places().contains(&placed()), + "the moved table is back where it was put" + ); + let input = test.workspace().input().expect("the workspace's input"); + + input.deliver(WorkspaceInput::CloseActiveWorkspaceTab); + wait_until(WAIT, || diagrams(&test).is_empty()).expect("the diagram closed"); + input.deliver(WorkspaceInput::ReopenClosedTab); + + wait_until(WAIT, || diagrams(&test).len() == 1).expect("the diagram reopened"); + assert_eq!(diagrams(&test)[0].schema().as_deref(), Some("sales")); + drawn(&diagrams(&test)[0]); + assert!(diagrams(&test)[0].places().contains(&placed())); +} + +/// Where the saved record says the user put orders. +fn placed() -> DiagramPlace { + DiagramPlace { + table: "orders".to_owned(), + x: 900, + y: 40, + } +} + +#[gtk4::test] +fn a_change_to_a_table_marks_its_schemas_diagram_out_of_date() { + let test = TestWindow::connected_with("DiagramOutOfDate", sales); + show_diagram(&test, "sales"); + wait_until(WAIT, || diagrams(&test).len() == 1).expect("a diagram tab"); + let diagram = diagrams(&test).remove(0); + drawn(&diagram); + let banner = diagram.imp().outdated_banner.get(); + let input = test.workspace().input().expect("the workspace's input"); + + input.deliver(WorkspaceInput::SchemaChanged { + schema: Some("audit".to_owned()), + table: Some("events".to_owned()), + }); + drain_main_context(); + assert!(!banner.is_revealed(), "another schema's change"); + + input.deliver(WorkspaceInput::SchemaChanged { + schema: Some("sales".to_owned()), + table: Some("orders".to_owned()), + }); + wait_until(WAIT, || banner.is_revealed()).expect("the banner offers a refresh"); +} + +/// The canvas draws in the theme's colours, which the app's stylesheet +/// hands it through its probes: the cards stand out from the window, and +/// the text from the cards. +#[gtk4::test] +fn the_diagram_draws_in_the_themes_colours() { + let test = TestWindow::connected_with("DiagramColours", sales); + show_diagram(&test, "sales"); + wait_until(WAIT, || diagrams(&test).len() == 1).expect("a diagram tab"); + let diagram = diagrams(&test).remove(0); + drawn(&diagram); + + let palette = diagram.canvas().palette(); + + assert_ne!(palette.background, palette.text); + assert_ne!(palette.card, palette.text); + assert_eq!(palette.background.alpha(), 1.0); + assert!(palette.accent.alpha() > 0.0); +} diff --git a/linux/crates/app/src/ui/workspace/tests/mod.rs b/linux/crates/app/src/ui/workspace/tests/mod.rs index b81f544850..6c7ae4efe5 100644 --- a/linux/crates/app/src/ui/workspace/tests/mod.rs +++ b/linux/crates/app/src/ui/workspace/tests/mod.rs @@ -1,4 +1,5 @@ mod close_tab; +mod diagram; mod keys; mod large_catalog; mod references; diff --git a/linux/crates/app/src/ui/workspace/window_tabs.rs b/linux/crates/app/src/ui/workspace/window_tabs.rs index dde256faf1..8c4a92bc64 100644 --- a/linux/crates/app/src/ui/workspace/window_tabs.rs +++ b/linux/crates/app/src/ui/workspace/window_tabs.rs @@ -45,7 +45,10 @@ impl TabDirectory for WindowTabs { let id = self.index.id_of(&page)?; let editor = match tabs.get(&id)? { WorkspaceTab::Editor(slot) => Some(&slot.editor), - WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) | WorkspaceTab::Key(_) => None, + WorkspaceTab::Structure(_) + | WorkspaceTab::Table(_) + | WorkspaceTab::Key(_) + | WorkspaceTab::Diagram(_) => None, }; Some(TabEntry { id, diff --git a/linux/crates/app/src/ui/workspace/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs index 3c65cef64b..9516ae3365 100644 --- a/linux/crates/app/src/ui/workspace/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -133,8 +133,11 @@ pub(super) fn workspace_record( }) .collect(); keep_filters_within(&mut snapshots, KEPT_FILTER_BUDGET); + keep_places_within(&mut snapshots, KEPT_PLACES_BUDGET); let selected = view.selected_page().and_then(|page| index.id_of(&page)); - crate::workspace::workspace_record(&snapshots, selected) + let mut record = crate::workspace::workspace_record(&snapshots, selected); + record.shrink_to_fit(); + record } /// How many bytes of filters the saved tabs keep in all. The workspace @@ -160,6 +163,26 @@ fn keep_filters_within(snapshots: &mut [TabSnapshot], mut budget: usize) { } } +/// How many bytes of diagram places the saved tabs keep in all. A table +/// takes some 40, so this holds thousands of moved tables. +const KEPT_PLACES_BUDGET: usize = 256 * 1024; + +/// Keep each diagram's places while they fit `budget`, in tab order. A +/// diagram past it is saved without them and laid out afresh, rather +/// than the whole file becoming unreadable. +fn keep_places_within(snapshots: &mut [TabSnapshot], mut budget: usize) { + for snapshot in snapshots { + let Some(TabRecord::Diagram { places, .. }) = &mut snapshot.record else { + continue; + }; + let size = serde_json::to_vec(places).map_or(usize::MAX, |json| json.len()); + match budget.checked_sub(size) { + Some(left) => budget = left, + None => places.clear(), + } + } +} + fn tab_record(tab: &WorkspaceTab) -> Option { match tab { WorkspaceTab::Editor(slot) => Some(TabRecord::Editor { @@ -175,6 +198,10 @@ fn tab_record(tab: &WorkspaceTab) -> Option { table: slot.table.clone(), }), }, + WorkspaceTab::Diagram(slot) => Some(TabRecord::Diagram { + schema: slot.schema.clone(), + places: slot.diagram.places(), + }), WorkspaceTab::Table(slot) => Some(TabRecord::Table { schema: slot.schema.clone(), table: slot.table.clone(), @@ -274,6 +301,40 @@ mod tests { assert_eq!(kept(&tight), [false, false]); } + fn diagram(tables: usize) -> TabSnapshot { + TabSnapshot { + id: Uuid::new_v4(), + record: Some(TabRecord::Diagram { + schema: Some("public".to_owned()), + places: (0..tables) + .map(|at| crate::workspace::DiagramPlace { + table: format!("table_{at:05}"), + x: 100, + y: 200, + }) + .collect(), + }), + } + } + + /// A diagram of tens of thousands of moved tables would make the file + /// too large to read back; it is laid out afresh instead. + #[test] + fn diagram_places_past_the_budget_are_left_out() { + let mut snapshots = vec![diagram(3), diagram(20_000), diagram(2)]; + + keep_places_within(&mut snapshots, KEPT_PLACES_BUDGET); + + let counts: Vec = snapshots + .iter() + .map(|snapshot| match &snapshot.record { + Some(TabRecord::Diagram { places, .. }) => places.len(), + _ => usize::MAX, + }) + .collect(); + assert_eq!(counts, [3, 0, 2]); + } + struct Fixture { runtime: crate::runtime::AppRuntime, settings: crate::test_support::MemorySettings, @@ -453,6 +514,7 @@ mod tests { TabRecord::Table { table, .. } => format!("table {table}"), TabRecord::Structure { table, .. } => format!("structure {table}"), TabRecord::Editor { .. } => "editor".to_owned(), + TabRecord::Diagram { .. } => "diagram".to_owned(), TabRecord::Key { key } => format!("key {key}"), }; let page = view.append(>k::Label::new(Some(&name))); diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 3cd5a14e43..eb235744ed 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -121,6 +121,7 @@ impl WorkspaceModel { TabRecord::Structure { schema, table } => TabSpec::Structure { target: StructureTarget::Existing(TableRef::new(schema, table)), }, + TabRecord::Diagram { schema, places } => TabSpec::Diagram { schema, places }, // Its text is on disk already, or there is none to keep, so // nothing is written until the user types. TabRecord::Editor { draft, file } => TabSpec::Editor { @@ -215,6 +216,7 @@ impl WorkspaceModel { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); } + WorkspaceTab::Diagram(slot) => slot.diagram.shutdown(), WorkspaceTab::Key(slot) => slot.tab.shutdown(), } let page = removed.page().clone(); @@ -476,6 +478,7 @@ impl WorkspaceModel { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); } + WorkspaceTab::Diagram(slot) => slot.diagram.shutdown(), WorkspaceTab::Key(slot) => slot.tab.shutdown(), } } @@ -638,6 +641,10 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { table: s.table.clone(), }), }, + WorkspaceTab::Diagram(s) => Some(ClosedTabDescriptor::Diagram { + schema: s.schema.clone(), + places: s.diagram.places(), + }), WorkspaceTab::Key(s) => Some(ClosedTabDescriptor::Key { key: s.tab.key().to_owned(), }), @@ -695,6 +702,7 @@ impl WorkspaceModel { target: StructureTarget::Existing(TableRef::new(schema, table)), }); } + ClosedTabDescriptor::Diagram { schema, places } => self.show_diagram(schema, places), ClosedTabDescriptor::Key { key } => { self.open_tab(TabSpec::Key { key }); } diff --git a/linux/crates/app/src/workspace/diagram_place.rs b/linux/crates/app/src/workspace/diagram_place.rs new file mode 100644 index 0000000000..f9d2b96053 --- /dev/null +++ b/linux/crates/app/src/workspace/diagram_place.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// Where the user put one table of an ER diagram, in whole diagram units. +/// The table is named without its schema: a diagram shows one schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagramPlace { + pub table: String, + pub x: i32, + pub y: i32, +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index 81ec6cccd4..fed49aefd4 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -12,6 +12,7 @@ mod close_reason; mod close_scope; mod close_step; mod completed_work; +mod diagram_place; pub(crate) mod dialog_response; mod dirty_kind; mod dirty_tab; @@ -75,6 +76,7 @@ pub use close_reason::CloseReason; pub use close_scope::CloseScope; pub use close_step::{CloseStep, next_close_step}; pub use completed_work::CompletedWork; +pub use diagram_place::DiagramPlace; pub(crate) use dialog_response::respond; #[cfg(test)] pub(crate) use dialog_response::{answer, visible_alert}; diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs index 6c3e5907d6..fa4b714d69 100644 --- a/linux/crates/app/src/workspace/tab_record.rs +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use tablepro_core::FilterSet; use tablepro_storage::DraftId; -use super::{EditorFileRecord, SortRecord}; +use super::{DiagramPlace, EditorFileRecord, SortRecord}; /// What the page size preference allows. const PAGE_SIZES: RangeInclusive = 100..=100_000; @@ -29,6 +29,15 @@ pub enum TabRecord { schema: Option, table: String, }, + /// A schema's ER diagram. Its tables are read again when it opens, + /// and those the user moved go back where they were put; with none + /// moved, the automatic layout places them all. + Diagram { + #[serde(default, skip_serializing_if = "Option::is_none")] + schema: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + places: Vec, + }, /// The text lives in the draft store, so a script of any length /// costs the record one id. Editor { @@ -43,6 +52,29 @@ pub enum TabRecord { } impl TabRecord { + /// How large the state a tab can be saved without is, encoded, or + /// `None` with none to leave out. + pub(super) fn optional_size(&self) -> Option { + let encoded = match self { + Self::Table { + filter: Some(filter), .. + } => serde_json::to_vec(filter), + Self::Diagram { places, .. } if !places.is_empty() => serde_json::to_vec(places), + _ => return None, + }; + Some(encoded.map_or(usize::MAX, |json| json.len())) + } + + /// Leave out what `optional_size` counts: a table tab opens on its + /// table's remembered filter, and a diagram is laid out afresh. + pub(super) fn drop_optional(&mut self) { + match self { + Self::Table { filter, .. } => *filter = None, + Self::Diagram { places, .. } => places.clear(), + Self::Structure { .. } | Self::Editor { .. } | Self::Key { .. } => {} + } + } + /// The record as a tab can be built from it, or `None` when it names /// nothing that could exist. pub(super) fn clamped(self) -> Option { @@ -73,6 +105,7 @@ impl TabRecord { }) } Self::Structure { schema, table } => (!table.is_empty()).then_some(Self::Structure { schema, table }), + Self::Diagram { schema, places } => Some(Self::Diagram { schema, places }), Self::Editor { draft, file } => Some(Self::Editor { draft, file }), // A key's name can be empty, as Redis lets one be. Self::Key { key } => Some(Self::Key { key }), diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs index 91436fb19a..a433588b51 100644 --- a/linux/crates/app/src/workspace/workspace_record.rs +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; -use tablepro_storage::document::VersionedDocument; +use serde_json::Value; +use tablepro_storage::document::{Upgrade, VersionedDocument}; use super::TabRecord; @@ -16,10 +17,41 @@ pub struct WorkspaceRecord { impl VersionedDocument for WorkspaceRecord { const KIND: &'static str = "workspace"; - const VERSION: u32 = 1; + const VERSION: u32 = 2; + const UPGRADES: &'static [Upgrade] = &[diagram_tabs]; +} + +/// Version 2 adds the diagram tab. Every version 1 tab reads as it was, +/// and a build that knows only version 1 says a version 2 file is from a +/// newer build rather than calling it unreadable. +fn diagram_tabs(document: Value) -> Result { + Ok(document) } impl WorkspaceRecord { + /// Leave out the tabs' optional state, the largest first, until the + /// file is no larger than the store reads back: a table tab's filter + /// and a diagram's places. A file past the cap would block every saved + /// tab of the session. + pub fn shrink_to_fit(&mut self) { + loop { + let size = tablepro_storage::document::encode_document(&*self) + .map_or(u64::MAX, |bytes| u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + if size <= super::workspace_store::MAX_FILE_BYTES { + return; + } + let largest = self + .tabs + .iter_mut() + .filter_map(|tab| tab.optional_size().map(|size| (size, tab))) + .max_by_key(|(size, _)| *size); + let Some((_, tab)) = largest else { + return; + }; + tab.drop_optional(); + } + } + /// The record as a window can rebuild it: no tab naming something /// that could not exist, and `active_tab` still pointing at the tab /// it named, or at none. @@ -89,6 +121,18 @@ mod tests { draft: DraftId::new(), file: None, }, + TabRecord::Diagram { + schema: Some("sales".to_owned()), + places: vec![crate::workspace::DiagramPlace { + table: "orders".to_owned(), + x: -120, + y: 340, + }], + }, + TabRecord::Diagram { + schema: None, + places: Vec::new(), + }, ], }; @@ -97,7 +141,7 @@ mod tests { assert_eq!(decoded, record); let text = String::from_utf8_lossy(&bytes); - assert!(text.contains("\"version\": 1"), "{text}"); + assert!(text.contains("\"version\": 2"), "{text}"); assert!(!text.contains("SELECT"), "an editor record carries text: {text}"); } @@ -157,6 +201,58 @@ mod tests { ); } + /// A version 2 workspace file, the first with diagram tabs. Never edit + /// the fixture. + #[test] + fn a_version_2_workspace_reads_as_it_was_written() { + let bytes = include_bytes!("../../tests/fixtures/workspace-v2.json"); + let draft = uuid::Uuid::parse_str("7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a").expect("a uuid"); + let place = |table: &str, x, y| crate::workspace::DiagramPlace { + table: table.to_owned(), + x, + y, + }; + + let decoded: WorkspaceRecord = decode_document(Path::new("/tmp/w.json"), bytes).expect("decode"); + + assert_eq!( + decoded, + WorkspaceRecord { + active_tab: Some(3), + tabs: vec![ + TabRecord::Table { + schema: Some("public".to_owned()), + table: "orders".to_owned(), + offset: 0, + page_size: 1_000, + filter: None, + sort: None, + }, + TabRecord::Editor { + draft: DraftId::from_uuid(draft), + file: None, + }, + TabRecord::Structure { + schema: Some("sales".to_owned()), + table: "customers".to_owned(), + }, + TabRecord::Diagram { + schema: Some("sales".to_owned()), + places: vec![place("customers", -120, 40), place("orders", 336, 0)], + }, + TabRecord::Diagram { + schema: None, + places: Vec::new(), + }, + TabRecord::Key { + key: "user:1".to_owned(), + }, + ], + } + ); + assert_eq!(decoded.clone().clamped(), decoded); + } + #[test] fn clamped_keeps_every_tab_however_many() { let record = WorkspaceRecord { @@ -207,4 +303,47 @@ mod tests { assert_eq!(record.clamped().active_tab, None); } + + /// Each part under its own budget can still make a file past the cap + /// once pretty-printed together, which would block every saved tab. + #[test] + fn a_record_past_the_file_cap_leaves_out_the_largest_optional_state() { + let places = |count: usize| { + (0..count) + .map(|at| crate::workspace::DiagramPlace { + table: format!("table_{at:05}"), + x: 1_000, + y: 2_000, + }) + .collect::>() + }; + let mut record = WorkspaceRecord { + active_tab: Some(0), + tabs: vec![ + TabRecord::Diagram { + schema: Some("public".to_owned()), + places: places(16_000), + }, + TabRecord::Diagram { + schema: Some("audit".to_owned()), + places: places(3), + }, + table("orders"), + ], + }; + + record.shrink_to_fit(); + + let bytes = encode_document(&record).expect("encode"); + assert!(bytes.len() as u64 <= super::super::workspace_store::MAX_FILE_BYTES); + let kept: Vec = record + .tabs + .iter() + .map(|tab| match tab { + TabRecord::Diagram { places, .. } => places.len(), + _ => usize::MAX, + }) + .collect(); + assert_eq!(kept, [0, 3, usize::MAX], "only the largest is left out"); + } } diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index 4bfbf6a293..fd01fedd2a 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -563,15 +563,15 @@ mod tests { async fn unsupported_version_blocks_key() { let fixture = Fixture::new(); let key = key(); - fixture.seed(&key, br#"{"version": 2, "tabs": []}"#); + fixture.seed(&key, br#"{"version": 3, "tabs": []}"#); let load = fixture.store.load(&key).await; assert_eq!( load, WorkspaceLoad::Blocked(WorkspaceLoadError::UnsupportedVersion { - found: Some(2), - expected: 1 + found: Some(3), + expected: 2 }) ); assert_eq!( diff --git a/linux/crates/app/tests/fixtures/workspace-v2.json b/linux/crates/app/tests/fixtures/workspace-v2.json new file mode 100644 index 0000000000..09b0d6bbf3 --- /dev/null +++ b/linux/crates/app/tests/fixtures/workspace-v2.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "active_tab": 3, + "tabs": [ + { + "kind": "table", + "schema": "public", + "table": "orders", + "offset": 0, + "page_size": 1000, + "sort": null + }, + { + "kind": "editor", + "draft": "7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a" + }, + { + "kind": "structure", + "schema": "sales", + "table": "customers" + }, + { + "kind": "diagram", + "schema": "sales", + "places": [ + { + "table": "customers", + "x": -120, + "y": 40 + }, + { + "table": "orders", + "x": 336, + "y": 0 + } + ] + }, + { + "kind": "diagram" + }, + { + "kind": "key", + "key": "user:1" + } + ] +} diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index b9e9172877..6f2c97dfad 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -13,6 +13,7 @@ ui/connect-dialog.ui ui/connect-failed-page.ui ui/connecting-page.ui + ui/diagram-tab.ui ui/export-operations-button.ui ui/filter-rule-row.ui ui/filter-strip.ui diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 20392514da..50a65afe66 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -390,5 +390,70 @@ + + + ER Diagram + + + Zoom In + <Primary>plus <Primary>equal + + + + + Zoom Out + <Primary>minus + + + + + Reset Zoom + <Primary>0 + + + + + Best Fit + f + + + + + Read the Tables Again + F5 <Primary>r + + + + + Open the Selected Table + Return + + + + + Move the Selected Table Left + <Alt>Left + + + + + Move the Selected Table Right + <Alt>Right + + + + + Move the Selected Table Up + <Alt>Up + + + + + Move the Selected Table Down + <Alt>Down + + + + diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 60a06411b1..ca4166fcbf 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -175,3 +175,26 @@ columnview > listview > row > cell > tp-grid-cell > text.tp-cell-text:focus-with box-shadow: inset 0 0 0 1px var(--window-fg-color); } } + +/* The ER diagram draws its cards and lines itself, in the theme's + colours: each hidden probe's colour is one of libadwaita's variables, + which the canvas reads. */ +tp-diagram-canvas > .tp-diagram-window-bg { + color: var(--window-bg-color); +} + +tp-diagram-canvas > .tp-diagram-card-bg { + color: var(--card-bg-color); +} + +tp-diagram-canvas > .tp-diagram-card-fg { + color: var(--card-fg-color); +} + +tp-diagram-canvas > .tp-diagram-border { + color: var(--border-color); +} + +tp-diagram-canvas > .tp-diagram-accent { + color: var(--accent-color); +} diff --git a/linux/data/resources/ui/diagram-tab.ui b/linux/data/resources/ui/diagram-tab.ui new file mode 100644 index 0000000000..c9b0d8d12d --- /dev/null +++ b/linux/data/resources/ui/diagram-tab.ui @@ -0,0 +1,241 @@ + + + + + +
+ + Reset Layout + diagram.reset-layout + +
+
+ + Export as PNG… + diagram.export + png + + + Export as SVG… + diagram.export + svg + +
+
+ +
diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 0d24eeb4dc..427b19d447 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -330,6 +330,6 @@ A keyring failure is a `SecretError` from `tablepro-core`, not a `StorageError`; - A JSON document is a `VersionedDocument` with a `version` field. A new shape bumps its `VERSION` and appends a step to its `UPGRADES`, which turns a file of the version before into the new shape. `decode_document` runs an older file through every step it is behind, oldest first, so a user who skipped releases keeps their data. A step that shipped is never changed or dropped. - The upgrade happens in memory. The file stays as it was until the next save writes the current version. - A file older than the first step, one a step cannot read, or one from a newer build is never written over. The connection list and a workspace wait for the user's Reset, which moves the file aside; the smaller state files move an unusable file aside themselves. -- Each released version of the connection list and the workspace file has a golden file that a test reads into the model it was written from: `crates/storage/tests/fixtures/connections-v1.json`, `connections-v2.json` and `crates/app/tests/fixtures/workspace-v1.json`. A committed fixture is never edited. A format change adds the new version's fixture and updates the expected models of the old ones, which now read through the upgrade. +- Each released version of the connection list and the workspace file has a golden file that a test reads into the model it was written from: `crates/storage/tests/fixtures/connections-v1.json`, `connections-v2.json`, and `crates/app/tests/fixtures/workspace-v1.json` and `workspace-v2.json`. A committed fixture is never edited. A format change adds the new version's fixture and updates the expected models of the old ones, which now read through the upgrade. - The history database moves forward by sqlx migration: a new timestamped file under `crates/storage/migrations/` with its own fixture test, never an edit to an applied one. - Update this page, and add a fragment to the Linux changelog. diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index cdf7f8ed68..be1c972a4c 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -200,6 +200,25 @@ crates/app/src/ui/credential_prompt/prompt_form.rs crates/app/src/ui/credential_prompt/prompt_text.rs crates/app/src/ui/credential_prompt/prompter.rs crates/app/src/ui/delete_connection_dialog.rs +crates/app/src/ui/diagram/canvas.rs +crates/app/src/ui/diagram/canvas_imp.rs +crates/app/src/ui/diagram/canvas_input.rs +crates/app/src/ui/diagram/card_text.rs +crates/app/src/ui/diagram/describe.rs +crates/app/src/ui/diagram/draw.rs +crates/app/src/ui/diagram/engines.rs +crates/app/src/ui/diagram/export.rs +crates/app/src/ui/diagram/fetch.rs +crates/app/src/ui/diagram/layout.rs +crates/app/src/ui/diagram/mod.rs +crates/app/src/ui/diagram/model.rs +crates/app/src/ui/diagram/node.rs +crates/app/src/ui/diagram/palette.rs +crates/app/src/ui/diagram/scene.rs +crates/app/src/ui/diagram/tab.rs +crates/app/src/ui/diagram/tab_imp.rs +crates/app/src/ui/diagram/tests.rs +crates/app/src/ui/diagram/zoom.rs crates/app/src/ui/discard_alert.rs crates/app/src/ui/editor/buffer_edit.rs crates/app/src/ui/editor/completion/catalog_read.rs @@ -471,6 +490,7 @@ crates/app/src/ui/tags/tag_dialog.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/ui/workspace/close_flow.rs +crates/app/src/ui/workspace/diagram.rs crates/app/src/ui/workspace/editor_label.rs crates/app/src/ui/workspace/finished_work.rs crates/app/src/ui/workspace/health_banner.rs @@ -510,6 +530,7 @@ crates/app/src/ui/workspace/tab_spec.rs crates/app/src/ui/workspace/tab_title.rs crates/app/src/ui/workspace/table_naming.rs crates/app/src/ui/workspace/tests/close_tab.rs +crates/app/src/ui/workspace/tests/diagram.rs crates/app/src/ui/workspace/tests/keys.rs crates/app/src/ui/workspace/tests/large_catalog.rs crates/app/src/ui/workspace/tests/mod.rs @@ -533,6 +554,7 @@ crates/app/src/workspace/close_reason.rs crates/app/src/workspace/close_scope.rs crates/app/src/workspace/close_step.rs crates/app/src/workspace/completed_work.rs +crates/app/src/workspace/diagram_place.rs crates/app/src/workspace/dialog_response.rs crates/app/src/workspace/dirty_kind.rs crates/app/src/workspace/dirty_tab.rs @@ -596,6 +618,7 @@ data/resources/ui/browse-tab.ui data/resources/ui/connect-dialog.ui data/resources/ui/connect-failed-page.ui data/resources/ui/connecting-page.ui +data/resources/ui/diagram-tab.ui data/resources/ui/export-operations-button.ui data/resources/ui/filter-rule-row.ui data/resources/ui/filter-strip.ui