diff --git a/.github/screenshots/1.png b/.github/screenshots/1.png deleted file mode 100644 index 0139f69..0000000 Binary files a/.github/screenshots/1.png and /dev/null differ diff --git a/.github/screenshots/10.png b/.github/screenshots/10.png deleted file mode 100644 index c0061f0..0000000 Binary files a/.github/screenshots/10.png and /dev/null differ diff --git a/.github/screenshots/11.png b/.github/screenshots/11.png deleted file mode 100644 index 5941e29..0000000 Binary files a/.github/screenshots/11.png and /dev/null differ diff --git a/.github/screenshots/12.png b/.github/screenshots/12.png deleted file mode 100644 index d280c17..0000000 Binary files a/.github/screenshots/12.png and /dev/null differ diff --git a/.github/screenshots/13.png b/.github/screenshots/13.png deleted file mode 100644 index c29795e..0000000 Binary files a/.github/screenshots/13.png and /dev/null differ diff --git a/.github/screenshots/14.png b/.github/screenshots/14.png deleted file mode 100644 index 7b0290b..0000000 Binary files a/.github/screenshots/14.png and /dev/null differ diff --git a/.github/screenshots/15.png b/.github/screenshots/15.png deleted file mode 100644 index e9cdc62..0000000 Binary files a/.github/screenshots/15.png and /dev/null differ diff --git a/.github/screenshots/2.png b/.github/screenshots/2.png deleted file mode 100644 index 36ea1d4..0000000 Binary files a/.github/screenshots/2.png and /dev/null differ diff --git a/.github/screenshots/3.png b/.github/screenshots/3.png deleted file mode 100644 index aeede68..0000000 Binary files a/.github/screenshots/3.png and /dev/null differ diff --git a/.github/screenshots/4.png b/.github/screenshots/4.png deleted file mode 100644 index 03a0d66..0000000 Binary files a/.github/screenshots/4.png and /dev/null differ diff --git a/.github/screenshots/5.png b/.github/screenshots/5.png deleted file mode 100644 index c04e571..0000000 Binary files a/.github/screenshots/5.png and /dev/null differ diff --git a/.github/screenshots/6.png b/.github/screenshots/6.png deleted file mode 100644 index 0e34b6d..0000000 Binary files a/.github/screenshots/6.png and /dev/null differ diff --git a/.github/screenshots/7.png b/.github/screenshots/7.png deleted file mode 100644 index e6b8d3b..0000000 Binary files a/.github/screenshots/7.png and /dev/null differ diff --git a/.github/screenshots/8.png b/.github/screenshots/8.png deleted file mode 100644 index 1a3cdba..0000000 Binary files a/.github/screenshots/8.png and /dev/null differ diff --git a/.github/screenshots/9.png b/.github/screenshots/9.png deleted file mode 100644 index 46ca8f5..0000000 Binary files a/.github/screenshots/9.png and /dev/null differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13ad0c7..21e0073 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,9 @@ cargo xtask lint # rustfmt and clippy, as CI runs them cargo xtask e2e up # PostgreSQL, MySQL, MariaDB, ClickHouse and libSQL from docker-compose.yml cargo xtask e2e # the suites that need them cargo xtask e2e down -cargo xtask screenshots # the README screenshots, against the PostgreSQL above -cargo xtask docs-images # the landing page's images, from those screenshots +cargo xtask screenshots # the README's and landing page's screenshots, against the PostgreSQL above +cargo xtask demo-video # the demo video, against every engine above +cargo xtask docs-images # their smaller copies and link preview ``` `cargo xtask` is this repository's task runner (the `xtask/` crate); run it alone to list its commands. `COMPOSE="podman compose"` makes it use Podman. @@ -43,9 +44,9 @@ CI (`.github/workflows/test.yml`) runs the Linux checks and the E2E suites, SQL ## Screenshots -`cargo xtask screenshots` takes the README's screenshots again, at 2048 × 1152 points (4096 × 2304 pixels on a Retina display). It restores `fixtures/screenshots/forum.sql` into the PostgreSQL from `docker-compose.yml`, starts from the connections, tabs and settings in `fixtures/screenshots/data`, and plays the scenes in `crates/barsql-ui/src/screenshots.rs` in a snapshot build. A BarSQL window stays open while they run. It finishes with `cargo xtask docs-images`, which writes each screenshot into `docs/screenshots` as a full-size lossless WebP plus 640, 800 and 1280px copies, which the landing page picks from to fit the screen. +`cargo xtask screenshots` takes the screenshots again. It restores `fixtures/screenshots/forum.sql` into the PostgreSQL from `docker-compose.yml`, starts from the connections, tabs and settings in `fixtures/screenshots/data`, and plays the scenes in `crates/barsql-ui/src/screenshots.rs` in a snapshot build. The window is 1920 × 1080 points and stays hidden: each shot renders offscreen at the display's scale and is scaled to 1920 × 1080 pixels, so a Retina Mac and a 125% Windows display give the same size. Windows still needs a display at least that many points across, since it keeps even a hidden window within it. Each is kept as a lossless WebP in `docs/screenshots`, which the README and the landing page share. It finishes with `cargo xtask docs-images`, which makes the 640, 800 and 1280px copies, the link preview and the README's video poster. -For a single screenshot, build with `cargo build -p barsql --features snapshot`, then point `BARSQL_SNAPSHOT=out.png` at a data folder in `BARSQL_DATA_DIR`. The app draws its window headless, saves it and quits. `BARSQL_SNAPSHOT_SIZE` sets the window's size in points (`2048x1152`), `BARSQL_SNAPSHOT_RUN=1` runs the active tab first, and `BARSQL_SNAPSHOT_PANEL` chooses what the screenshot shows: +For a single screenshot, build with `cargo build -p barsql --features snapshot`, then point `BARSQL_SNAPSHOT=out.png` at a data folder in `BARSQL_DATA_DIR`. The app draws its window headless, saves it and quits. `BARSQL_SNAPSHOT_SIZE` sets the window's size in points (`1920x1080`), `BARSQL_SNAPSHOT_RUN=1` runs the active tab first, and `BARSQL_SNAPSHOT_PANEL` chooses what the screenshot shows: - the sidebar panels: `saved`, `recent`, `connections` - the grid: `cell`, `export`, `json` @@ -53,6 +54,14 @@ For a single screenshot, build with `cargo build -p barsql --features snapshot`, - the update dialog: `update`, `update-current`, `update-downloading`, `update-ready`, `update-failed` - other screens: `plan`, `quick`, `quick=`, `suggest=`, `toasts`, `palette` (the command palette), `chart` (the first result as a chart, with `BARSQL_SNAPSHOT_RUN=1`), `er` (the `main` schema's ER diagram), `structure` (a table's Structure tab: the first in `main`, or `structure=`) +## Demo video + +`cargo xtask demo-video` records the two-minute demo into `target/demo-video/BarSQL-demo.mp4` (1080p, 30 fps, H.264) and copies it to `docs/demo.mp4`, which the landing page plays. It needs [ffmpeg](https://ffmpeg.org) (`brew install ffmpeg`, `winget install ffmpeg`, or `FFMPEG` pointing at one) and starts PostgreSQL, MySQL, ClickHouse, libSQL and SQL Server from `docker-compose.yml`, which stay up afterwards. The forum database gets a `staging` schema to compare against and a trigger for the schema tree to show, both from `fixtures/demo-video/staging.sql`, and the other engines get a sample table each, created off camera. + +The tour itself is `crates/barsql-ui/src/screenshots/video.rs`, in the screenshots' hidden 1920 × 1080 point window at zoom 15, with a drawn pointer, typing at a human pace and a caption for each step. It opens on the editor of a forum connection saved off camera, and adds the MySQL connection on camera before the six engines. The camera skips loads and crossfades over changes made off camera. Every frame is rendered offscreen; only the frames that changed are saved, as PNGs in `target/demo-video/frames` with an ffconcat list of how long each shows, which ffmpeg turns into the video. Change a caption or a step there and run it again; a run takes about 20 minutes, mostly recording. + +The README plays a GitHub upload of the video, as GitHub plays only its own uploads there. After a new recording, drag `docs/demo.mp4` into the README in GitHub's editor and replace the old upload's link with the new one. + ## Translations BarSQL speaks English, Deutsch, Български and Español. A new language is mostly JSON and needs little of the rest of the code; [#25](https://github.com/Bare7a/BarSQL/pull/25) added Spanish and touches every step below. @@ -85,7 +94,7 @@ python3 -m http.server 8765 --directory docs # then open http://localhost:8765 ## Project Structure ```text -├── .github/ # test and release workflows, PR and issue templates, the README screenshots +├── .github/ # test and release workflows, PR and issue templates ├── Cargo.toml # the workspace; GPUI Kit pinned to an exact version ├── rust-toolchain.toml # the Rust version rustup installs ├── docker-compose.yml # PostgreSQL / MySQL / MariaDB / ClickHouse / libSQL / SQL Server for the E2E suites @@ -99,7 +108,7 @@ python3 -m http.server 8765 --directory docs # then open http://localhost:8765 │ ├── barsql-storage/ # the JSON files in BarSQL-data │ └── barsql-ui/ # the GPUI views, locales and whole-window scenarios ├── xtask/ # cargo xtask: lint, e2e, screenshots, docs-images, icons, package, bump-version -├── docs/ # the landing page at barsql.bare7a.eu (GitHub Pages) +├── docs/ # the landing page at barsql.bare7a.eu (GitHub Pages) and the README's images ├── guide/ # the user guide, one page per area ├── fixtures/ │ ├── golden/ # per-engine expectations for the E2E suites diff --git a/Cargo.lock b/Cargo.lock index 8882d52..d3694d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,7 +641,7 @@ dependencies = [ [[package]] name = "barsql" -version = "1.2.6" +version = "1.2.7" dependencies = [ "anyhow", "barsql-app", @@ -656,7 +656,7 @@ dependencies = [ [[package]] name = "barsql-app" -version = "1.2.6" +version = "1.2.7" dependencies = [ "async-channel", "barsql-core", @@ -684,7 +684,7 @@ dependencies = [ [[package]] name = "barsql-core" -version = "1.2.6" +version = "1.2.7" dependencies = [ "dirs 7.0.0", "hex", @@ -697,7 +697,7 @@ dependencies = [ [[package]] name = "barsql-db" -version = "1.2.6" +version = "1.2.7" dependencies = [ "anyhow", "async-channel", @@ -734,7 +734,7 @@ dependencies = [ [[package]] name = "barsql-io" -version = "1.2.6" +version = "1.2.7" dependencies = [ "barsql-core", "barsql-db", @@ -749,7 +749,7 @@ dependencies = [ [[package]] name = "barsql-sql" -version = "1.2.6" +version = "1.2.7" dependencies = [ "barsql-core", "libc", @@ -761,7 +761,7 @@ dependencies = [ [[package]] name = "barsql-storage" -version = "1.2.6" +version = "1.2.7" dependencies = [ "barsql-core", "serde", @@ -772,7 +772,7 @@ dependencies = [ [[package]] name = "barsql-ui" -version = "1.2.6" +version = "1.2.7" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index c8fc0a2..47798e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "xtask"] [workspace.package] -version = "1.2.6" +version = "1.2.7" edition = "2024" license = "GPL-3.0-only" publish = false diff --git a/README.md b/README.md index 9641c42..3ead0da 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ No cloud. No accounts. No telemetry. Just a focused, native desktop tool for **PostgreSQL**, **MySQL / MariaDB**, **SQL Server**, **ClickHouse**, **Turso / libSQL** and **SQLite**, on Windows, macOS and Linux. -🌐 **[barsql.bare7a.eu](https://barsql.bare7a.eu)** · 📖 **[User Guide](guide/README.md)** +🌐 **[barsql.bare7a.eu](https://barsql.bare7a.eu)** · 🎬 **[Demo video](https://barsql.bare7a.eu/demo.mp4)** · 📖 **[User Guide](guide/README.md)** -![BarSQL's editor with schema-aware suggestions, a saved query's results in the grid and the focused row as JSON](.github/screenshots/1.png?raw=true) +https://github.com/user-attachments/assets/ea4b7bd3-82b9-469b-b99c-4619aeb6e83a --- @@ -88,29 +88,29 @@ Work with **SQLite**, **PostgreSQL**, **MySQL / MariaDB**, **SQL Server**, **Cli
- - - + + + - - - + + + - - - + + + - - - + + + - - - + + +

Editor

Transactions & multiple results

Table data

Editor

Transactions & multiple results

Table data

Cell editor

Grid

Export

Cell editor

Grid

Export

Connections

Quick Search

DDL viewer

Connections

Quick Search

DDL viewer

Plan viewer

CSV & SQL import

Structure editor

Plan viewer

CSV & SQL import

Structure editor

ER diagram

Quick charts

Appearance

ER diagram

Quick charts

Appearance
diff --git a/crates/barsql-ui/locales/bg.json b/crates/barsql-ui/locales/bg.json index 0335fad..6df2d47 100644 --- a/crates/barsql-ui/locales/bg.json +++ b/crates/barsql-ui/locales/bg.json @@ -437,12 +437,10 @@ "search": "Намери таблица", "matches": "{{index}} от {{count}}", "noMatches": "Няма такава таблица", - "keysOnly": "Само ключове", "keysOnlyTooltip": "Покажи само ключовите колони", "zoomIn": "Увеличи", "zoomOut": "Намали", "zoomReset": "Обратно на 100%", - "fit": "Побери", "fitTooltip": "Покажи цялата диаграма", "open": "Отвори таблицата" }, diff --git a/crates/barsql-ui/locales/de.json b/crates/barsql-ui/locales/de.json index 3dac959..b1d7eba 100644 --- a/crates/barsql-ui/locales/de.json +++ b/crates/barsql-ui/locales/de.json @@ -437,12 +437,10 @@ "search": "Tabelle suchen", "matches": "{{index}} von {{count}}", "noMatches": "Keine Tabelle passt", - "keysOnly": "Nur Schlüssel", "keysOnlyTooltip": "Nur die Schlüsselspalten zeigen", "zoomIn": "Vergrößern", "zoomOut": "Verkleinern", "zoomReset": "Zurück auf 100 %", - "fit": "Einpassen", "fitTooltip": "Das ganze Diagramm zeigen", "open": "Tabelle öffnen" }, diff --git a/crates/barsql-ui/locales/en.json b/crates/barsql-ui/locales/en.json index b072e16..9a149cc 100644 --- a/crates/barsql-ui/locales/en.json +++ b/crates/barsql-ui/locales/en.json @@ -437,12 +437,10 @@ "search": "Find a table", "matches": "{{index}} of {{count}}", "noMatches": "No table matches", - "keysOnly": "Keys only", "keysOnlyTooltip": "Show only the key columns", "zoomIn": "Zoom in", "zoomOut": "Zoom out", "zoomReset": "Back to 100%", - "fit": "Fit", "fitTooltip": "Show the whole diagram", "open": "Open table" }, diff --git a/crates/barsql-ui/locales/es.json b/crates/barsql-ui/locales/es.json index 8cc6e26..aae0504 100644 --- a/crates/barsql-ui/locales/es.json +++ b/crates/barsql-ui/locales/es.json @@ -447,12 +447,10 @@ "search": "Buscar una tabla", "matches": "{{index}} de {{count}}", "noMatches": "Ninguna tabla coincide", - "keysOnly": "Solo claves", "keysOnlyTooltip": "Mostrar solo las columnas clave", "zoomIn": "Acercar", "zoomOut": "Alejar", "zoomReset": "Volver al 100 %", - "fit": "Ajustar", "fitTooltip": "Mostrar todo el diagrama", "open": "Abrir la tabla" }, diff --git a/crates/barsql-ui/src/connection_dialog.rs b/crates/barsql-ui/src/connection_dialog.rs index e758350..8097f93 100644 --- a/crates/barsql-ui/src/connection_dialog.rs +++ b/crates/barsql-ui/src/connection_dialog.rs @@ -602,12 +602,15 @@ impl ConnectionForm { cx: &mut Context, ) -> Div { v_flex() - .child(form::checkbox(id, checked, t(cx, label), false, cx).debug_selector(move || id.into()).on_click( - cx.listener(move |form, _, _, cx| { - flip(form); - cx.notify(); - }), - )) + .child( + form::checkbox(id, checked, t(cx, label), false, cx) + .debug_selector(move || id.into()) + .on_click(cx.listener(move |form, _, _, cx| { + flip(form); + cx.notify(); + })) + .test_support(), + ) .child(form::hint(t(cx, hint), cx).pl(rems(2.))) } @@ -643,14 +646,14 @@ fn database_items(mut menu: PopupMenu, databases: &[String], form: &Entity); -#[cfg(all(test, feature = "e2e"))] +#[cfg(any(all(test, feature = "e2e"), feature = "snapshot"))] impl Global for Opened {} -#[cfg(all(test, feature = "e2e"))] +#[cfg(any(all(test, feature = "e2e"), feature = "snapshot"))] impl ConnectionForm { pub(crate) fn databases(&self) -> Option<&[String]> { self.databases.as_ref().map(|(_, databases)| databases.as_slice()) @@ -668,9 +671,9 @@ enum Browse { KnownHosts, } -// Gives scenario tests a selector to click into. -fn labelled(selector: &'static str, input: Input) -> Div { - div().debug_selector(move || selector.into()).child(input) +// A selector for scenario tests and a scope for test lookups. +fn labelled(selector: &'static str, input: Input) -> Stateful
{ + div().id(selector).debug_selector(move || selector.into()).child(input) } impl Render for ConnectionForm { @@ -1147,7 +1150,7 @@ pub fn open( let editing = !config.id.is_empty(); let on_saved: OnSaved = Rc::new(on_saved); let form = cx.new(|cx| ConnectionForm::new(config, on_saved, window, cx)); - #[cfg(all(test, feature = "e2e"))] + #[cfg(any(all(test, feature = "e2e"), feature = "snapshot"))] cx.set_global(Opened(form.downgrade())); let handle = form.clone(); let name = form.read(cx).fields.name.clone(); diff --git a/crates/barsql-ui/src/er_diagram.rs b/crates/barsql-ui/src/er_diagram.rs index d2a0d68..4b58b91 100644 --- a/crates/barsql-ui/src/er_diagram.rs +++ b/crates/barsql-ui/src/er_diagram.rs @@ -18,11 +18,11 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use self::layout::{BOX_W, ErTable, HEADER_H, Layout, ROW_H, arrange, find}; -use crate::form::{self, ToolButton}; +use crate::form; use crate::i18n::{I18n, t, t_with}; use crate::schema; use crate::scrollbars::HoverScrollbar as _; -use crate::tokens::{ICON_XS, RADIUS, TEXT_SM, TEXT_XS, TINT}; +use crate::tokens::{ICON_SM, ICON_XS, RADIUS, TEXT_SM, TEXT_XS, TINT}; // Tables a diagram loads. pub const MAX_TABLES: usize = 200; @@ -311,7 +311,6 @@ impl ErDiagram { Some(t_with(cx, "erDiagram.matches", &[("index", &index), ("count", &count)])) } }; - let percent = format!("{}%", (self.zoom * 100.).round()); // Only a cut diagram has something to say along the toolbar. How to get around waits behind the "?". let note = (self.total > MAX_TABLES).then(|| { let (shown, total) = (MAX_TABLES.to_string(), self.total.to_string()); @@ -319,58 +318,77 @@ impl ErDiagram { }); let modifier = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" }; let hint = t_with(cx, "erDiagram.hint", &[("modifier", modifier)]); + // The hit count sits inside the field, so typing moves nothing. + let search = form::filter_input(&self.search, window, cx).when_some(found, |input, found| { + input.suffix( + div() + .debug_selector(|| "er-matches".into()) + .text_size(TEXT_XS) + .text_color(theme.muted_foreground) + .child(found), + ) + }); h_flex() .debug_selector(|| "er-toolbar".into()) .flex_none() .h(rems(3.077)) - .gap(rems(0.462)) + .gap(rems(0.615)) .px(rems(0.769)) .bg(theme.sidebar) .border_b_1() .border_color(theme.border) - .child(div().flex_none().w(rems(15.385)).debug_selector(|| "er-search".into()).child(form::filter_input( - &self.search, - window, - cx, - ))) - .children(found.map(|text| { + .child(Icon::new(IconName::Search).size(ICON_SM).text_color(theme.muted_foreground)) + // Fills the bar, and shrinks alongside the note when the bar runs short. + .child( div() - .flex_none() - .text_size(TEXT_XS) - .text_color(theme.muted_foreground) - .debug_selector(|| "er-matches".into()) - .child(text) - })) - .child(div().flex_1().min_w_0().children(note.map(|note| { + .flex_grow_1() + .flex_basis(rems(15.385)) + .min_w_0() + .debug_selector(|| "er-search".into()) + .child(search), + ) + .children(note.map(|note| { div() .debug_selector(|| "er-note".into()) + .min_w_0() .truncate() - .text_right() .text_size(TEXT_XS) .text_color(theme.muted_foreground) .child(note) - }))) - .child( - Button::new("er-help") - .debug_selector(|| "er-help".into()) - .ghost() - .tool_icon(Icon::new(Lucide::CircleQuestionMark), ICON_XS) - .tooltip(hint), - ) + })) .child( - Button::new("er-keys-only") + form::filter_button("er-keys-only", Icon::new(Lucide::KeyRound)) .debug_selector(|| "er-keys-only".into()) - .tool(Icon::new(Lucide::KeyRound), ICON_XS, t(cx, "erDiagram.keysOnly")) .selected(self.keys_only) .disabled(!ready) .tooltip(t(cx, "erDiagram.keysOnlyTooltip")) .on_click(cx.listener(|this, _, window, cx| this.toggle_keys(window, cx))), ) - .child(div().flex_none().w(px(1.)).h(rems(1.231)).mx(rems(0.154)).bg(theme.border)) + .child(self.zoom_buttons(ready, cx)) + .child( + form::filter_button("er-fit", Icon::new(Lucide::Scan)) + .debug_selector(|| "er-fit".into()) + .disabled(!ready) + .tooltip(t(cx, "erDiagram.fitTooltip")) + .on_click(cx.listener(|this, _, window, cx| this.fit(window, cx))), + ) + .child( + form::filter_button("er-help", Icon::new(Lucide::CircleQuestionMark)) + .debug_selector(|| "er-help".into()) + .ghost() + .tooltip(hint), + ) + } + + // Joined as Explain and its menu are. The zoom keeps one width, so nothing shifts as it changes. + fn zoom_buttons(&self, ready: bool, cx: &mut Context) -> impl IntoElement + use<> { + let percent = format!("{}%", (self.zoom * 100.).round()); + h_flex() .child( - Button::new("er-zoom-out") + form::filter_button("er-zoom-out", Icon::new(IconName::Minus)) .debug_selector(|| "er-zoom-out".into()) - .tool_icon(Icon::new(IconName::Minus), ICON_XS) + .rounded_tr(px(0.)) + .rounded_br(px(0.)) .disabled(!ready || self.zoom <= zoom::MIN) .tooltip(t(cx, "erDiagram.zoomOut")) .on_click( @@ -380,29 +398,29 @@ impl ErDiagram { .child( Button::new("er-zoom-reset") .debug_selector(|| "er-zoom-reset".into()) - .tool_label(percent) + .small() + .h(rems(2.308)) + .w(rems(3.692)) + .p_0() + .rounded(px(0.)) + .border_l_0() + .child(div().text_size(TEXT_XS).child(percent)) .disabled(!ready) .tooltip(t(cx, "erDiagram.zoomReset")) .on_click(cx.listener(|this, _, window, cx| this.zoom_to(1., None, window, cx))), ) .child( - Button::new("er-zoom-in") + form::filter_button("er-zoom-in", Icon::new(IconName::Plus)) .debug_selector(|| "er-zoom-in".into()) - .tool_icon(Icon::new(IconName::Plus), ICON_XS) + .rounded_tl(px(0.)) + .rounded_bl(px(0.)) + .border_l_0() .disabled(!ready || self.zoom >= zoom::MAX) .tooltip(t(cx, "erDiagram.zoomIn")) .on_click( cx.listener(|this, _, window, cx| this.zoom_to(zoom::step(this.zoom, true), None, window, cx)), ), ) - .child( - Button::new("er-fit") - .debug_selector(|| "er-fit".into()) - .tool(Icon::new(Lucide::Scan), ICON_XS, t(cx, "erDiagram.fit")) - .disabled(!ready) - .tooltip(t(cx, "erDiagram.fitTooltip")) - .on_click(cx.listener(|this, _, window, cx| this.fit(window, cx))), - ) } fn diagram(&self, tables: &[ErTable], cx: &mut Context) -> impl IntoElement { @@ -554,6 +572,7 @@ impl ErDiagram { })) .child(header) .children(rows) + .test_support() .into_any_element() } } @@ -644,6 +663,14 @@ impl ErDiagram { } } + // Its box registers as `("er-table", ix)`. + pub(crate) fn table_index(&self, name: &str) -> Option { + match &self.state { + State::Ready(tables) => tables.iter().position(|table| table.name == name), + _ => None, + } + } + // As the zoom buttons do, past Fit's 100% cap. pub(crate) fn set_zoom(&mut self, zoom: f32, window: &Window, cx: &mut Context) { self.zoom_to(zoom, None, window, cx); diff --git a/crates/barsql-ui/src/er_diagram/tests.rs b/crates/barsql-ui/src/er_diagram/tests.rs index 4e8458b..a6cca2e 100644 --- a/crates/barsql-ui/src/er_diagram/tests.rs +++ b/crates/barsql-ui/src/er_diagram/tests.rs @@ -9,7 +9,7 @@ use gpui_kit::{ TestAppContext, TouchPhase, VisualTestContext, point, px, size, }; -use super::{ErDiagram, zoom}; +use super::{ErDiagram, MAX_TABLES, zoom}; use crate::test_support::Env; type Tables = Vec<(String, Vec)>; @@ -132,6 +132,20 @@ fn the_toolbar_keeps_its_hint_behind_a_help_button(cx: &mut TestAppContext) { assert!(cx.debug_bounds("er-note").is_none(), "nothing to say while every table shows"); } +// As wide as the tab gets in the smallest window. +#[gpui_kit::test] +fn a_narrow_toolbar_keeps_its_buttons_and_room_to_search(cx: &mut TestAppContext) { + let (view, _, cx) = diagram(cx, small()); + view.update(cx, |view, _| view.set_tables(MAX_TABLES + 140, small())); + cx.simulate_resize(size(px(500.), px(600.))); + let bar = bounds(cx, "er-toolbar"); + for button in ["er-keys-only", "er-zoom-out", "er-zoom-reset", "er-zoom-in", "er-fit", "er-help"] { + assert!(bounds(cx, button).right() <= bar.right(), "{button} sticks out of {bar:?}"); + } + let (search, note) = (bounds(cx, "er-search"), bounds(cx, "er-note")); + assert!(search.size.width >= px(65.) && note.size.width > px(0.), "{search:?} beside {note:?}"); +} + // It opens fitted, unless that would be too small to read. Fit then shows it all. #[gpui_kit::test] fn a_big_schema_opens_readable_and_fit_shows_it_all(cx: &mut TestAppContext) { diff --git a/crates/barsql-ui/src/grid/toolbar.rs b/crates/barsql-ui/src/grid/toolbar.rs index 24249e0..0e693d5 100644 --- a/crates/barsql-ui/src/grid/toolbar.rs +++ b/crates/barsql-ui/src/grid/toolbar.rs @@ -116,7 +116,7 @@ fn copy_button(grid: &Entity, cx: &mut App) -> AnyElement { .rounded_br(px(0.)) .on_click(move |_, window, cx| copy.update(cx, |grid, cx| grid.copy(false, window, cx))), ) - .child(crate::screenshots::probe( + .child( Button::new("copy-format") .debug_selector(|| "copy-format".into()) .tool_icon(Icon::new(IconName::ChevronDown), ICON_XS) @@ -138,8 +138,7 @@ fn copy_button(grid: &Entity, cx: &mut App) -> AnyElement { } menu }), - "copy-format", - )) + ) .into_any_element() } diff --git a/crates/barsql-ui/src/grid/view.rs b/crates/barsql-ui/src/grid/view.rs index 45b8772..37e0c58 100644 --- a/crates/barsql-ui/src/grid/view.rs +++ b/crates/barsql-ui/src/grid/view.rs @@ -386,7 +386,9 @@ pub struct Grid { // What the last right-click landed on, which picks the menu. menu_target: Option, // What `publish` last announced. - published: (Option, Vec, Option), + published: (Option, Vec, Option, u64), + // Counts the times the rows were swapped out, since new values under a focus that stayed are news too. + replaced: u64, pub(super) picker_open: bool, // Kept across renders so the column picker's list doesn't jump back to the top. pub(super) picker_scroll: ScrollHandle, @@ -433,7 +435,8 @@ impl Grid { press: None, hover: None, menu_target: None, - published: (None, Vec::new(), None), + published: (None, Vec::new(), None, 0), + replaced: 0, picker_open: false, picker_scroll: ScrollHandle::new(), overlay: None, @@ -460,7 +463,7 @@ impl Grid { // A sort renumbers the focused row without moving it, so that counts as a change. fn publish(&mut self, cx: &mut Context) { let display = self.selection.focus_row.and_then(|row| self.order.display_of(row)); - let published = (self.selection.focus_row, self.columns.clone(), display); + let published = (self.selection.focus_row, self.columns.clone(), display, self.replaced); if self.published != published { self.published = published; cx.emit(GridEvent::FocusedRowChanged); @@ -1953,6 +1956,22 @@ impl Grid { self.row_top(row) + m.row_height / 2., ) } + + pub(crate) fn gutter_point(&self, row: usize) -> Point { + point( + self.scroll.grid().left() + self.metrics.gutter_width / 2., + self.row_top(row) + self.metrics.row_height / 2., + ) + } + + // On the column's name, clear of its sort button and resize edge. + pub(crate) fn header_point(&self, col: usize) -> Point { + let (bounds, m, scroll) = (self.scroll.grid(), &self.metrics, self.scroll.position()); + point( + bounds.left() + m.gutter_width + self.col_x[col] + m.cell_pad_x + px(2.) - scroll.x, + bounds.top() + m.header_height / 2., + ) + } } // For README screenshots. row and col are display positions, and the scroll stops at the edges. @@ -1980,22 +1999,6 @@ impl Grid { self.sort.column.map(|c| (self.set.columns[c].name.clone(), self.sort.desc)) } - pub(crate) fn gutter_point(&self, row: usize) -> Point { - point( - self.scroll.grid().left() + self.metrics.gutter_width / 2., - self.row_top(row) + self.metrics.row_height / 2., - ) - } - - // On the column's name, clear of its sort button and resize edge. - pub(crate) fn header_point(&self, col: usize) -> Point { - let (bounds, m, scroll) = (self.scroll.grid(), &self.metrics, self.scroll.position()); - point( - bounds.left() + m.gutter_width + self.col_x[col] + m.cell_pad_x + px(2.) - scroll.x, - bounds.top() + m.header_height / 2., - ) - } - pub(crate) fn is_editing(&self) -> bool { self.editing.is_some() } diff --git a/crates/barsql-ui/src/grid/view/table.rs b/crates/barsql-ui/src/grid/view/table.rs index e0fc6e3..ecc3693 100644 --- a/crates/barsql-ui/src/grid/view/table.rs +++ b/crates/barsql-ui/src/grid/view/table.rs @@ -128,6 +128,7 @@ impl Grid { && columns.iter().zip(self.set.columns.iter()).all(|(a, b)| a.name == b.name); let count = columns.len(); self.set = ResultSet::new(columns); + self.replaced += 1; self.order = RowOrder::identity(0); self.ordered_for = (SortState::default(), 0); self.sorting = None; @@ -143,6 +144,8 @@ impl Grid { self.sampled = None; self.selection = Default::default(); } + // The old focus is gone, even if no rows come to take its place. + self.publish(cx); cx.notify(); } @@ -157,6 +160,7 @@ impl Grid { } let (focus, col) = (self.selection.focus_row.and_then(moved), self.selection.focus_col); self.set = set; + self.replaced += 1; // No row count matches it, so the order is worked out again for the new values. self.ordered_for = (SortState::default(), usize::MAX); self.sorting = None; diff --git a/crates/barsql-ui/src/hover_card.rs b/crates/barsql-ui/src/hover_card.rs index 8417071..22eed32 100644 --- a/crates/barsql-ui/src/hover_card.rs +++ b/crates/barsql-ui/src/hover_card.rs @@ -266,6 +266,13 @@ impl HoverCard { } } +#[cfg(feature = "snapshot")] +impl HoverCard { + pub(crate) fn showing(&self) -> bool { + self.card.is_some() + } +} + #[cfg(test)] impl HoverCard { // The header's name and detail, then each row's parts, all joined by spaces. diff --git a/crates/barsql-ui/src/lib.rs b/crates/barsql-ui/src/lib.rs index 9f01c27..5f3522c 100644 --- a/crates/barsql-ui/src/lib.rs +++ b/crates/barsql-ui/src/lib.rs @@ -88,10 +88,18 @@ pub struct LaunchOptions { pub snapshot_size: Option<(f32, f32)>, // Play the README scenes against the forum database, save 1.png to 12.png here and quit. pub screenshots: Option, + // Play the demo video's tour, write its frames here and quit. + pub demo_video: Option, // Look for a newer release 5 s after launch. Production builds only. pub check_updates: bool, } +impl LaunchOptions { + fn unattended(&self) -> bool { + self.snapshot.is_some() || self.screenshots.is_some() || self.demo_video.is_some() + } +} + // Open transactions roll back on quit, best effort within GPUI's quit budget. Servers roll back abandoned // sessions anyway. pub fn run(bar: BarApp, options: LaunchOptions) { @@ -105,15 +113,15 @@ pub fn run(bar: BarApp, options: LaunchOptions) { opener.open_sqlite(&path); } }); - // Snapshots and screenshots quit unattended, with nobody to ask. + // Snapshots, screenshots and the demo video quit unattended, with nobody to ask. #[cfg(target_os = "macos")] - if options.snapshot.is_none() && options.screenshots.is_none() { + if !options.unattended() { system_quit::install(); } app.run(move |cx| { gpui_kit::init(cx); - if options.screenshots.is_some() { - screenshots::begin(cx); + if options.screenshots.is_some() || options.demo_video.is_some() { + screenshots::begin(options.demo_video.is_some(), cx); } // No animations, so dialogs, menus, tooltips and toasts open and close at once. cx.set_reduce_motion(true); diff --git a/crates/barsql-ui/src/plan_view.rs b/crates/barsql-ui/src/plan_view.rs index f1afbb7..81379e8 100644 --- a/crates/barsql-ui/src/plan_view.rs +++ b/crates/barsql-ui/src/plan_view.rs @@ -130,11 +130,16 @@ impl PlanView { } } - // README screenshots. Several nodes can tie for hottest, and the deepest one has the richest details. + // Ties go to the deepest node, which has the richest details. Its label registers as `plan-node-{key}`. #[cfg(feature = "snapshot")] - pub(crate) fn select_hottest(&mut self, cx: &mut Context) { + pub(crate) fn hottest(&self) -> Option { let rows = flatten(&self.plan, self.metric, &self.collapsed); - if let Some(key) = rows.iter().rev().find(|row| row.hottest).map(|row| row.key.clone()) { + rows.iter().rev().find(|row| row.hottest).map(|row| row.key.clone()) + } + + #[cfg(feature = "snapshot")] + pub(crate) fn select_hottest(&mut self, cx: &mut Context) { + if let Some(key) = self.hottest() { self.select(key, cx); } } @@ -273,6 +278,7 @@ impl PlanView { }; let label_color = if node.never_run { theme.muted_foreground } else { theme.foreground }; let main = h_flex() + .id(SharedString::from(format!("plan-node-{key}"))) .flex_1() .min_w(rems(18.)) .gap(rems(0.462)) @@ -315,7 +321,9 @@ impl PlanView { .font_family(theme.mono_font_family.clone()) .child(node.detail.clone()), ) - }); + }) + // The row has a context menu, which takes no test support after it. + .test_support(); let cell = |main: String, sub: Option| { h_flex() diff --git a/crates/barsql-ui/src/query_tab.rs b/crates/barsql-ui/src/query_tab.rs index a63a16d..2a33093 100644 --- a/crates/barsql-ui/src/query_tab.rs +++ b/crates/barsql-ui/src/query_tab.rs @@ -1296,6 +1296,7 @@ impl Render for QueryTab { ) .child( div() + .id("results-pane") .debug_selector(|| "results-pane".into()) .relative() .flex_none() @@ -1313,7 +1314,8 @@ impl Render for QueryTab { cx.notify(); })), ) - .when(self.running.is_some(), |el| el.child(ProgressLine)), + .when(self.running.is_some(), |el| el.child(ProgressLine)) + .test_support(), ) } } @@ -1360,14 +1362,14 @@ impl QueryTab { pub(crate) fn in_transaction(&self) -> bool { self.txn != TxnState::Idle } -} -#[cfg(test)] -impl QueryTab { pub(crate) fn editor(&self) -> Entity { self.editor.clone() } +} +#[cfg(test)] +impl QueryTab { pub(crate) fn occurrence_marks(&self, cx: &App) -> Vec> { self._occurrences.read(cx).ranges(cx) } diff --git a/crates/barsql-ui/src/scenarios/result_edits.rs b/crates/barsql-ui/src/scenarios/result_edits.rs index 88eb770..92759d2 100644 --- a/crates/barsql-ui/src/scenarios/result_edits.rs +++ b/crates/barsql-ui/src/scenarios/result_edits.rs @@ -4,7 +4,7 @@ use gpui_kit::component::WindowExt as _; use gpui_kit::{Entity, Modifiers, TestAppContext}; use super::driver::{Driver, open}; -use crate::actions::CloseTab; +use crate::actions::{CloseTab, ToggleJsonPanel}; use crate::grid::Grid; use crate::query_tab::TxnState; @@ -69,6 +69,21 @@ fn an_edited_query_result_saves_to_its_table(cx: &mut TestAppContext) { assert_eq!(app.query("SELECT name FROM e2e_qe WHERE id = 1")[0][0], Value::Text("Alicia".into())); } +// Apply swaps in the saved rows without moving the focus. +#[gpui_kit::test] +fn the_json_viewer_shows_an_applied_result_edit(cx: &mut TestAppContext) { + let mut app = editable(cx, "SELECT * FROM e2e_qe ORDER BY id"); + app.dispatch(ToggleJsonPanel); + click_cell(&mut app, 0, 1); + let panel = app.cx.update(|_, cx| app.workspace.read(cx).json_panel()).expect("the JSON viewer is open"); + let json = |app: &mut Driver| app.cx.update(|_, cx| panel.read(cx).text().unwrap_or_default().to_string()); + assert!(json(&mut app).contains("Alice")); + edit_cell(&mut app, 0, 1, "Alicia"); + apply(&mut app); + let shown = json(&mut app); + assert!(shown.contains("Alicia") && !shown.contains("Alice\""), "{shown}"); +} + #[gpui_kit::test] fn a_row_marked_for_delete_leaves_the_result_and_the_table(cx: &mut TestAppContext) { let mut app = editable(cx, "SELECT * FROM e2e_qe ORDER BY id"); diff --git a/crates/barsql-ui/src/scenarios/table_view.rs b/crates/barsql-ui/src/scenarios/table_view.rs index cb33e8c..55d4457 100644 --- a/crates/barsql-ui/src/scenarios/table_view.rs +++ b/crates/barsql-ui/src/scenarios/table_view.rs @@ -2,6 +2,7 @@ use barsql_core::Value; use gpui_kit::{Entity, Modifiers, ScrollDelta, ScrollEvent, TestAppContext, point, px}; use super::driver::{Driver, open, selector}; +use crate::actions::ToggleJsonPanel; use crate::cell_viewer::{CellViewer, Opened}; use crate::export_dialog::Opened as ExportOpened; use crate::grid::Grid; @@ -276,6 +277,45 @@ fn an_edited_cell_persists_after_apply(cx: &mut TestAppContext) { assert_eq!(app.query("SELECT name FROM e2e_edit")[0][0], Value::Text("Updated".into())); } +// The returned reader gives what the viewer shows, None for no row. +fn json_of_first_row(app: &mut Driver) -> impl FnMut(&mut Driver) -> Option + use<> { + app.dispatch(ToggleJsonPanel); + click_cell(app, 0, 1); + let panel = app.cx.update(|_, cx| app.workspace.read(cx).json_panel()).expect("the JSON viewer is open"); + move |app| app.cx.update(|_, cx| panel.read(cx).text().map(str::to_string)) +} + +// Apply reloads the page with the focus still on the edited row. +#[gpui_kit::test] +fn the_json_viewer_shows_an_applied_edit(cx: &mut TestAppContext) { + let mut app = + prepared(cx, &format!("CREATE TABLE e2e_jsonapply {PEOPLE}; INSERT INTO e2e_jsonapply VALUES (1, 'Alice')")); + browse(&mut app, "e2e_jsonapply"); + let mut json = json_of_first_row(&mut app); + assert!(json(&mut app).is_some_and(|shown| shown.contains("Alice"))); + edit_cell(&mut app, 0, 1, "Updated"); + app.click("table-apply"); + wait(&mut app); + let shown = json(&mut app).unwrap_or_default(); + assert!(shown.contains("Updated") && !shown.contains("Alice"), "{shown}"); +} + +#[gpui_kit::test] +fn the_json_viewer_empties_once_apply_deletes_the_last_row(cx: &mut TestAppContext) { + let mut app = + prepared(cx, &format!("CREATE TABLE e2e_jsondel {PEOPLE}; INSERT INTO e2e_jsondel VALUES (1, 'Alice')")); + browse(&mut app, "e2e_jsondel"); + let mut json = json_of_first_row(&mut app); + assert!(json(&mut app).is_some()); + let grid = grid(&mut app); + let at = app.grid_point(&grid, |grid| grid.gutter_point(0)); + app.double_click_at(at); + assert_eq!(pending(&mut app), (0, 1)); + app.click("table-apply"); + wait(&mut app); + assert_eq!((rows(&mut app), json(&mut app)), (0, None)); +} + #[gpui_kit::test] fn typing_in_a_double_clicked_cell_appends_to_its_value(cx: &mut TestAppContext) { let mut app = prepared(cx, &format!("CREATE TABLE e2e_caret {PEOPLE}; INSERT INTO e2e_caret VALUES (1, 'Alice')")); diff --git a/crates/barsql-ui/src/schema_diff_view.rs b/crates/barsql-ui/src/schema_diff_view.rs index 6b01d67..ff30b6b 100644 --- a/crates/barsql-ui/src/schema_diff_view.rs +++ b/crates/barsql-ui/src/schema_diff_view.rs @@ -354,6 +354,8 @@ pub fn open( ) { let title = t(cx, "schemaDiff.title"); let view = cx.new(|cx| SchemaDiffView::new(source, schema, Rc::new(on_script), cx)); + #[cfg(feature = "snapshot")] + cx.set_global(Opened(view.downgrade())); window.open_dialog(cx, move |dialog, window, cx| { let height = window.viewport_size().height * 0.75; let entry = view.clone(); @@ -367,3 +369,21 @@ pub fn open( }) }); } + +// Last opened dialog, for the demo video. +#[cfg(feature = "snapshot")] +pub(crate) struct Opened(pub WeakEntity); + +#[cfg(feature = "snapshot")] +impl Global for Opened {} + +#[cfg(feature = "snapshot")] +impl SchemaDiffView { + pub(crate) fn ready(&self) -> bool { + matches!(self.state, Compare::Ready { .. }) + } + + pub(crate) fn target_schema(&self) -> Option<&str> { + self.target_schema.as_deref() + } +} diff --git a/crates/barsql-ui/src/schema_tree.rs b/crates/barsql-ui/src/schema_tree.rs index 9835a94..96ed80b 100644 --- a/crates/barsql-ui/src/schema_tree.rs +++ b/crates/barsql-ui/src/schema_tree.rs @@ -1446,7 +1446,16 @@ impl SchemaTree { let guide_left = |level: usize| rems(INDENT_REM * level as f32 + ROW_PADDING.0 + ICON_SM.0 / 2.); let guides = (0..depth) .map(|level| div().absolute().top_0().bottom_0().left(guide_left(level)).w(px(1.)).bg(theme.border)); - div().relative().w_full().pl(rems(INDENT_REM * depth as f32)).children(guides).child(element).into_any_element() + // Under the row's id too, as the row's context menu hides it from test lookups. + div() + .id(row.id) + .relative() + .w_full() + .pl(rems(INDENT_REM * depth as f32)) + .children(guides) + .child(element) + .test_support() + .into_any_element() } // Sits over the row's end on a gradient that fades out the truncated name. @@ -1508,7 +1517,8 @@ impl SchemaTree { .on_click(cx.listener(move |_, _, _, cx| { cx.stop_propagation(); cx.emit(SchemaTreeEvent::Browse { schema: browse_schema.clone(), table: browse_table.clone() }); - })), + })) + .test_support(), ) } @@ -1801,6 +1811,11 @@ fn scenario_id(id: &str, connection: Option<&ConnectionConfig>) -> String { #[cfg(any(test, feature = "snapshot"))] impl SchemaTree { + #[cfg(feature = "snapshot")] + pub(crate) fn loading(&self) -> bool { + self.rows.iter().any(|row| matches!(row.kind, RowKind::Note { spinner: true, .. })) + } + // Each row as (scenario id, label, extra), where extra is the kind, badge or count. pub(crate) fn listed(&self) -> Vec<(String, String, String)> { self.rows @@ -1864,6 +1879,13 @@ impl SchemaTree { pub(crate) fn import_into(&mut self, schema: &str, table: &str, window: &mut Window, cx: &mut Context) { self.import(schema.to_string(), Some(table.to_string()), window, cx); } + + // The element id of the row with this scenario id, such as `t:public.users`. + pub(crate) fn row_id(&self, scenario: &str) -> Option { + let connection = self.connection.as_ref(); + let row = self.rows.iter().find(|row| scenario_id(&row.id, connection) == scenario)?; + Some(row.id.clone()) + } } #[cfg(test)] diff --git a/crates/barsql-ui/src/screenshots.rs b/crates/barsql-ui/src/screenshots.rs index 2680a10..d7b9001 100644 --- a/crates/barsql-ui/src/screenshots.rs +++ b/crates/barsql-ui/src/screenshots.rs @@ -1,9 +1,6 @@ -// README screenshots, run by `cargo xtask screenshots` via BARSQL_SCREENSHOTS. Plays the scenes against the -// forum database, saves each as `.png` and quits. +// The screenshots for `cargo xtask screenshots`: plays the scenes against the forum database, saves each as `.png`. use std::path::PathBuf; -#[cfg(feature = "snapshot")] -use gpui_kit::base::ElementExt as _; use gpui_kit::component::Root; use gpui_kit::*; @@ -18,27 +15,16 @@ pub fn windows_title_bar(_: &App) -> bool { false } -// Records where an element was drawn so the scenes can click it. -#[cfg(feature = "snapshot")] -pub fn probe(element: impl IntoElement, name: &'static str) -> AnyElement { - let record = move |bounds: Bounds, _: &mut Window, cx: &mut App| { - cx.default_global::().0.insert(name, bounds); - }; - div().relative().child(element).on_prepaint(record).into_any_element() -} - -#[cfg(not(feature = "snapshot"))] -pub fn probe(element: impl IntoElement, _: &'static str) -> AnyElement { - element.into_any_element() -} - #[cfg(feature = "snapshot")] -pub fn begin(cx: &mut App) { +pub fn begin(video: bool, cx: &mut App) { cx.set_global(script::WindowsTitleBar); + if video { + video::watch_inputs(cx); + } } #[cfg(not(feature = "snapshot"))] -pub fn begin(_: &mut App) {} +pub fn begin(_: bool, _: &mut App) {} #[cfg(feature = "snapshot")] pub fn run(window: WindowHandle, out: PathBuf, cx: &mut AsyncApp) { @@ -51,9 +37,32 @@ pub fn run(_: WindowHandle, _: PathBuf, cx: &mut AsyncApp) { cx.update(|cx| cx.quit()); } +#[cfg(feature = "snapshot")] +pub fn record(window: WindowHandle, out: PathBuf, cx: &mut AsyncApp) { + video::run(window.into(), out, cx); +} + +#[cfg(not(feature = "snapshot"))] +pub fn record(_: WindowHandle, _: PathBuf, cx: &mut AsyncApp) { + eprintln!("BARSQL_DEMO_VIDEO needs a build with --features snapshot"); + cx.update(|cx| cx.quit()); +} + +#[cfg(feature = "snapshot")] +pub fn overlay(window: &mut Window, cx: &mut App) -> Option { + video::overlay(window, cx) +} + +#[cfg(not(feature = "snapshot"))] +pub fn overlay(_: &mut Window, _: &mut App) -> Option { + None +} + +#[cfg(feature = "snapshot")] +mod video; + #[cfg(feature = "snapshot")] mod script { - use std::collections::HashMap; use std::io::BufWriter; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -62,22 +71,23 @@ mod script { use barsql_sql::lang::TxnControl; use barsql_sql::structure::{ColumnRow, Draft, Row}; use gpui_kit::component::{Root, WindowExt}; + use gpui_kit::test::TestWindowExt as _; use gpui_kit::*; - use image::DynamicImage; use image::codecs::png::{CompressionType, FilterType, PngEncoder}; + use image::{DynamicImage, RgbaImage}; use crate::grid::Grid; use crate::schema_objects::Group; use crate::workspace::Workspace; use crate::{connection_dialog, export_dialog, import_dialog, query_tab, theme}; - // In points. The PNGs come out at the display's scale. - const SIZE: (f32, f32) = (2048., 1152.); + // In points; shots come out at 1920 × 1080 pixels on any display. + pub(super) const SIZE: (f32, f32) = (1920., 1080.); const SIDEBAR_WIDTH: f32 = 364.; const JSON_WIDTH: f32 = 410.; - const WAIT: Duration = Duration::from_secs(30); + pub(super) const WAIT: Duration = Duration::from_secs(30); const POSTS_SQL: &str = "SELECT * FROM posts p ORDER BY p.id;"; - const PLAN_SQL: &str = "SELECT c.name AS category, u.display_name AS author, + pub(super) const PLAN_SQL: &str = "SELECT c.name AS category, u.display_name AS author, COUNT(*) AS posts, SUM(p.views) AS views FROM posts p JOIN users u ON u.id = p.author_id @@ -98,7 +108,7 @@ INSERT INTO categories ('Kotlin', 'Talk about anything related to Kotlin.', '{\"allowLinks\": true}') RETURNING *; "; - const CHART_SQL: &str = "SELECT date_trunc('month', created_at)::date AS month, + pub(super) const CHART_SQL: &str = "SELECT date_trunc('month', created_at)::date AS month, COUNT(*) AS posts, COUNT(*) FILTER (WHERE score >= 500) AS popular, COUNT(*) FILTER (WHERE score >= 900) AS top_rated @@ -111,18 +121,13 @@ ORDER BY 1;"; const DISPLAY_NAME: usize = 3; const PREFERENCES: usize = 10; // Relative so the dialog shows a short path. The xtask runs the app from the repo root. - const IMPORT_CSV: &str = "fixtures/screenshots/users.csv"; + pub(super) const IMPORT_CSV: &str = "fixtures/screenshots/users.csv"; pub struct WindowsTitleBar; impl Global for WindowsTitleBar {} - #[derive(Default)] - pub struct Probes(pub HashMap<&'static str, Bounds>); - - impl Global for Probes {} - - type Res = Result; + pub(super) type Res = Result; pub fn run(window: WindowHandle, out: PathBuf, cx: &mut AsyncApp) { cx.spawn(async move |cx| { @@ -139,12 +144,16 @@ ORDER BY 1;"; // A window behind others gets no frames, so every step draws one and input lands on what it showed. The // handle is untyped so nothing holds the root view, which dialogs update. - struct Robot { - window: AnyWindowHandle, + pub(super) struct Robot { + pub(super) window: AnyWindowHandle, } impl Robot { - fn act(&self, cx: &mut AsyncApp, f: impl FnOnce(&Entity, &mut Window, &mut App) -> R) -> Res { + pub(super) fn act( + &self, + cx: &mut AsyncApp, + f: impl FnOnce(&Entity, &mut Window, &mut App) -> R, + ) -> Res { self.window .update(cx, |root, window, cx| { let root = root.downcast::().map_err(|_| "no root")?; @@ -154,7 +163,7 @@ ORDER BY 1;"; .map_err(|error| error.to_string())? } - fn workspace( + pub(super) fn workspace( &self, cx: &mut AsyncApp, f: impl FnOnce(&mut Workspace, &mut Window, &mut Context) -> R, @@ -170,11 +179,13 @@ ORDER BY 1;"; let _ = self.window.update(cx, |_, window, cx| { window.refresh(); window.draw(cx).clear(cx); + // Windows sends a hidden window no frames, so its next-frame callbacks wait on these. + window.simulate_next_frame(cx); }); self.pause(cx, 40).await; } - async fn settle( + pub(super) async fn settle( &self, cx: &mut AsyncApp, what: &str, @@ -244,6 +255,11 @@ ORDER BY 1;"; window.render_to_image().map_err(|error| error.to_string()) }) .map_err(|error| error.to_string())??; + let (width, height) = (SIZE.0 as u32, SIZE.1 as u32); + let image = match image.dimensions() == (width, height) { + true => image, + false => shrink(&image, width, height), + }; let file = std::fs::File::create(path).map_err(|error| format!("{}: {error}", path.display()))?; let encoder = PngEncoder::new_with_quality(BufWriter::new(file), CompressionType::Best, FilterType::Adaptive); @@ -255,9 +271,35 @@ ORDER BY 1;"; Ok(()) } - fn probe(&self, cx: &mut AsyncApp, name: &str) -> Res> { - let bounds = cx.update(|cx| cx.try_global::().and_then(|probes| probes.0.get(name).copied())); - bounds.map(|bounds| bounds.center()).ok_or_else(|| format!("{name} was not drawn")) + // In the last frame. Two matches are an error, not try_find's panic, which would abort the app. + pub(super) fn bounds(&self, cx: &mut AsyncApp, id: impl Into) -> Res> { + let id = id.into(); + let found = self.act(cx, |_, window, _| window.find_all(id.clone()))?; + match found.as_slice() { + [element] => Ok(element.bounds()), + [] => Err(format!("{id} was not drawn")), + _ => Err(format!("{id} was drawn {} times", found.len())), + } + } + + pub(super) fn center(&self, cx: &mut AsyncApp, id: impl Into) -> Res> { + Ok(self.bounds(cx, id)?.center()) + } + + // Inputs all register under one id, and a password under its own role, so take the scope's first. + pub(super) fn field(&self, cx: &mut AsyncApp, scope: &'static str) -> Res> { + let found = self + .act(cx, |_, window, _| window.within(scope).elements().first().map(|input| input.bounds().center()))?; + found.ok_or_else(|| format!("no input in {scope}")) + } + + // A schema tree row by its scenario id, such as `t:public.users`. + pub(super) fn tree_row(&self, cx: &mut AsyncApp, row: &str) -> Res> { + let id = self.act(cx, |workspace, _, cx| { + let tree = workspace.read(cx).sidebar().read(cx).panels().0; + tree.read(cx).row_id(row) + })?; + self.center(cx, id.ok_or_else(|| format!("no tree row {row}"))?) } async fn close_dialog(&self, cx: &mut AsyncApp) -> Res<()> { @@ -269,8 +311,25 @@ ORDER BY 1;"; self.act(cx, |workspace, _, cx| workspace.read(cx).active_grid(cx))?.ok_or_else(|| "no grid".into()) } - fn cell(&self, cx: &mut AsyncApp, row: usize, col: usize) -> Res> { + pub(super) fn cell(&self, cx: &mut AsyncApp, row: usize, col: usize) -> Res> { + let grid = self.grid(cx)?; + Ok(cx.update(|cx| grid.read(cx).cell_point(row, col))) + } + + pub(super) fn header(&self, cx: &mut AsyncApp, col: usize) -> Res> { + let grid = self.grid(cx)?; + Ok(cx.update(|cx| grid.read(cx).header_point(col))) + } + + pub(super) fn gutter(&self, cx: &mut AsyncApp, row: usize) -> Res> { let grid = self.grid(cx)?; + Ok(cx.update(|cx| grid.read(cx).gutter_point(row))) + } + + pub(super) fn structure_cell(&self, cx: &mut AsyncApp, row: usize, col: usize) -> Res> { + let grid = + self.act(cx, |workspace, _, cx| workspace.read(cx).structure_tab().map(|tab| tab.read(cx).grid()))?; + let grid = grid.ok_or("no Structure tab")?; Ok(cx.update(|cx| grid.read(cx).cell_point(row, col))) } @@ -283,12 +342,12 @@ ORDER BY 1;"; Ok(()) } - async fn fit_window(&self, cx: &mut AsyncApp) -> Res<()> { + pub(super) async fn fit_window(&self, cx: &mut AsyncApp) -> Res<()> { let target = size(px(SIZE.0), px(SIZE.1)); let deadline = Instant::now() + WAIT; while self.act(cx, |_, window, _| window.viewport_size())? != target { if Instant::now() > deadline { - return Err("the window would not grow to 2048 × 1152".into()); + return Err("the window would not grow to 1920 × 1080".into()); } self.act(cx, |_, window, _| window.resize(target))?; self.pause(cx, 250).await; @@ -317,7 +376,7 @@ ORDER BY 1;"; Ok(()) } - async fn activate(&self, cx: &mut AsyncApp, title: &str) -> Res<()> { + pub(super) async fn activate(&self, cx: &mut AsyncApp, title: &str) -> Res<()> { let found = self.workspace(cx, |workspace, window, cx| workspace.activate_title(title, window, cx))?; if !found { return Err(format!("no {title} tab")); @@ -347,6 +406,43 @@ ORDER BY 1;"; workspace.read(cx).query_tab().is_some_and(|tab| tab.read(cx).is_running()) } + // Area averaging: a 2x render comes down in exact 2 × 2 blocks, a 125% one weighs pixels by overlap. + fn shrink(image: &RgbaImage, width: u32, height: u32) -> RgbaImage { + let (columns, rows) = (areas(image.width(), width), areas(image.height(), height)); + let (from, to) = (image.width() as usize * 4, width as usize * 4); + let mut out = RgbaImage::new(width, height); + let mut line = vec![0f32; from]; + for ((first, shares), target) in rows.iter().zip(out.chunks_exact_mut(to)) { + line.fill(0.); + for (row, share) in image.chunks_exact(from).skip(*first).zip(shares) { + for (sum, &value) in line.iter_mut().zip(row) { + *sum += share * f32::from(value); + } + } + for ((first, shares), pixel) in columns.iter().zip(target.as_chunks_mut::<4>().0) { + for (channel, value) in pixel.iter_mut().enumerate() { + let sum: f32 = + shares.iter().enumerate().map(|(i, share)| share * line[(first + i) * 4 + channel]).sum(); + *value = (sum + 0.5) as u8; + } + } + } + out + } + + // Along one axis, each output pixel's first source pixel and the shares of those it covers. + fn areas(from: u32, to: u32) -> Vec<(usize, Vec)> { + let ratio = f64::from(from) / f64::from(to); + (0..to) + .map(|ix| { + let (start, end) = (f64::from(ix) * ratio, f64::from(ix + 1) * ratio); + let (first, past) = (start.floor() as usize, (end.ceil() as usize).min(from as usize)); + let share = |px: usize| ((end.min(px as f64 + 1.) - start.max(px as f64)) / ratio) as f32; + (first, (first..past).map(share).collect()) + }) + .collect() + } + // A slug column with a unique index, a longer title and is_locked dropped, typed as the catalog spells them. fn stage_posts(draft: &mut Draft) { let mut text = String::new(); @@ -559,7 +655,7 @@ ORDER BY 1;"; workspace.read(cx).active_grid(cx).is_some_and(|grid| grid.read(cx).selection_counts() == (14, 3)) }) .await?; - let formats = robot.probe(cx, "copy-format")?; + let formats = robot.center(cx, "copy-format")?; robot.click(cx, formats, Modifiers::none(), 1).await; robot.capture(cx, &shot(5)).await?; robot.keys(cx, "escape").await?; @@ -769,4 +865,28 @@ ORDER BY 1;"; robot.capture(cx, &shot(15)).await?; Ok(()) } + + #[cfg(test)] + mod tests { + use image::{Rgba, RgbaImage, imageops}; + + use super::shrink; + + #[test] + fn a_2x_render_comes_down_in_whole_pixel_blocks() { + let render = + RgbaImage::from_fn(64, 36, |x, y| Rgba([(x * 37 + y * 11) as u8, (x * y) as u8, (x ^ y) as u8, 255])); + assert_eq!(shrink(&render, 32, 18), imageops::thumbnail(&render, 32, 18)); + } + + #[test] + fn a_125_percent_render_weighs_pixels_by_area() { + let stripes = RgbaImage::from_fn(5, 1, |x, _| { + let shade = if x % 2 == 1 { 255 } else { 0 }; + Rgba([shade, shade, shade, 255]) + }); + let shades: Vec = shrink(&stripes, 4, 1).pixels().map(|pixel| pixel[0]).collect(); + assert_eq!(shades, [51, 153, 153, 51]); + } + } } diff --git a/crates/barsql-ui/src/screenshots/video.rs b/crates/barsql-ui/src/screenshots/video.rs new file mode 100644 index 0000000..fc07f45 --- /dev/null +++ b/crates/barsql-ui/src/screenshots/video.rs @@ -0,0 +1,1567 @@ +// The demo video for `cargo xtask demo-video`: tours the app offscreen and saves the changed frames for ffmpeg. +use std::cell::{Cell, RefCell}; +use std::fs::File; +use std::io::{BufWriter, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use gpui_kit::base::input::{InputBaseState, InputModeKind}; +use gpui_kit::component::input::{EditorState, InputState}; +use gpui_kit::component::{ActiveTheme as _, WindowExt as _}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::test::{TestQueryExt as _, TestWindowExt as _}; +use gpui_kit::*; +use image::codecs::png::{CompressionType, FilterType, PngEncoder}; +use image::{ExtendedColorType, ImageEncoder as _, RgbaImage, imageops}; + +use barsql_core::{ConnectionConfig, DriverType}; + +use super::script::{CHART_SQL, IMPORT_CSV, PLAN_SQL, Res, Robot, SIZE, WAIT}; +use crate::assets::{LOGO, vector_img}; +use crate::connection_dialog::{self, ConnectionForm}; +use crate::i18n::t; +use crate::import_dialog::{self, ImportDialog}; +use crate::plan_view::PlanView; +use crate::query_tab::{self, QueryTab}; +use crate::schema_diff_view::{self, SchemaDiffView}; +use crate::schema_objects::Group; +use crate::workspace::Workspace; +use crate::{state, theme}; + +const SIDEBAR_WIDTH: f32 = 360.; +const JSON_WIDTH: f32 = 440.; +const FRAME: Duration = Duration::from_micros(33_333); +const RING_FRAMES: usize = 8; +const POINTER_SIZE: f32 = 34.; +// The arrow's tip, from the SVG's 32-unit box. +const POINTER_TIP: (f32, f32) = (8. * POINTER_SIZE / 32., 4. * POINTER_SIZE / 32.); +// Above dialogs and menus. +const OVERLAY_PRIORITY: usize = 1 << 20; +// A dialog's caret: over GPUI Kit's dialogs, which draw at 10 and up, and under its popups at 100. +const DIALOG_MARKS_PRIORITY: usize = 99; +// Each half of the drawn caret's blink. +const BLINK: Duration = Duration::from_millis(530); +const FADE_OUT: Duration = Duration::from_millis(150); +const FADE_IN: Duration = Duration::from_millis(200); +const CROSSFADE_FRAMES: usize = 9; +// The query tabs' results split, set once so it never moves on camera. +const RESULTS_SHARE: f32 = 60.; +// Column indexes in `public.posts`, and its rows in a Structure tab. +const POST_CATEGORY: usize = 1; +const POST_TITLE: usize = 3; +const CONTENT_ROW: usize = 4; +const IS_LOCKED_ROW: usize = 8; +// From fixtures/demo-video/staging.sql. +const USERS_TRIGGER: &str = "users_normalize_email"; +const POINTER: &[u8] = br##""##; + +// What the overlay draws over the app. +#[derive(Default)] +struct Stage { + // The last click and how far its ring has faded, 0 to 1. + ring: Option<(Point, f32)>, + caption: Option, + leaving: Option, + end_card: bool, + // GPUI Kit paints carets and selections only in an active window, which the recording's never is. + inputs: Vec, + // Video time, and when the caret last moved, which restarts its blink. + now: Duration, + caret_moved: Option, +} + +impl Global for Stage {} + +#[derive(Clone)] +struct Caption { + text: SharedString, + // Lifts the caption above this element, off the rows under it. + above: Option<&'static str>, + // When its fade starts: in while showing, out once leaving. + fade_from: Duration, +} + +// From launch, so the inputs the window opens with are watched too. +pub fn watch_inputs(cx: &mut App) { + cx.observe_new(|_: &mut InputState, _, cx| add_input(TextInput::Line(cx.entity().downgrade()), cx)).detach(); + cx.observe_new(|_: &mut EditorState, _, cx| add_input(TextInput::Editor(cx.entity().downgrade()), cx)).detach(); +} + +fn add_input(input: TextInput, cx: &mut App) { + let inputs = &mut cx.default_global::().inputs; + inputs.retain(TextInput::alive); + inputs.push(input); +} + +#[derive(Clone)] +enum TextInput { + Line(WeakEntity), + Editor(WeakEntity), +} + +impl TextInput { + fn alive(&self) -> bool { + match self { + TextInput::Line(input) => input.upgrade().is_some(), + TextInput::Editor(editor) => editor.upgrade().is_some(), + } + } + + fn marks(&self, window: &mut Window, cx: &mut App) -> Option { + match self { + TextInput::Line(input) => marks(&input.upgrade()?, window, cx), + TextInput::Editor(editor) => marks(&editor.upgrade()?, window, cx), + } + } +} + +struct Marks { + caret: Option>, + selection: Vec>, +} + +fn marks(input: &Entity>, window: &mut Window, cx: &mut App) -> Option { + let state = input.read(cx); + if window.is_window_active() || !state.focus_handle(cx).is_focused(window) { + return None; + } + // GPUI Kit's caret-anchored popups place themselves the same way. + let caret = state.prepaint_caret_geometry().map(|geometry| { + let bounds = geometry.cursor_bounds(); + Bounds::new(point(bounds.origin.x, bounds.origin.y + geometry.scroll_offset().y), bounds.size) + }); + let (text, laid_out) = (state.value(), state.text_bounds()); + // The IME's range bounds stop at a line's end. + let selection = input.update(cx, |state, cx| { + let (selected, laid_out) = (state.selected_text_range(false, window, cx)?.range, laid_out?); + let mut boxes = Vec::new(); + let mut start = 0; + for line in text.split('\n') { + let end = start + line.encode_utf16().count(); + let (from, to) = (selected.start.max(start), selected.end.min(end)); + if from < to { + boxes.extend(state.bounds_for_range(from..to, laid_out, window, cx)); + } + start = end + 1; + } + Some(boxes) + }); + Some(Marks { caret, selection: selection.unwrap_or_default() }) +} + +pub fn overlay(window: &mut Window, cx: &mut App) -> Option { + let marks_priority = if window.has_active_dialog(cx) { DIALOG_MARKS_PRIORITY } else { 0 }; + let stage = cx.try_global::()?; + let blink_on = stage + .caret_moved + .is_none_or(|moved| (stage.now.saturating_sub(moved).as_millis() / BLINK.as_millis()).is_multiple_of(2)); + let inputs = stage.inputs.clone(); + let (caret, selection) = (cx.theme().caret, cx.theme().selection); + // In prepaint, which deferred elements run after the inputs', so the caret is this frame's. + let marks = canvas( + move |_, window, cx| inputs.iter().find_map(|input| input.marks(window, cx)), + move |_, marks, window, _| { + let Some(marks) = marks else { return }; + for bounds in marks.selection { + window.paint_quad(fill(bounds, selection)); + } + if let Some(bounds) = marks.caret.filter(|_| blink_on) { + window.paint_quad(fill(bounds, caret)); + } + }, + ) + .absolute() + .inset_0(); + let faded = |caption: &Caption, length: Duration| { + (stage.now.saturating_sub(caption.fade_from).as_secs_f32() / length.as_secs_f32()).min(1.) + }; + let leaving = stage.leaving.clone().filter(|leaving| faded(leaving, FADE_OUT) < 1.).map(|leaving| { + let opacity = 1. - faded(&leaving, FADE_OUT); + caption(leaving, opacity) + }); + let showing = stage.caption.clone().map(|showing| { + let opacity = faded(&showing, FADE_IN); + caption(showing, opacity) + }); + let layer = div() + .absolute() + .inset_0() + .when(stage.end_card, |el| el.child(end_card(cx))) + .children(leaving) + .children(showing) + .when_some(stage.ring, |el, (at, faded)| el.child(ring(at, faded, cx.theme().primary))); + let overlay = div() + .absolute() + .inset_0() + .child(deferred(marks).with_priority(marks_priority)) + .child(deferred(layer).with_priority(OVERLAY_PRIORITY)); + Some(overlay.into_any_element()) +} + +// Laid out in prepaint, once this frame has placed `above`. Moves beside a dialog it would cover. +fn caption(caption: Caption, opacity: f32) -> Canvas { + canvas( + move |_, window, cx| { + let viewport = window.viewport_size(); + // A tab switch leaves the old pane registered for a frame, in the same place. + let above = caption.above.and_then(|id| window.find_all(id).first().map(|pane| pane.bounds())); + let bottom = above.map_or(viewport.height - px(40.), |above| above.top() - px(22.)); + let (mut pill, mut measured) = caption_pill(&caption.text, opacity, None, window, cx); + let mut origin = point((viewport.width - measured.width) / 2., bottom - measured.height); + // Only dialogs register a surface. + let dialog = window + .find_all("surface") + .into_iter() + .map(|surface| surface.bounds()) + .find(|dialog| dialog.intersects(&Bounds::new(origin, measured))); + if let Some(dialog) = dialog { + let room = viewport.width - dialog.right(); + if measured.width > room - px(80.) { + let text = SharedString::from(balanced(&caption.text)); + (pill, measured) = caption_pill(&text, opacity, Some(room - px(80.)), window, cx); + } + origin = point(dialog.right() + (room - measured.width) / 2., bottom - measured.height); + } + pill.prepaint_at(origin, window, cx); + pill + }, + |_, mut pill, window, cx| pill.paint(window, cx), + ) + .absolute() + .inset_0() +} + +fn caption_pill( + text: &SharedString, + opacity: f32, + max_width: Option, + window: &mut Window, + cx: &mut App, +) -> (AnyElement, Size) { + let mut pill = div() + .when_some(max_width, |el, width| el.max_w(width)) + .px(px(32.)) + .py(px(14.)) + .rounded(px(18.)) + .bg(black().opacity(0.82)) + .border_1() + .border_color(white().opacity(0.12)) + .text_size(px(38.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(white()) + .text_center() + .opacity(opacity) + .child(text.clone()) + .into_any_element(); + let measured = pill.layout_as_root(size(AvailableSpace::MaxContent, AvailableSpace::MaxContent), window, cx); + (pill, measured) +} + +// Two lines of near-equal length, like CSS's `text-wrap: balance`. +fn balanced(text: &str) -> String { + let words: Vec<&str> = text.split(' ').collect(); + let longer = |at: usize| words[..at].join(" ").len().max(words[at..].join(" ").len()); + match (1..words.len()).min_by_key(|&at| longer(at)) { + Some(at) => format!("{}\n{}", words[..at].join(" "), words[at..].join(" ")), + None => text.to_string(), + } +} + +// At the window's scale, in the straight alpha `imageops::overlay` expects. +fn arrow(window: &Window, cx: &App) -> Res { + let side = DevicePixels((POINTER_SIZE * window.scale_factor()).round() as i32); + let renderer = cx.svg_renderer(); + let raster = renderer + .parse_svg(POINTER) + .and_then(|svg| renderer.render_parsed(&svg, SvgSize::ExactSize(size(side, side)))) + .map_err(|error| error.to_string())?; + let bgra = raster.as_bytes(0).ok_or("the pointer did not render")?.to_vec(); + let mut arrow = RgbaImage::from_raw(side.0 as u32, side.0 as u32, bgra).ok_or("the pointer did not render")?; + for pixel in arrow.pixels_mut() { + pixel.0.swap(0, 2); + } + Ok(arrow) +} + +fn ring(at: Point, faded: f32, color: Hsla) -> Div { + let radius = px(8. + 18. * faded); + let alpha = 0.6 * (1. - faded); + div() + .absolute() + .left(at.x - radius) + .top(at.y - radius) + .size(radius * 2.) + .rounded_full() + .border_2() + .border_color(color.opacity(alpha)) + .bg(color.opacity(alpha * 0.35)) +} + +fn end_card(cx: &App) -> Div { + let theme = cx.theme(); + div() + .absolute() + .inset_0() + .bg(theme.background) + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(20.)) + .child(vector_img(LOGO, px(164.))) + .child(div().mt(px(10.)).text_size(px(82.)).font_weight(FontWeight::BOLD).child("BarSQL")) + .child(div().text_size(px(40.)).child("A fast, local-first SQL workbench")) + .child( + div() + .text_size(px(32.)) + .text_color(theme.muted_foreground) + .child("Free and open source for Windows, macOS and Linux"), + ) + .child( + div() + .mt(px(44.)) + .flex() + .gap(px(58.)) + .text_size(px(36.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.primary) + .child("barsql.bare7a.eu") + .child("github.com/Bare7a/BarSQL"), + ) +} + +pub fn run(window: AnyWindowHandle, out: PathBuf, cx: &mut AsyncApp) { + cx.spawn(async move |cx| { + let robot = Robot { window }; + match record(&robot, &out, cx).await { + Ok(length) => println!("{:.1} s of video saved to {}", length.as_secs_f64(), out.display()), + Err(error) => { + eprintln!("demo video failed: {error}"); + std::process::exit(1); + } + } + cx.update(|cx| cx.quit()); + }) + .detach(); +} + +async fn record(robot: &Robot, out: &Path, cx: &mut AsyncApp) -> Res { + robot.fit_window(cx).await?; + robot.workspace(cx, |workspace, window, cx| { + workspace.set_panel_widths(px(SIDEBAR_WIDTH), px(JSON_WIDTH), window, cx) + })?; + open_forum(robot, cx).await?; + robot.act(cx, |_, _, cx| query_tab::set_results_share(RESULTS_SHARE, cx))?; + let arrow = robot.act(cx, |_, window, cx| arrow(window, cx))??; + let camera = Camera::new(robot, out, arrow)?; + camera.place(cx, point(px(SIZE.0 * 0.62), px(SIZE.1 * 0.7))); + // The tour copies and pastes through the system clipboard, so it's restored afterwards. + let clipboard = cx.update(|cx| cx.read_from_clipboard()); + let toured = tour(&camera, cx).await; + if let Some(item) = clipboard { + cx.update(|cx| cx.write_to_clipboard(item)); + } + toured?; + camera.recorder.into_inner().finish() +} + +// Off camera, so the video opens on the forum's editor. +async fn open_forum(robot: &Robot, cx: &mut AsyncApp) -> Res<()> { + let forum = ConnectionConfig { + name: "Forum".into(), + driver: DriverType::Postgres, + color: connection_dialog::DEFAULT_COLOR.into(), + host: "127.0.0.1".into(), + port: 55432, + username: "postgres".into(), + password: "postgres".into(), + database: "forum".into(), + schema: "public".into(), + ssl_mode: "disable".into(), + ..Default::default() + }; + let forum = save(cx, forum, None).await?; + robot + .workspace(cx, |workspace, window, cx| { + workspace.sidebar().update(cx, |sidebar, cx| sidebar.reload_connections(cx)); + workspace.open_query(forum, "Query 1 · Forum", "", window, cx); + let tab = workspace.query_tab()?; + tab.update(cx, |tab, cx| tab.focus_editor(window, cx)); + Some(()) + })? + .ok_or("no query tab")?; + robot.settle(cx, "the forum's schemas", |workspace, _, cx| listed(workspace, cx, "s:public")).await +} + +async fn save(cx: &mut AsyncApp, config: ConnectionConfig, seed: Option<&'static str>) -> Res { + let bar = cx.update(|cx| state::bar(cx)); + let name = config.name.clone(); + let task = cx.update(|cx| { + state::spawn(cx, async move { + let saved = bar.save_connection(config).await?; + if let Some(seed) = seed { + bar.execute_statement(&saved.id, "demo-video-seed", seed).await?; + } + Ok::<_, barsql_core::QueryError>(saved) + }) + }); + match task.await { + Some(Ok(saved)) => Ok(saved), + Some(Err(error)) => Err(format!("{name}: {}", error.message)), + None => Err(format!("{name}: the setup stopped")), + } +} + +// The landing page's sections in its order, but with the chart before editing rows to keep the query tab together. +async fn tour(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + sql(cam, cx).await?; + plan(cam, cx).await?; + chart(cam, cx).await?; + search(cam, cx).await?; + edit(cam, cx).await?; + schema(cam, cx).await?; + structure(cam, cx).await?; + data(cam, cx).await?; + engines(cam, cx).await?; + end(cam, cx).await +} + +async fn sql(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption(cx, Some("Write SQL at full speed")); + cam.hold(cx, 1000).await?; + cam.type_text(cx, "SELECT * FROM posts p\nWHERE p.").await?; + cam.until(cx, "the column suggestions", suggesting).await?; + cam.hold(cx, 1400).await?; + cam.type_text(cx, "sco").await?; + cam.hold(cx, 500).await?; + cam.keys(cx, "enter").await?; + cam.type_text(cx, " > 900\nORDER BY p.score DESC;").await?; + cam.close_suggestions(cx).await?; + cam.caption(cx, Some("Hover a table for its columns")); + let posts = cam.word(cx, "posts")?; + cam.glide(cx, posts).await?; + cam.until(cx, "the hover card", hovering).await?; + cam.hold(cx, 1800).await?; + cam.click_on(cx, "run").await?; + cam.until(cx, "the posts", |workspace, _, cx| { + workspace.read(cx).active_grid(cx).is_some_and(|grid| grid.read(cx).rows_shown() > 0) + }) + .await?; + cam.caption(cx, Some("Click a row to see it as JSON")); + cam.hold(cx, 700).await?; + let title = cam.robot.cell(cx, 2, POST_TITLE)?; + cam.click(cx, title).await?; + cam.hold(cx, 1800).await +} + +async fn plan(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption_over_results(cx, "See why a query is slow"); + // The plan wants the JSON viewer's width. + cam.click_on(cx, "json-panel-close").await?; + cam.hold(cx, 300).await?; + let sql = cam.word(cx, "DESC")?; + cam.click(cx, sql).await?; + cam.keys(cx, "mod-a").await?; + cam.paste(cx, PLAN_SQL)?; + cam.hold(cx, 700).await?; + cam.click_on(cx, "explain-menu").await?; + cam.hold(cx, 500).await?; + let analyze = label(cx, "editor.explainAnalyze"); + cam.pick(cx, &analyze).await?; + cam.until(cx, "the plan", |workspace, _, cx| active_plan(workspace, cx).is_some()).await?; + cam.hold(cx, 700).await?; + let hottest = cam.robot.act(cx, |workspace, _, cx| active_plan(workspace, cx)?.read(cx).hottest())?; + let hottest = hottest.ok_or("the plan has no hottest step")?; + cam.click_on(cx, SharedString::from(format!("plan-node-{hottest}"))).await?; + cam.hold(cx, 1800).await +} + +async fn chart(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption_over_results(cx, "Chart any result in one click"); + let sql = cam.word(cx, "LIMIT")?; + cam.click(cx, sql).await?; + cam.keys(cx, "mod-a").await?; + cam.paste(cx, CHART_SQL)?; + cam.hold(cx, 900).await?; + cam.click_on(cx, "run").await?; + cam.until(cx, "the monthly posts", |workspace, _, cx| { + workspace.read(cx).active_grid(cx).is_some_and(|grid| grid.read(cx).rows_shown() == 17) + }) + .await?; + cam.hold(cx, 700).await?; + // Dated charts open as lines, so the bars go on before the click's first frame. + let toggle = cam.robot.center(cx, "chart-toggle")?; + cam.push(cx, toggle, MouseButton::Left, 1, Modifiers::none()).await?; + let plotted = cam.robot.act(cx, |workspace, _, cx| { + let results = workspace.read(cx).query_tab()?.read(cx).results(); + let chart = results.read(cx).chart()?; + chart.update(cx, |chart, cx| chart.plot_bars(0, &[1, 2, 3], cx)); + Some(()) + })?; + plotted.ok_or("the chart toggle opened no chart")?; + cam.ring(cx, toggle).await?; + cam.hold(cx, 2000).await +} + +async fn search(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption(cx, Some("Jump to any table with Quick Search")); + cam.hold(cx, 400).await?; + cam.keys(cx, "mod-p").await?; + cam.until(cx, "Quick Search", |_, window, cx| window.has_active_dialog(cx)).await?; + cam.type_text(cx, "posts").await?; + cam.hold(cx, 900).await?; + cam.keys(cx, "enter").await?; + cam.until(cx, "the posts table", |workspace, _, cx| { + let workspace = workspace.read(cx); + workspace.active_title(cx) == "posts" && workspace.table_tab().is_some() + }) + .await?; + cam.hold(cx, 700).await +} + +// On posts, as deleting a user would trip the keys pointing at it. +async fn edit(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption(cx, Some("Filter with a WHERE that autocompletes")); + cam.click_field(cx, "table-filter").await?; + cam.type_text(cx, "sco").await?; + cam.until(cx, "the filter suggestions", filtering).await?; + cam.hold(cx, 600).await?; + cam.keys(cx, "enter").await?; + cam.type_text(cx, " > 900").await?; + if cam.robot.act(cx, filtering)? { + cam.keys(cx, "escape").await?; + } + cam.keys(cx, "enter").await?; + cam.until(cx, "the filtered posts", |workspace, _, cx| { + workspace + .read(cx) + .table_tab() + .is_some_and(|tab| tab.read(cx).idle() && !tab.read(cx).applied_filter().is_empty()) + }) + .await?; + cam.caption(cx, Some("Edit rows without writing UPDATE")); + cam.hold(cx, 400).await?; + let title = cam.robot.cell(cx, 0, POST_TITLE)?; + cam.press(cx, title, MouseButton::Left, 2).await?; + cam.hold(cx, 300).await?; + cam.keys(cx, "mod-a").await?; + cam.type_text(cx, "Go microservices in 2026").await?; + cam.keys(cx, "enter").await?; + cam.caption(cx, Some("Copy, paste and select like a spreadsheet")); + let copied = cam.robot.cell(cx, 1, POST_TITLE)?; + cam.click(cx, copied).await?; + cam.keys(cx, "mod-c").await?; + let pasted = cam.robot.cell(cx, 2, POST_TITLE)?; + cam.click(cx, pasted).await?; + cam.keys(cx, "mod-v").await?; + cam.hold(cx, 400).await?; + let (corner, far) = (cam.robot.cell(cx, 3, POST_CATEGORY)?, cam.robot.cell(cx, 6, POST_TITLE)?); + cam.click(cx, corner).await?; + cam.press_with(cx, far, Modifiers::shift()).await?; + cam.hold(cx, 500).await?; + let column = cam.robot.header(cx, POST_TITLE)?; + cam.click(cx, column).await?; + cam.hold(cx, 500).await?; + cam.caption(cx, Some("Delete rows and apply every change at once")); + let row = cam.robot.gutter(cx, 5)?; + cam.click(cx, row).await?; + cam.hold(cx, 300).await?; + cam.keys(cx, "delete").await?; + cam.until(cx, "the staged delete", |workspace, _, cx| { + workspace.read(cx).table_tab().is_some_and(|tab| tab.read(cx).pending().1 == 1) + }) + .await?; + cam.hold(cx, 500).await?; + cam.click_on(cx, "table-apply").await?; + cam.until(cx, "the saved changes", |workspace, _, cx| { + workspace.read(cx).table_tab().is_some_and(|tab| tab.read(cx).idle() && tab.read(cx).pending() == (0, 0)) + }) + .await?; + cam.hold(cx, 1000).await +} + +async fn schema(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption(cx, Some("Browse your schema down to the triggers")); + let public = cam.robot.tree_row(cx, "s:public")?; + cam.click(cx, public).await?; + cam.until(cx, "the tables", |workspace, _, cx| listed(workspace, cx, "t:public.users")).await?; + cam.hold(cx, 300).await?; + let users = cam.robot.tree_row(cx, "t:public.users")?; + cam.click(cx, users).await?; + let group = |group: Group| format!("g:public:users:{}", group.key()); + let triggers = group(Group::Triggers); + cam.until(cx, "the users table's groups", |workspace, _, cx| listed(workspace, cx, &triggers)).await?; + cam.hold(cx, 500).await?; + for (kind, object) in [(Group::Indexes, "users_pkey"), (Group::Triggers, USERS_TRIGGER)] { + let row = cam.robot.tree_row(cx, &group(kind))?; + cam.click(cx, row).await?; + let object = format!("o:public:users:{}:{object}", kind.key()); + cam.until(cx, &object, |workspace, _, cx| listed(workspace, cx, &object)).await?; + cam.hold(cx, 600).await?; + } + cam.hold(cx, 500).await?; + let (public, diagram) = (cam.robot.tree_row(cx, "s:public")?, label(cx, "erDiagram.menu")); + cam.context_pick(cx, public, &diagram).await?; + cam.until(cx, "the diagram", |workspace, _, cx| workspace.read(cx).diagram_tab().is_some()).await?; + cam.caption(cx, Some("See any schema as an ER diagram")); + cam.hold(cx, 700).await?; + let posts = + cam.robot.act(cx, |workspace, _, cx| workspace.read(cx).diagram_tab()?.read(cx).table_index("posts"))?; + cam.click_on(cx, ("er-table", posts.ok_or("no posts table in the diagram")?)).await?; + cam.hold(cx, 1500).await?; + let (public, compare) = (cam.robot.tree_row(cx, "s:public")?, label(cx, "schemaDiff.menu")); + cam.context_pick(cx, public, &compare).await?; + cam.until(cx, "the schema compare", |_, _, cx| diff_view(cx).is_some()).await?; + cam.caption(cx, Some("Compare two schemas and get a sync script")); + cam.hold(cx, 500).await?; + cam.click_on(cx, "diff-target-schema").await?; + cam.hold(cx, 500).await?; + cam.pick(cx, "staging").await?; + cam.until(cx, "staging to be picked", |_, _, cx| { + diff_view(cx).is_some_and(|view| view.read(cx).target_schema() == Some("staging")) + }) + .await?; + cam.hold(cx, 300).await?; + cam.click_on(cx, "diff-compare").await?; + cam.until(cx, "the comparison", |_, _, cx| diff_view(cx).is_some_and(|view| view.read(cx).ready())).await?; + cam.hold(cx, 1300).await?; + // A shorter results pane shows the whole script; the dialog hides the change. + cam.robot.act(cx, |_, _, cx| query_tab::set_results_share(40., cx))?; + cam.click_on(cx, "diff-open-script").await?; + cam.until(cx, "the script tab", |_, window, cx| !window.has_active_dialog(cx)).await?; + cam.hold(cx, 1600).await +} + +async fn structure(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + let (posts, edit) = (cam.robot.tree_row(cx, "t:public.posts")?, label(cx, "sidebar.editStructure")); + cam.context_pick(cx, posts, &edit).await?; + cam.until(cx, "the posts structure", |workspace, _, cx| workspace.read(cx).structure_tab().is_some()).await?; + cam.caption(cx, Some("Change a table without writing ALTER")); + cam.hold(cx, 600).await?; + let content = cam.robot.structure_cell(cx, CONTENT_ROW, 0)?; + cam.press(cx, content, MouseButton::Left, 2).await?; + cam.hold(cx, 200).await?; + cam.keys(cx, "mod-a").await?; + cam.type_text(cx, "body").await?; + cam.keys(cx, "enter").await?; + cam.hold(cx, 500).await?; + let locked = cam.robot.structure_cell(cx, IS_LOCKED_ROW, 0)?; + cam.click(cx, locked).await?; + cam.click_on(cx, "structure-drop").await?; + cam.hold(cx, 500).await?; + cam.click_on(cx, "structure-add").await?; + cam.hold(cx, 300).await?; + cam.type_text(cx, "slug").await?; + cam.keys(cx, "tab").await?; + cam.type_text(cx, "text").await?; + cam.keys(cx, "enter").await?; + cam.until(cx, "the changes", |workspace, _, cx| { + workspace.read(cx).structure_tab().is_some_and(|tab| tab.read(cx).has_changes()) + }) + .await?; + cam.hold(cx, 2000).await +} + +async fn data(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption(cx, None); + let (users, import) = (cam.robot.tree_row(cx, "t:public.users")?, label(cx, "sidebar.importIntoTable")); + cam.context_pick(cx, users, &import).await?; + cam.until(cx, "the import dialog", |_, _, cx| importer(cx).is_some()).await?; + cam.hold(cx, 500).await?; + // Browse opens a system dialog the video can't film, so the file is handed over directly. + let browse = cam.robot.center(cx, "import-browse")?; + cam.mimic(cx, browse).await?; + cam.robot.act(cx, |_, window, cx| { + let dialog = importer(cx)?; + dialog.update(cx, |dialog, cx| dialog.choose(IMPORT_CSV.into(), window, cx)); + Some(()) + })?; + cam.robot + .settle(cx, "the CSV preview", |_, _, cx| importer(cx).is_some_and(|dialog| dialog.read(cx).preview_loaded())) + .await?; + // After the preview grows the dialog, so the caption starts beside it. + cam.caption(cx, Some("Import a CSV into any table")); + cam.hold(cx, 2200).await?; + cam.caption(cx, None); + cam.click_on(cx, "import-dismiss").await?; + cam.until(cx, "the dialog to close", |_, window, cx| !window.has_active_dialog(cx)).await?; + // The posts table's tab. + cam.click_on(cx, ("editor-tab-title", 1usize)).await?; + cam.until(cx, "the posts table", |workspace, _, cx| workspace.read(cx).table_tab().is_some()).await?; + cam.hold(cx, 400).await?; + cam.click_on(cx, "export-results").await?; + cam.until(cx, "the export dialog", |_, window, cx| window.has_active_dialog(cx)).await?; + cam.caption(cx, Some("Export to CSV, JSON, Markdown or SQL")); + cam.hold(cx, 2400).await?; + cam.caption(cx, None); + cam.click_on(cx, "export-dismiss").await?; + cam.until(cx, "the dialog to close", |_, window, cx| !window.has_active_dialog(cx)).await +} + +// Adds the MySQL connection on camera, with the others saved off camera, then a query tab for each engine. +async fn engines(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + let (shop, others): (Vec, Vec) = + samples().into_iter().partition(|sample| sample.config.driver == DriverType::MySql); + let shop = shop.into_iter().next().ok_or("no MySQL sample")?; + let mut saved = Vec::new(); + for sample in &others { + saved.push(save(cx, sample.config.clone(), Some(sample.seed)).await?); + } + cam.robot.act(cx, |workspace, _, cx| { + workspace.read(cx).sidebar().update(cx, |sidebar, cx| sidebar.reload_connections(cx)) + })?; + add_shop(cam, cx).await?; + // Off camera, the tour's tabs make way for one per engine. + cam.robot.workspace(cx, |workspace, window, cx| { + loop { + if let Some(tab) = workspace.structure_tab() { + tab.update(cx, |tab, cx| tab.revert(window, cx)); + } + let open = workspace.query_tab().is_some() + || workspace.table_tab().is_some() + || workspace.diagram_tab().is_some() + || workspace.structure_tab().is_some(); + if !open { + break; + } + workspace.close_active(window, cx); + } + query_tab::set_results_share(RESULTS_SHARE, cx); + })?; + let found = cam.robot.act(cx, |workspace, _, cx| { + let sidebar = workspace.read(cx).sidebar(); + sidebar.update(cx, |sidebar, cx| sidebar.close_switcher(cx)); + let connections = sidebar.read(cx).connections(); + let named = |name: &str| connections.iter().find(|c| c.name == name).cloned(); + Some((named("Forum")?, named("Shop")?)) + })?; + let (forum, shop_connection) = found.ok_or("no Forum or Shop connection")?; + // The orders table, which the dialog's connection can't make. + let bar = cx.update(|cx| state::bar(cx)); + let id = shop_connection.id.clone(); + let seeded = + cx.update(|cx| state::spawn(cx, async move { bar.execute_statement(&id, "demo-video-seed", shop.seed).await })); + match seeded.await { + Some(Ok(_)) => {} + Some(Err(error)) => return Err(format!("Shop: {}", error.message)), + None => return Err("Shop: the setup stopped".into()), + } + let top_posts = "SELECT title, score, views FROM posts ORDER BY score DESC LIMIT 50;"; + let mut tabs = vec![(forum, "Top posts · PostgreSQL", top_posts)]; + let mut others = others.iter().zip(saved); + let (sqlite, sqlite_saved) = others.next().ok_or("no SQLite sample")?; + tabs.push((sqlite_saved, sqlite.title, sqlite.sql)); + tabs.push((shop_connection, shop.title, shop.sql)); + tabs.extend(others.map(|(sample, saved)| (saved, sample.title, sample.sql))); + let first = tabs[0].1; + let mut opened: Vec> = Vec::new(); + for (connection, title, sql) in tabs { + let tab = cam.robot.workspace(cx, |workspace, window, cx| { + workspace.open_query(connection, title, sql, window, cx); + let tab = workspace.query_tab()?; + tab.update(cx, |tab, cx| tab.run_all(window, cx)); + Some(tab) + })?; + opened.push(tab.ok_or_else(|| format!("no {title} tab"))?); + } + let count = opened.len(); + cam.robot + .settle(cx, "every engine's rows", move |_, _, cx| { + opened.iter().all(|tab| { + let tab = tab.read(cx); + let grid = tab.results().read(cx).active_grid(); + !tab.is_running() && grid.is_some_and(|grid| grid.read(cx).rows_shown() > 0) + }) + }) + .await?; + cam.robot.activate(cx, first).await?; + cam.crossfade(cx).await?; + cam.caption_over_results(cx, "Six engines. One app."); + cam.hold(cx, 1000).await?; + for ix in 1..count { + cam.click_on(cx, ("editor-tab-title", ix)).await?; + cam.hold(cx, 800).await?; + } + Ok(()) +} + +async fn add_shop(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.click_on(cx, "connection-switcher").await?; + cam.hold(cx, 700).await?; + cam.click_on(cx, "new-connection").await?; + cam.until(cx, "the connection dialog", |_, window, cx| window.has_active_dialog(cx)).await?; + cam.hold(cx, 300).await?; + cam.type_text(cx, "Shop").await?; + cam.click_on(cx, "conn-driver").await?; + cam.hold(cx, 600).await?; + let mysql = label(cx, "connection.mysql"); + cam.pick(cx, &mysql).await?; + // After the pick, as the taller server form moves the caption beside the dialog. + cam.caption(cx, Some("Add a connection in seconds")); + cam.hold(cx, 600).await?; + // Typed, as `localhost` would send MySQL's client to a socket. + cam.click_field(cx, "conn-host").await?; + cam.keys(cx, "mod-a").await?; + cam.type_text(cx, "127.0.0.1").await?; + cam.click_field(cx, "conn-port").await?; + cam.keys(cx, "mod-a").await?; + cam.type_text(cx, "33306").await?; + // The form starts with MySQL's root user. + cam.click_field(cx, "conn-password").await?; + cam.type_text(cx, "root").await?; + cam.caption(cx, Some("Pick the database from the server")); + cam.click_on(cx, "conn-database-list").await?; + cam.until(cx, "the database list", |_, _, cx| form(cx).is_some_and(|form| form.read(cx).databases().is_some())) + .await?; + cam.hold(cx, 500).await?; + cam.pick(cx, "barsql_test").await?; + cam.until(cx, "barsql_test to be picked", |_, _, cx| { + form(cx).is_some_and(|form| form.read(cx).database(cx) == "barsql_test") + }) + .await?; + cam.hold(cx, 600).await?; + cam.wheel_to_end(cx, "conn-mcp").await?; + cam.caption(cx, Some("Let AI assistants query this database")); + cam.hold(cx, 300).await?; + cam.click_on(cx, "conn-mcp").await?; + cam.hold(cx, 400).await?; + // Down to what the tick adds. + cam.wheel_to_end(cx, "conn-mcp").await?; + cam.hold(cx, 1800).await?; + cam.caption(cx, None); + cam.click_on(cx, "connection-save").await?; + cam.until(cx, "the dialog to close", |_, window, cx| !window.has_active_dialog(cx)).await?; + cam.hold(cx, 600).await +} + +struct Sample { + config: ConnectionConfig, + seed: &'static str, + title: &'static str, + sql: &'static str, +} + +// The stack's other engines, as docker-compose.yml runs them, each with a table to show. +fn samples() -> Vec { + let server = |name: &str, driver: DriverType, port: i64, user: &str, password: &str, database: &str| { + // SQL Server encrypts with the certificate it made itself. + let mssql = driver == DriverType::SqlServer; + ConnectionConfig { + name: name.into(), + driver, + color: "#3b82f6".into(), + host: "127.0.0.1".into(), + port, + username: user.into(), + password: password.into(), + database: database.into(), + schema: if mssql { "dbo".into() } else { database.into() }, + ssl_mode: if mssql { "require".into() } else { "disable".into() }, + ..Default::default() + } + }; + let sqlite = ConnectionConfig { + name: "Tasks".into(), + driver: DriverType::Sqlite, + color: "#0ea5e9".into(), + file_path: barsql_core::paths::data_dir().join("tasks.db").to_string_lossy().into_owned(), + ..Default::default() + }; + vec![ + Sample { + config: sqlite, seed: SQLITE_SEED, title: "Tasks · SQLite", sql: "SELECT * FROM tasks ORDER BY due;" + }, + Sample { + config: ConnectionConfig { + color: "#f59e0b".into(), + ..server("Shop", DriverType::MySql, 33306, "root", "root", "barsql_test") + }, + seed: MYSQL_SEED, + title: "Orders · MySQL", + sql: "SELECT * FROM orders ORDER BY placed_at DESC;", + }, + Sample { + config: ConnectionConfig { + color: "#ef4444".into(), + ..server("Billing", DriverType::SqlServer, 31433, "sa", "BarSQL-e2e-Passw0rd", "master") + }, + seed: MSSQL_SEED, + title: "Invoices · SQL Server", + sql: "SELECT * FROM dbo.invoices ORDER BY issued DESC;", + }, + Sample { + config: ConnectionConfig { + color: "#eab308".into(), + ..server("Analytics", DriverType::ClickHouse, 38123, "default", "clickhouse", "barsql_test") + }, + seed: CLICKHOUSE_SEED, + title: "Page views · ClickHouse", + sql: "SELECT path, count() AS views, round(avg(ms)) AS avg_ms, quantile(0.95)(ms) AS p95_ms +FROM page_views +GROUP BY path +ORDER BY views DESC;", + }, + Sample { + config: ConnectionConfig { + name: "Reading list".into(), + driver: DriverType::Turso, + color: "#14b8a6".into(), + url: "http://127.0.0.1:38080".into(), + ..Default::default() + }, + seed: TURSO_SEED, + title: "Bookmarks · Turso", + sql: "SELECT * FROM bookmarks ORDER BY added DESC;", + }, + ] +} + +const SQLITE_SEED: &str = "DROP TABLE IF EXISTS tasks; +CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, project TEXT NOT NULL, done INTEGER NOT NULL, due TEXT); +INSERT INTO tasks (title, project, done, due) VALUES + ('Record the demo video', 'Launch', 1, '2026-11-06'), + ('Answer the open issues', 'Community', 1, '2026-11-07'), + ('Write the Show HN post', 'Launch', 0, '2026-11-10'), + ('Post on r/rust', 'Launch', 0, '2026-11-11'), + ('Add a French translation', 'Community', 0, '2026-11-20'), + ('Sign the Windows build', 'Release', 0, '2026-12-01'), + ('Publish v1.3.0', 'Release', 0, '2026-12-05');"; + +const MYSQL_SEED: &str = "DROP TABLE IF EXISTS orders; +CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, customer VARCHAR(60) NOT NULL, total DECIMAL(10,2) NOT NULL, + status VARCHAR(20) NOT NULL, placed_at DATETIME NOT NULL); +INSERT INTO orders (customer, total, status, placed_at) VALUES + ('Ana Lopez', 129.90, 'shipped', '2026-11-01 09:14:00'), + ('Ben Carter', 54.00, 'delivered', '2026-10-30 17:42:00'), + ('Chloe Martin', 310.25, 'processing', '2026-11-03 11:05:00'), + ('Daniel Kim', 18.50, 'delivered', '2026-10-28 08:30:00'), + ('Elena Petrova', 89.99, 'shipped', '2026-11-02 14:20:00'), + ('Farid Haddad', 245.00, 'cancelled', '2026-10-31 19:55:00'), + ('Grace Liu', 72.40, 'processing', '2026-11-04 10:12:00'), + ('Hugo Silva', 159.00, 'delivered', '2026-10-27 13:47:00');"; + +const MSSQL_SEED: &str = "DROP TABLE IF EXISTS dbo.invoices; +CREATE TABLE dbo.invoices (id INT IDENTITY PRIMARY KEY, customer NVARCHAR(60) NOT NULL, amount DECIMAL(10,2) NOT NULL, + issued DATE NOT NULL, due DATE NOT NULL, paid BIT NOT NULL); +INSERT INTO dbo.invoices (customer, amount, issued, due, paid) VALUES + (N'Northwind Traders', 4200.00, '2026-10-01', '2026-10-31', 1), + (N'Contoso Ltd', 1875.50, '2026-10-05', '2026-11-04', 1), + (N'Fabrikam', 960.00, '2026-10-12', '2026-11-11', 0), + (N'Adventure Works', 12500.00, '2026-10-15', '2026-11-14', 0), + (N'Tailspin Toys', 340.75, '2026-10-20', '2026-11-19', 1), + (N'Wide World Importers', 5120.00, '2026-10-28', '2026-11-27', 0);"; + +const CLICKHOUSE_SEED: &str = "DROP TABLE IF EXISTS page_views; +CREATE TABLE page_views (ts DateTime, path String, ms UInt32) ENGINE = MergeTree ORDER BY ts; +INSERT INTO page_views +SELECT toDateTime('2026-11-01 00:00:00') + number * 17, + ['/', '/docs', '/download', '/guide', '/blog', '/privacy'][number % 6 + 1], + 20 + (number * 7919) % 480 +FROM numbers(200000);"; + +const TURSO_SEED: &str = "DROP TABLE IF EXISTS bookmarks; +CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, url TEXT NOT NULL, added TEXT NOT NULL); +INSERT INTO bookmarks (title, url, added) VALUES + ('GPUI', 'https://www.gpui.rs', '2026-11-02'), + ('GPUI Kit', 'https://github.com/longbridge/gpui-component', '2026-11-01'), + ('libSQL', 'https://github.com/tursodatabase/libsql', '2026-10-29'), + ('The Rust Book', 'https://doc.rust-lang.org/book/', '2026-10-25'), + ('Use the Index, Luke', 'https://use-the-index-luke.com', '2026-10-20');"; + +async fn end(cam: &Camera<'_>, cx: &mut AsyncApp) -> Res<()> { + cam.caption_over_results(cx, "Switch between dark and light themes"); + cam.hold(cx, 500).await?; + let (view, theme, light) = (label(cx, "menu.view"), label(cx, "viewSections.theme"), label(cx, "theme.light")); + cam.open_menu(cx, &view).await?; + cam.hold(cx, 300).await?; + // Across into the submenu before down, or the pointer would open Language's instead. + let theme = cam.menu_item(cx, &theme)?; + cam.glide(cx, theme).await?; + cam.hold(cx, 400).await?; + let light = cam.menu_item(cx, &light)?; + cam.glide(cx, point(light.x, theme.y)).await?; + cam.push(cx, light, MouseButton::Left, 1, Modifiers::none()).await?; + cam.crossfade(cx).await?; + cam.hold(cx, 1500).await?; + cam.robot.act(cx, |_, window, cx| theme::set_choice(theme::ThemeChoice::Dark, window, cx))?; + cam.stage(cx, |stage| { + stage.caption = None; + stage.leaving = None; + stage.end_card = true; + }); + cam.pointer.set(None); + cam.crossfade(cx).await?; + cam.hold(cx, 3500).await +} + +fn form(cx: &App) -> Option> { + cx.try_global::().and_then(|opened| opened.0.upgrade()) +} + +fn diff_view(cx: &App) -> Option> { + cx.try_global::().and_then(|opened| opened.0.upgrade()) +} + +fn listed(workspace: &Entity, cx: &App, id: &str) -> bool { + let tree = workspace.read(cx).sidebar().read(cx).panels().0; + tree.read(cx).listed().iter().any(|(row, ..)| row == id) +} + +// Sine in and out, peaking at 1.6× the average speed, so long glides don't jump. +fn ease(t: f32) -> f32 { + 0.5 - 0.5 * (std::f32::consts::PI * t).cos() +} + +fn blend(from: &RgbaImage, to: &RgbaImage, amount: f32) -> RgbaImage { + let weight = (amount * 256.).round() as u32; + let mut out = to.clone(); + for (channel, &under) in out.iter_mut().zip(from.iter()) { + *channel = ((u32::from(under) * (256 - weight) + u32::from(*channel) * weight) >> 8) as u8; + } + out +} + +// Loading states the camera doesn't film. +fn loading(workspace: &Entity, _: &mut Window, cx: &mut App) -> bool { + let workspace = workspace.read(cx); + workspace.query_tab().is_some_and(|tab| tab.read(cx).is_running()) + || workspace.table_tab().is_some_and(|tab| !tab.read(cx).idle()) + || workspace.diagram_tab().is_some_and(|diagram| !diagram.read(cx).fitted()) + || workspace.structure_tab().is_some_and(|tab| !tab.read(cx).editable()) + || workspace.sidebar().read(cx).panels().0.read(cx).loading() +} + +fn active_plan(workspace: &Entity, cx: &App) -> Option> { + workspace.read(cx).query_tab()?.read(cx).results().read(cx).active_plan() +} + +fn label(cx: &mut AsyncApp, key: &str) -> SharedString { + cx.update(|cx| t(cx, key)) +} + +fn suggesting(workspace: &Entity, _: &mut Window, cx: &mut App) -> bool { + workspace.read(cx).query_tab().is_some_and(|tab| tab.read(cx).completion().read(cx).is_open(cx)) +} + +fn filtering(workspace: &Entity, _: &mut Window, cx: &mut App) -> bool { + workspace.read(cx).table_tab().is_some_and(|tab| tab.read(cx).completion().read(cx).is_open(cx)) +} + +fn hovering(workspace: &Entity, _: &mut Window, cx: &mut App) -> bool { + workspace.read(cx).query_tab().is_some_and(|tab| tab.read(cx).hover().read(cx).showing()) +} + +fn importer(cx: &App) -> Option> { + cx.try_global::().and_then(|opened| opened.0.upgrade()) +} + +// The robot at a viewer's pace, recording each frame with how long it shows. +struct Camera<'a> { + robot: &'a Robot, + recorder: RefCell, + // None once put away for the end card. + pointer: Cell>>, + arrow: RgbaImage, + keystrokes: Cell, +} + +impl<'a> Camera<'a> { + fn new(robot: &'a Robot, out: &Path, arrow: RgbaImage) -> Res { + Ok(Self { + robot, + recorder: RefCell::new(Recorder::new(out)?), + pointer: Cell::new(None), + arrow, + keystrokes: Cell::new(0), + }) + } + + fn stage(&self, cx: &mut AsyncApp, f: impl FnOnce(&mut Stage)) { + cx.update(|cx| f(cx.default_global::())); + } + + fn caption(&self, cx: &mut AsyncApp, text: Option<&'static str>) { + self.set_caption(cx, text, None); + } + + // A caption over the empty end of the editor, clear of the results. + fn caption_over_results(&self, cx: &mut AsyncApp, text: &'static str) { + self.set_caption(cx, Some(text), Some("results-pane")); + } + + // The caption showing fades out, then `text` fades in. + fn set_caption(&self, cx: &mut AsyncApp, text: Option<&'static str>, above: Option<&'static str>) { + let now = self.recorder.borrow().elapsed; + self.stage(cx, |stage| { + let showing = stage.caption.as_ref().map(|caption| (caption.text.as_ref(), caption.above)); + if showing == text.map(|text| (text, above)) { + return; + } + let leaving = stage.caption.take().map(|caption| Caption { fade_from: now, ..caption }); + let fade_from = if leaving.is_some() { now + FADE_OUT } else { now }; + stage.leaving = leaving; + stage.caption = text.map(|text| Caption { text: text.into(), above, fade_from }); + }); + } + + // Without a glide. GPUI's mouse moves too, so hovers follow. + fn place(&self, cx: &mut AsyncApp, at: Point) { + self.pointer.set(Some(at)); + let _ = self.robot.window.update(cx, |_, window, cx| { + let moved = MouseMoveEvent { position: at, pressed_button: None, modifiers: Modifiers::none() }; + window.dispatch_event(PlatformInput::MouseMove(moved), cx); + }); + } + + // Waits out the rest of `shown`, keeping the app in real time, and always a little so its tasks run. + async fn shot(&self, cx: &mut AsyncApp, shown: Duration) -> Res<()> { + self.loaded(cx).await?; + let started = Instant::now(); + let image = self.render(cx)?; + self.recorder.borrow_mut().add(image, shown)?; + let rest = shown.saturating_sub(started.elapsed()).max(Duration::from_millis(1)); + cx.background_executor().timer(rest).await; + Ok(()) + } + + fn render(&self, cx: &mut AsyncApp) -> Res { + let now = self.recorder.borrow().elapsed; + self.robot + .window + .update(cx, |_, window, cx| { + cx.default_global::().now = now; + window.refresh(); + window.draw(cx).clear(cx); + window.simulate_next_frame(cx); + let mut image = window.render_to_image().map_err(|error| error.to_string())?; + // Over GPUI's tooltips too, as a system pointer is. + if let Some(at) = self.pointer.get() { + let corner = |at: Pixels, tip: f32| ((f32::from(at) - tip) * window.scale_factor()).round() as i64; + imageops::overlay( + &mut image, + &self.arrow, + corner(at.x, POINTER_TIP.0), + corner(at.y, POINTER_TIP.1), + ); + } + Ok::<_, String>(image) + }) + .map_err(|error| error.to_string())? + } + + // Waits out loads unfilmed. Still draws, which some views wait on. + async fn loaded(&self, cx: &mut AsyncApp) -> Res<()> { + let deadline = Instant::now() + WAIT; + while self.robot.act(cx, loading)? { + if Instant::now() > deadline { + return Err("timed out waiting for a load".into()); + } + let _ = self.robot.window.update(cx, |_, window, cx| { + window.refresh(); + window.draw(cx).clear(cx); + window.simulate_next_frame(cx); + }); + cx.background_executor().timer(Duration::from_millis(20)).await; + } + Ok(()) + } + + // From the last recorded frame to the window now, over a change made off camera. + async fn crossfade(&self, cx: &mut AsyncApp) -> Res<()> { + self.loaded(cx).await?; + let from = self.recorder.borrow().last.clone().ok_or("no frame to fade from")?; + let to = self.render(cx)?; + for frame in 1..CROSSFADE_FRAMES { + let mixed = blend(&from, &to, ease(frame as f32 / CROSSFADE_FRAMES as f32)); + self.recorder.borrow_mut().add(mixed, FRAME)?; + cx.background_executor().timer(Duration::from_millis(1)).await; + } + self.recorder.borrow_mut().add(to, FRAME) + } + + async fn hold(&self, cx: &mut AsyncApp, ms: u64) -> Res<()> { + let frames = (Duration::from_millis(ms).as_secs_f64() / FRAME.as_secs_f64()).round().max(1.) as usize; + for _ in 0..frames { + self.shot(cx, FRAME).await?; + } + Ok(()) + } + + async fn until( + &self, + cx: &mut AsyncApp, + what: &str, + check: impl Fn(&Entity, &mut Window, &mut App) -> bool, + ) -> Res<()> { + let deadline = Instant::now() + WAIT; + loop { + self.shot(cx, FRAME).await?; + if self.robot.act(cx, &check)? { + return Ok(()); + } + if Instant::now() > deadline { + return Err(format!("timed out waiting for {what}")); + } + } + } + + async fn glide(&self, cx: &mut AsyncApp, to: Point) -> Res<()> { + let from = self.pointer.get().unwrap_or(to); + let distance = f32::from(from.x - to.x).hypot(f32::from(from.y - to.y)); + if distance < 1. { + return Ok(()); + } + let ms = (280. + distance * 0.45).clamp(300., 700.); + let steps = (ms / FRAME.as_secs_f32() / 1000.).round().max(1.) as usize; + for step in 1..=steps { + self.place(cx, from + (to - from) * ease(step as f32 / steps as f32)); + self.shot(cx, FRAME).await?; + } + Ok(()) + } + + async fn click(&self, cx: &mut AsyncApp, at: Point) -> Res<()> { + self.press(cx, at, MouseButton::Left, 1).await + } + + // Where `word` first appears in the query tab's SQL. + fn word(&self, cx: &mut AsyncApp, word: &str) -> Res> { + let found = self.robot.act(cx, |workspace, window, cx| { + let editor = workspace.read(cx).query_tab()?.read(cx).editor(); + editor.update(cx, |state, cx| { + // The SQL is ASCII, so its byte offsets count UTF-16 units too. + let start = state.value().find(word)?; + let laid_out = state.text_bounds()?; + state.bounds_for_range(start..start + word.len(), laid_out, window, cx) + }) + })?; + found.map(|bounds| bounds.center()).ok_or_else(|| format!("{word} is not in the editor")) + } + + async fn click_on(&self, cx: &mut AsyncApp, id: impl Into) -> Res<()> { + let at = self.robot.center(cx, id)?; + self.click(cx, at).await + } + + async fn click_field(&self, cx: &mut AsyncApp, scope: &'static str) -> Res<()> { + let at = self.robot.field(cx, scope)?; + self.click(cx, at).await + } + + fn menu_item(&self, cx: &mut AsyncApp, label: &str) -> Res> { + let item = self.robot.act(cx, |_, window, _| { + let items = window.find_all_by_role(Role::MenuItem); + items.into_iter().find(|item| item.label() == Some(label)).map(|item| item.bounds().center()) + })?; + item.ok_or_else(|| format!("no menu item {label}")) + } + + async fn pick(&self, cx: &mut AsyncApp, label: &str) -> Res<()> { + let item = self.menu_item(cx, label)?; + self.click(cx, item).await + } + + async fn open_menu(&self, cx: &mut AsyncApp, label: &str) -> Res<()> { + let menu = self.robot.act(cx, |_, window, _| { + let menus = window.find_all("menu"); + menus.into_iter().find(|menu| menu.label() == Some(label)).map(|menu| menu.bounds().center()) + })?; + self.click(cx, menu.ok_or_else(|| format!("no {label} menu"))?).await + } + + // A click shown but not sent, for buttons that open system windows. + async fn mimic(&self, cx: &mut AsyncApp, at: Point) -> Res<()> { + self.glide(cx, at).await?; + self.hold(cx, 120).await?; + self.ring(cx, at).await + } + + // The distance is measured off camera by how far `inside` moves, so the ease runs its whole course. + async fn wheel_to_end(&self, cx: &mut AsyncApp, inside: &'static str) -> Res<()> { + let at = self.pointer.get().ok_or("no pointer to scroll under")?; + let start = self.robot.bounds(cx, inside)?.top(); + self.scroll(cx, at, px(-100_000.))?; + let distance = start - self.robot.bounds(cx, inside)?.top(); + self.scroll(cx, at, distance)?; + let frames = 18; + let mut turned = px(0.); + for frame in 1..=frames { + let to = distance * ease(frame as f32 / frames as f32); + self.scroll(cx, at, turned - to)?; + turned = to; + self.shot(cx, FRAME).await?; + } + Ok(()) + } + + // One wheel event, laid out at once so the bounds read next are current. + fn scroll(&self, cx: &mut AsyncApp, at: Point, dy: Pixels) -> Res<()> { + let delta = ScrollDelta::Pixels(point(px(0.), dy)); + let scrolled = ScrollEvent { position: at, delta, ..Default::default() }; + self.robot + .window + .update(cx, |_, window, cx| { + window.dispatch_event(PlatformInput::Scroll(scrolled), cx); + window.refresh(); + window.draw(cx).clear(cx); + }) + .map_err(|error| error.to_string()) + } + + async fn context_pick(&self, cx: &mut AsyncApp, at: Point, label: &str) -> Res<()> { + self.press(cx, at, MouseButton::Right, 1).await?; + self.hold(cx, 500).await?; + self.pick(cx, label).await + } + + // Replaces the active tab's SQL in one go, as a paste does. + fn paste(&self, cx: &mut AsyncApp, sql: &str) -> Res<()> { + self.caret_moved(cx); + self.robot.act(cx, |workspace, window, cx| { + let tab = workspace.read(cx).query_tab()?; + tab.update(cx, |tab, cx| tab.set_sql(sql.into(), window, cx)); + Some(()) + })?; + Ok(()) + } + + fn caret_moved(&self, cx: &mut AsyncApp) { + let now = self.recorder.borrow().elapsed; + self.stage(cx, |stage| stage.caret_moved = Some(now)); + } + + async fn press(&self, cx: &mut AsyncApp, at: Point, button: MouseButton, count: usize) -> Res<()> { + self.push(cx, at, button, count, Modifiers::none()).await?; + self.ring(cx, at).await + } + + async fn press_with(&self, cx: &mut AsyncApp, at: Point, modifiers: Modifiers) -> Res<()> { + self.push(cx, at, MouseButton::Left, 1, modifiers).await?; + self.ring(cx, at).await + } + + // Glides to `at` and clicks there, before any frame shows what the click did. + async fn push( + &self, + cx: &mut AsyncApp, + at: Point, + button: MouseButton, + count: usize, + modifiers: Modifiers, + ) -> Res<()> { + self.glide(cx, at).await?; + self.hold(cx, 120).await?; + let _ = self.robot.window.update(cx, |_, window, cx| { + for click_count in 1..=count { + let down = MouseDownEvent { button, position: at, modifiers, click_count, first_mouse: false }; + window.dispatch_event(PlatformInput::MouseDown(down), cx); + let up = MouseUpEvent { button, position: at, modifiers, click_count }; + window.dispatch_event(PlatformInput::MouseUp(up), cx); + } + }); + self.caret_moved(cx); + Ok(()) + } + + async fn ring(&self, cx: &mut AsyncApp, at: Point) -> Res<()> { + for frame in 0..RING_FRAMES { + self.stage(cx, |stage| stage.ring = Some((at, frame as f32 / RING_FRAMES as f32))); + self.shot(cx, FRAME).await?; + } + self.stage(cx, |stage| stage.ring = None); + Ok(()) + } + + // Space-separated keystrokes, with `mod` as the platform's command key, a beat apart. + async fn keys(&self, cx: &mut AsyncApp, keys: &str) -> Res<()> { + let os = cx.update(|cx| cx.platform_info().os); + for key in keys.split_whitespace() { + let keystroke = + Keystroke::parse_with_os(&key.replace("mod-", "secondary-"), os).map_err(|error| error.to_string())?; + self.caret_moved(cx); + let _ = self.robot.window.update(cx, |_, window, cx| window.dispatch_keystroke(keystroke, cx)); + self.hold(cx, 180).await?; + } + Ok(()) + } + + async fn close_suggestions(&self, cx: &mut AsyncApp) -> Res<()> { + if self.robot.act(cx, suggesting)? { + self.keys(cx, "escape").await?; + } + Ok(()) + } + + // A newline first closes the suggestions, which would take the Enter. + async fn type_text(&self, cx: &mut AsyncApp, text: &str) -> Res<()> { + for ch in text.chars() { + if ch == '\n' { + self.close_suggestions(cx).await?; + self.keys(cx, "enter").await?; + continue; + } + let key = if ch == ' ' { "space".to_string() } else { ch.to_lowercase().to_string() }; + let keystroke = Keystroke { modifiers: Modifiers::none(), key, key_char: Some(ch.to_string()) }; + self.caret_moved(cx); + let _ = self.robot.window.update(cx, |_, window, cx| window.dispatch_keystroke(keystroke, cx)); + self.shot(cx, self.beat(ch)).await?; + } + Ok(()) + } + + // 55 to 105 ms a key, unevenly, and a little longer after a word. + fn beat(&self, ch: char) -> Duration { + let n = self.keystrokes.get().wrapping_mul(1_103_515_245).wrapping_add(12_345); + self.keystrokes.set(n); + let pause = if ch == ' ' { 40 } else { 0 }; + Duration::from_millis(55 + u64::from((n >> 16) % 50) + pause) + } +} + +// PNGs on worker threads; an unchanged frame lengthens the last one. +struct Recorder { + dir: PathBuf, + shown: Vec, + elapsed: Duration, + last: Option>, + jobs: Option)>>, + writers: Vec>>, +} + +impl Recorder { + fn new(dir: &Path) -> Res { + std::fs::create_dir_all(dir).map_err(|error| format!("{}: {error}", dir.display()))?; + let (jobs, queue) = sync_channel(8); + let queue = Arc::new(Mutex::new(queue)); + let writers = (0..4) + .map(|_| { + let queue = queue.clone(); + std::thread::spawn(move || write_frames(&queue)) + }) + .collect(); + let dir = dir.to_path_buf(); + Ok(Self { dir, shown: Vec::new(), elapsed: Duration::ZERO, last: None, jobs: Some(jobs), writers }) + } + + fn add(&mut self, image: RgbaImage, shown: Duration) -> Res<()> { + self.elapsed += shown; + if self.last.as_ref().is_some_and(|last| last.as_raw() == image.as_raw()) { + if let Some(last) = self.shown.last_mut() { + *last += shown; + } + return Ok(()); + } + let image = Arc::new(image); + let path = self.dir.join(frame_name(self.shown.len() + 1)); + let sent = self.jobs.as_ref().ok_or("the recorder is closed")?.send((path, image.clone())); + if sent.is_err() { + // Each writer stops at its first error, so the reason is in one of them. + return Err(self.stop().err().unwrap_or_else(|| "every frame writer stopped".into())); + } + self.last = Some(image); + self.shown.push(shown); + Ok(()) + } + + fn stop(&mut self) -> Res<()> { + drop(self.jobs.take()); + for writer in self.writers.drain(..) { + writer.join().map_err(|_| "a frame writer panicked")??; + } + Ok(()) + } + + // ffmpeg reads PNGs at 25 fps unless told, and plays the last entry as long as the one before it. + fn finish(mut self) -> Res { + self.stop()?; + let file = |n: usize| format!("file {}\noption framerate 1000000\n", frame_name(n)); + let mut list = String::from("ffconcat version 1.0\n"); + let count = self.shown.len(); + for (ix, shown) in self.shown.iter().enumerate() { + let shown = if ix + 1 == count { *shown / 2 } else { *shown }; + list.push_str(&file(ix + 1)); + list.push_str(&format!("duration {:.6}\n", shown.as_secs_f64())); + } + list.push_str(&file(count)); + let path = self.dir.join("frames.ffconcat"); + std::fs::write(&path, list).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(self.elapsed) + } +} + +fn frame_name(n: usize) -> String { + format!("{n:06}.png") +} + +// Fast compression: these are only ffmpeg's input. +fn write_frames(queue: &Mutex)>>) -> Res<()> { + loop { + let job = queue.lock().map_err(|_| "the frame queue broke")?.recv(); + let Ok((path, image)) = job else { return Ok(()) }; + let failed = |error: &dyn std::fmt::Display| format!("{}: {error}", path.display()); + let mut file = BufWriter::new(File::create(&path).map_err(|error| failed(&error))?); + PngEncoder::new_with_quality(&mut file, CompressionType::Fast, FilterType::Sub) + .write_image(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgba8) + .map_err(|error| failed(&error))?; + // Dropping the writer flushes too, but loses the error. + file.flush().map_err(|error| failed(&error))?; + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use image::{Rgba, RgbaImage}; + + use super::{Recorder, balanced}; + + #[test] + fn a_caption_breaks_into_even_lines() { + assert_eq!(balanced("Add a connection in seconds"), "Add a connection\nin seconds"); + assert_eq!(balanced("Pick the database from the server"), "Pick the database\nfrom the server"); + assert_eq!(balanced("Let AI assistants query this database"), "Let AI assistants\nquery this database"); + } + + #[test] + fn an_unchanged_frame_lengthens_the_last_one() { + let dir = tempfile::tempdir().unwrap(); + let mut recorder = Recorder::new(dir.path()).unwrap(); + let frame = |shade| RgbaImage::from_pixel(4, 2, Rgba([shade, shade, shade, 255])); + recorder.add(frame(10), Duration::from_millis(40)).unwrap(); + recorder.add(frame(10), Duration::from_millis(60)).unwrap(); + recorder.add(frame(200), Duration::from_millis(30)).unwrap(); + assert_eq!(recorder.finish().unwrap(), Duration::from_millis(130)); + let list = std::fs::read_to_string(dir.path().join("frames.ffconcat")).unwrap(); + let expected = "ffconcat version 1.0 +file 000001.png +option framerate 1000000 +duration 0.100000 +file 000002.png +option framerate 1000000 +duration 0.015000 +file 000002.png +option framerate 1000000 +"; + assert_eq!(list, expected); + assert!(dir.path().join("000002.png").exists() && !dir.path().join("000003.png").exists()); + } + + #[test] + fn the_writers_own_error_stops_the_recording() { + let dir = tempfile::tempdir().unwrap(); + let frames = dir.path().join("frames"); + let mut recorder = Recorder::new(&frames).unwrap(); + std::fs::remove_dir(&frames).unwrap(); + // Each writer stops at the frame it can't save, and the queue closes once all four have. + let error = (0..=255u8).find_map(|shade| { + let frame = RgbaImage::from_pixel(4, 2, Rgba([shade, shade, shade, 255])); + recorder.add(frame, Duration::from_millis(40)).err() + }); + let error = error.expect("adding frames went on after every writer stopped"); + assert!(error.contains(".png:"), "{error}"); + } +} diff --git a/crates/barsql-ui/src/sidebar.rs b/crates/barsql-ui/src/sidebar.rs index df0e56d..d05bf7c 100644 --- a/crates/barsql-ui/src/sidebar.rs +++ b/crates/barsql-ui/src/sidebar.rs @@ -187,7 +187,7 @@ impl Sidebar { cx.notify(); } - fn reload_connections(&mut self, cx: &mut Context) { + pub(crate) fn reload_connections(&mut self, cx: &mut Context) { let bar = state::bar(cx); self.connections = bar.list_connections(); self.folders = bar.list_folders(); diff --git a/crates/barsql-ui/src/snapshot.rs b/crates/barsql-ui/src/snapshot.rs index 7249172..644e8f3 100644 --- a/crates/barsql-ui/src/snapshot.rs +++ b/crates/barsql-ui/src/snapshot.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::time::Duration; -use gpui_kit::{AnyWindowHandle, App, AsyncApp, Window}; +use gpui_kit::{AnyWindowHandle, App, AsyncApp, Modifiers, MouseMoveEvent, PlatformInput, Window, point, px}; use crate::actions::RunAll; @@ -12,6 +12,17 @@ pub type AfterRun = Box; pub fn schedule(window: AnyWindowHandle, path: PathBuf, run: bool, after: Option, cx: &mut AsyncApp) { cx.spawn(async move |cx| { cx.background_executor().timer(Duration::from_millis(800)).await; + let _ = window.update(cx, |_, window, cx| { + // The window's mouse starts wherever the real one is, so it would hover whatever lies under it. + let away = MouseMoveEvent { + position: point(px(-1.), px(-1.)), + pressed_button: None, + modifiers: Modifiers::none(), + }; + window.dispatch_event(PlatformInput::MouseMove(away), cx); + // Actions go to what the last drawn frame focused. + draw(window, cx); + }); if run { let _ = window.update(cx, |_, window, cx| window.dispatch_action(Box::new(RunAll), cx)); cx.background_executor().timer(Duration::from_millis(2000)).await; @@ -19,16 +30,16 @@ pub fn schedule(window: AnyWindowHandle, path: PathBuf, run: bool, after: Option if let Some(after) = after { // Actions go to what the last drawn frame focused, so draw the results first. let _ = window.update(cx, |_, window, cx| { - window.draw(cx).clear(cx); + draw(window, cx); after(window, cx); }); cx.background_executor().timer(Duration::from_millis(500)).await; } // A window behind others gets no frames, so draw the one a layout change asked for. let _ = window.update(cx, |_, window, cx| { - window.draw(cx).clear(cx); + draw(window, cx); window.refresh(); - window.draw(cx).clear(cx); + draw(window, cx); save(window, &path); cx.quit(); }); @@ -36,6 +47,13 @@ pub fn schedule(window: AnyWindowHandle, path: PathBuf, run: bool, after: Option .detach(); } +// Windows sends a hidden window no frames, so its next-frame callbacks wait on these draws. +fn draw(window: &mut Window, cx: &mut App) { + window.draw(cx).clear(cx); + #[cfg(feature = "snapshot")] + window.simulate_next_frame(cx); +} + #[cfg(feature = "snapshot")] fn save(window: &mut gpui_kit::Window, path: &std::path::Path) { match window.render_to_image() { diff --git a/crates/barsql-ui/src/structure_tab.rs b/crates/barsql-ui/src/structure_tab.rs index dfef6db..51da3a4 100644 --- a/crates/barsql-ui/src/structure_tab.rs +++ b/crates/barsql-ui/src/structure_tab.rs @@ -259,7 +259,7 @@ impl StructureTab { self.count(section) } - #[cfg(test)] + #[cfg(any(test, feature = "snapshot"))] pub fn grid(&self) -> Entity { self.sheet(self.section).grid.clone() } diff --git a/crates/barsql-ui/src/table_tab.rs b/crates/barsql-ui/src/table_tab.rs index 3887529..038be1f 100644 --- a/crates/barsql-ui/src/table_tab.rs +++ b/crates/barsql-ui/src/table_tab.rs @@ -168,7 +168,7 @@ impl TableTab { (self.staging.edit_count(), self.staging.delete_count()) } - #[cfg(test)] + #[cfg(any(test, feature = "snapshot"))] pub(crate) fn applied_filter(&self) -> &str { &self.applied_filter } @@ -718,7 +718,7 @@ impl TableTab { .border_color(theme.border) .child(Icon::new(Lucide::Funnel).size(ICON_SM).text_color(theme.muted_foreground)) .child( - completion::handlers(div(), &self.completion) + completion::handlers(div().id("table-filter"), &self.completion) .when(self.completion.read(cx).is_open(cx), |el| el.key_context(completion::OPEN_CONTEXT)) .flex_1() .min_w_0() diff --git a/crates/barsql-ui/src/workspace.rs b/crates/barsql-ui/src/workspace.rs index b7e28ac..c062edf 100644 --- a/crates/barsql-ui/src/workspace.rs +++ b/crates/barsql-ui/src/workspace.rs @@ -275,15 +275,19 @@ pub struct Workspace { } pub fn open(options: LaunchOptions, cx: &mut App) { - // Screenshot runs start at 80% of the screen and leave the saved window state alone. - let track = options.snapshot.is_none() && options.screenshots.is_none(); + // Screenshot runs start at 80% of the screen, hidden, and leave the saved window state alone. + let track = !options.unattended(); let saved = if track { window_state::load(cx) } else { Some(WindowState::default()) }; + let bounds = window_state::initial_bounds(saved.as_ref(), cx); let window_options = WindowOptions { titlebar: Some(TitlebarOptions { title: Some("BarSQL".into()), ..TitleBar::title_bar_options() }), - window_bounds: Some(window_state::initial_bounds(saved.as_ref(), cx)), + window_bounds: Some(bounds), window_min_size: Some(size(px(window_state::MIN_WIDTH as f32), px(window_state::MIN_HEIGHT as f32))), display_id: cx.primary_display().map(|display| display.id()), app_id: Some("BarSQL".into()), + // Unattended runs render offscreen, so their window never shows and nothing on screen reaches it. + show: track, + focus: track, ..TitleBar::window_options() }; cx.spawn(async move |cx| { @@ -452,13 +456,12 @@ pub fn open(options: LaunchOptions, cx: &mut App) { let _ = window.update(cx, |_, _, cx| update_dialog::check_on_startup(cx)); } if let Ok(window) = window { - // macOS keeps a titled window inside the screen, so a screenshot larger than the screen goes - // borderless first. - if let Some((width, height)) = options.snapshot_size.filter(|_| !track) { - let _ = window.update(cx, |_, window, _| { - window.toggle_simple_fullscreen(); - window.resize(size(px(width), px(height))); - }); + // Windows applies a hidden window's initial bounds only once it shows, so unattended runs size it here. + if !track { + let target = options + .snapshot_size + .map_or(bounds.get_bounds().size, |(width, height)| size(px(width), px(height))); + let _ = window.update(cx, |_, window, _| window.resize(target)); } if let Some(path) = options.snapshot { crate::snapshot::schedule(window.into(), path, options.snapshot_run, after, cx); @@ -466,6 +469,9 @@ pub fn open(options: LaunchOptions, cx: &mut App) { if let Some(path) = options.screenshots { screenshots::run(window, path, cx); } + if let Some(path) = options.demo_video { + screenshots::record(window, path, cx); + } } }) .detach(); @@ -1843,7 +1849,15 @@ impl Workspace { let lock = if active { theme.foreground.opacity(0.7) } else { theme.muted_foreground }; el.child(Icon::new(Lucide::Lock).size(ICON_2XS).text_color(lock)) }) - .child(div().max_w(rems(15.385)).truncate().child(open.view.title(cx))) + // The title, as the tab's context menu hides the tab from test lookups. + .child( + div() + .id(("editor-tab-title", ix)) + .max_w(rems(15.385)) + .truncate() + .child(open.view.title(cx)) + .test_support(), + ) .child( match open.pinned { // A pinned tab shows its pin, which unpins it, in place of the close button. @@ -1932,7 +1946,8 @@ impl Workspace { .cursor_pointer() .hover(|style| style.bg(theme.secondary_hover).text_color(theme.foreground)) .child(Icon::new(IconName::Plus).size(ICON_XS)) - .on_click(cx.listener(|this, _, window, cx| this.new_tab(window, cx))); + .on_click(cx.listener(|this, _, window, cx| this.new_tab(window, cx))) + .test_support(); div() .debug_selector(|| "tab-bar".into()) .relative() @@ -2460,6 +2475,7 @@ impl Render for Workspace { .children(toast::render(window, cx)) .child(self.drop_overlay(cx)) .child(self.context_menu.clone()) + .children(screenshots::overlay(window, cx)) } } diff --git a/crates/barsql/src/main.rs b/crates/barsql/src/main.rs index 711e020..49627dd 100644 --- a/crates/barsql/src/main.rs +++ b/crates/barsql/src/main.rs @@ -30,7 +30,8 @@ fn main() -> anyhow::Result<()> { } let snapshot = std::env::var_os("BARSQL_SNAPSHOT").filter(|p| !p.is_empty()).map(Into::into); let screenshots = std::env::var_os("BARSQL_SCREENSHOTS").filter(|p| !p.is_empty()).map(Into::into); - let listener = match snapshot.as_ref().or(screenshots.as_ref()) { + let demo_video = std::env::var_os("BARSQL_DEMO_VIDEO").filter(|p| !p.is_empty()).map(Into::into); + let listener = match snapshot.as_ref().or(screenshots.as_ref()).or(demo_video.as_ref()) { Some(_) => None, None => match single_instance::claim(&data_dir, &args) { Instance::First(listener) => listener, @@ -54,13 +55,15 @@ fn main() -> anyhow::Result<()> { } let snapshot_run = std::env::var_os("BARSQL_SNAPSHOT_RUN").is_some_and(|v| v == "1"); let snapshot_panel = std::env::var("BARSQL_SNAPSHOT_PANEL").ok(); - // "2048x1152" + // "1920x1080" let snapshot_size = std::env::var("BARSQL_SNAPSHOT_SIZE").ok().and_then(|size| { let (width, height) = size.split_once('x')?; Some((width.trim().parse().ok()?, height.trim().parse().ok()?)) }); - let check_updates = cfg!(feature = "production") && snapshot.is_none() && screenshots.is_none(); - let options = LaunchOptions { snapshot, snapshot_run, snapshot_panel, snapshot_size, screenshots, check_updates }; + let check_updates = + cfg!(feature = "production") && snapshot.is_none() && screenshots.is_none() && demo_video.is_none(); + let options = + LaunchOptions { snapshot, snapshot_run, snapshot_panel, snapshot_size, screenshots, demo_video, check_updates }; barsql_ui::run(bar, options); Ok(()) } diff --git a/docs/demo.mp4 b/docs/demo.mp4 new file mode 100644 index 0000000..742b40e Binary files /dev/null and b/docs/demo.mp4 differ diff --git a/docs/icons.svg b/docs/icons.svg index 300fe63..565c0b7 100644 --- a/docs/icons.svg +++ b/docs/icons.svg @@ -37,4 +37,5 @@ + diff --git a/docs/index.html b/docs/index.html index 6f71af5..681545d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -109,13 +109,14 @@

Fast, local-first SQL wor

-
@@ -164,8 +165,8 @@

A closer look

SQL editor
@@ -187,8 +188,8 @@

Write SQL at full speed

Plan viewer
@@ -208,8 +209,8 @@

See why a query is slow

Table data
@@ -230,8 +231,8 @@

Edit rows, not UPDATE statements

Results
@@ -251,8 +252,8 @@

See the shape of a result

Schema
@@ -274,8 +275,8 @@

Your schema, down to the triggers

Structure editor
@@ -295,8 +296,8 @@

Change a table without writing ALTER

Import and export
@@ -496,18 +497,18 @@

Screenshots

Click a screenshot to see it full size, then use the arrow keys to browse.

@@ -703,7 +704,7 @@

Linux

- +
diff --git a/docs/preview.jpg b/docs/preview.jpg index 1c22541..bfcb7ce 100644 Binary files a/docs/preview.jpg and b/docs/preview.jpg differ diff --git a/docs/screenshots/1-1280.webp b/docs/screenshots/1-1280.webp index 2c4e580..95e0cea 100644 Binary files a/docs/screenshots/1-1280.webp and b/docs/screenshots/1-1280.webp differ diff --git a/docs/screenshots/1-640.webp b/docs/screenshots/1-640.webp index ab46728..fca9b97 100644 Binary files a/docs/screenshots/1-640.webp and b/docs/screenshots/1-640.webp differ diff --git a/docs/screenshots/1-800.webp b/docs/screenshots/1-800.webp index 662f050..9895817 100644 Binary files a/docs/screenshots/1-800.webp and b/docs/screenshots/1-800.webp differ diff --git a/docs/screenshots/1.webp b/docs/screenshots/1.webp index aae934a..fad5e9f 100644 Binary files a/docs/screenshots/1.webp and b/docs/screenshots/1.webp differ diff --git a/docs/screenshots/10-1280.webp b/docs/screenshots/10-1280.webp index a28550f..edb641f 100644 Binary files a/docs/screenshots/10-1280.webp and b/docs/screenshots/10-1280.webp differ diff --git a/docs/screenshots/10-640.webp b/docs/screenshots/10-640.webp index 597ec38..d5040c2 100644 Binary files a/docs/screenshots/10-640.webp and b/docs/screenshots/10-640.webp differ diff --git a/docs/screenshots/10-800.webp b/docs/screenshots/10-800.webp index e7c205e..704b83c 100644 Binary files a/docs/screenshots/10-800.webp and b/docs/screenshots/10-800.webp differ diff --git a/docs/screenshots/10.webp b/docs/screenshots/10.webp index e3f4a31..3c3f0c1 100644 Binary files a/docs/screenshots/10.webp and b/docs/screenshots/10.webp differ diff --git a/docs/screenshots/11-1280.webp b/docs/screenshots/11-1280.webp index fd44742..da9db5b 100644 Binary files a/docs/screenshots/11-1280.webp and b/docs/screenshots/11-1280.webp differ diff --git a/docs/screenshots/11-640.webp b/docs/screenshots/11-640.webp index ba5cbaa..6c3ea15 100644 Binary files a/docs/screenshots/11-640.webp and b/docs/screenshots/11-640.webp differ diff --git a/docs/screenshots/11-800.webp b/docs/screenshots/11-800.webp index ffc14e0..6f6a778 100644 Binary files a/docs/screenshots/11-800.webp and b/docs/screenshots/11-800.webp differ diff --git a/docs/screenshots/11.webp b/docs/screenshots/11.webp index d7f6e33..88bb15b 100644 Binary files a/docs/screenshots/11.webp and b/docs/screenshots/11.webp differ diff --git a/docs/screenshots/12-1280.webp b/docs/screenshots/12-1280.webp index c1f33b2..4d07e16 100644 Binary files a/docs/screenshots/12-1280.webp and b/docs/screenshots/12-1280.webp differ diff --git a/docs/screenshots/12-640.webp b/docs/screenshots/12-640.webp index 2fdf1be..7d41830 100644 Binary files a/docs/screenshots/12-640.webp and b/docs/screenshots/12-640.webp differ diff --git a/docs/screenshots/12-800.webp b/docs/screenshots/12-800.webp index bddc734..9306812 100644 Binary files a/docs/screenshots/12-800.webp and b/docs/screenshots/12-800.webp differ diff --git a/docs/screenshots/12.webp b/docs/screenshots/12.webp index e013bc0..3f9b33f 100644 Binary files a/docs/screenshots/12.webp and b/docs/screenshots/12.webp differ diff --git a/docs/screenshots/13-1280.webp b/docs/screenshots/13-1280.webp index dc4efb0..135956a 100644 Binary files a/docs/screenshots/13-1280.webp and b/docs/screenshots/13-1280.webp differ diff --git a/docs/screenshots/13-640.webp b/docs/screenshots/13-640.webp index c0fe399..49f8e4f 100644 Binary files a/docs/screenshots/13-640.webp and b/docs/screenshots/13-640.webp differ diff --git a/docs/screenshots/13-800.webp b/docs/screenshots/13-800.webp index 037485f..9b070f7 100644 Binary files a/docs/screenshots/13-800.webp and b/docs/screenshots/13-800.webp differ diff --git a/docs/screenshots/13.webp b/docs/screenshots/13.webp index 8d03cd9..e06c0ce 100644 Binary files a/docs/screenshots/13.webp and b/docs/screenshots/13.webp differ diff --git a/docs/screenshots/14-1280.webp b/docs/screenshots/14-1280.webp index 79093f9..b042d22 100644 Binary files a/docs/screenshots/14-1280.webp and b/docs/screenshots/14-1280.webp differ diff --git a/docs/screenshots/14-640.webp b/docs/screenshots/14-640.webp index b02013b..bb8ad4a 100644 Binary files a/docs/screenshots/14-640.webp and b/docs/screenshots/14-640.webp differ diff --git a/docs/screenshots/14-800.webp b/docs/screenshots/14-800.webp index a191166..ff59e00 100644 Binary files a/docs/screenshots/14-800.webp and b/docs/screenshots/14-800.webp differ diff --git a/docs/screenshots/14.webp b/docs/screenshots/14.webp index d24bcf1..2999517 100644 Binary files a/docs/screenshots/14.webp and b/docs/screenshots/14.webp differ diff --git a/docs/screenshots/15-1280.webp b/docs/screenshots/15-1280.webp index 7cac5f0..d88caeb 100644 Binary files a/docs/screenshots/15-1280.webp and b/docs/screenshots/15-1280.webp differ diff --git a/docs/screenshots/15-640.webp b/docs/screenshots/15-640.webp index 7bedd3a..023d834 100644 Binary files a/docs/screenshots/15-640.webp and b/docs/screenshots/15-640.webp differ diff --git a/docs/screenshots/15-800.webp b/docs/screenshots/15-800.webp index 85baaf0..b3f9ed6 100644 Binary files a/docs/screenshots/15-800.webp and b/docs/screenshots/15-800.webp differ diff --git a/docs/screenshots/15.webp b/docs/screenshots/15.webp index e58f9d6..83fb68e 100644 Binary files a/docs/screenshots/15.webp and b/docs/screenshots/15.webp differ diff --git a/docs/screenshots/2-1280.webp b/docs/screenshots/2-1280.webp index 49a2192..21624dc 100644 Binary files a/docs/screenshots/2-1280.webp and b/docs/screenshots/2-1280.webp differ diff --git a/docs/screenshots/2-640.webp b/docs/screenshots/2-640.webp index bd45e2e..b603df2 100644 Binary files a/docs/screenshots/2-640.webp and b/docs/screenshots/2-640.webp differ diff --git a/docs/screenshots/2-800.webp b/docs/screenshots/2-800.webp index 6462516..de113eb 100644 Binary files a/docs/screenshots/2-800.webp and b/docs/screenshots/2-800.webp differ diff --git a/docs/screenshots/2.webp b/docs/screenshots/2.webp index afc9e71..50433c1 100644 Binary files a/docs/screenshots/2.webp and b/docs/screenshots/2.webp differ diff --git a/docs/screenshots/3-1280.webp b/docs/screenshots/3-1280.webp index 1d39936..8b1878f 100644 Binary files a/docs/screenshots/3-1280.webp and b/docs/screenshots/3-1280.webp differ diff --git a/docs/screenshots/3-640.webp b/docs/screenshots/3-640.webp index eb830f0..23c6149 100644 Binary files a/docs/screenshots/3-640.webp and b/docs/screenshots/3-640.webp differ diff --git a/docs/screenshots/3-800.webp b/docs/screenshots/3-800.webp index 454bd0b..efc1542 100644 Binary files a/docs/screenshots/3-800.webp and b/docs/screenshots/3-800.webp differ diff --git a/docs/screenshots/3.webp b/docs/screenshots/3.webp index 76125b4..ea829b3 100644 Binary files a/docs/screenshots/3.webp and b/docs/screenshots/3.webp differ diff --git a/docs/screenshots/4-1280.webp b/docs/screenshots/4-1280.webp index 5bf2e3c..976f583 100644 Binary files a/docs/screenshots/4-1280.webp and b/docs/screenshots/4-1280.webp differ diff --git a/docs/screenshots/4-640.webp b/docs/screenshots/4-640.webp index 18ec4e9..2efe0b5 100644 Binary files a/docs/screenshots/4-640.webp and b/docs/screenshots/4-640.webp differ diff --git a/docs/screenshots/4-800.webp b/docs/screenshots/4-800.webp index 22ff217..db5c688 100644 Binary files a/docs/screenshots/4-800.webp and b/docs/screenshots/4-800.webp differ diff --git a/docs/screenshots/4.webp b/docs/screenshots/4.webp index ec56cf9..d4b5b8d 100644 Binary files a/docs/screenshots/4.webp and b/docs/screenshots/4.webp differ diff --git a/docs/screenshots/5-1280.webp b/docs/screenshots/5-1280.webp index ec1eb33..6573494 100644 Binary files a/docs/screenshots/5-1280.webp and b/docs/screenshots/5-1280.webp differ diff --git a/docs/screenshots/5-640.webp b/docs/screenshots/5-640.webp index 6e7f7ef..db2e4b0 100644 Binary files a/docs/screenshots/5-640.webp and b/docs/screenshots/5-640.webp differ diff --git a/docs/screenshots/5-800.webp b/docs/screenshots/5-800.webp index 5bccbb4..5a2237f 100644 Binary files a/docs/screenshots/5-800.webp and b/docs/screenshots/5-800.webp differ diff --git a/docs/screenshots/5.webp b/docs/screenshots/5.webp index de639e6..e80db8e 100644 Binary files a/docs/screenshots/5.webp and b/docs/screenshots/5.webp differ diff --git a/docs/screenshots/6-1280.webp b/docs/screenshots/6-1280.webp index 17b8a42..e650136 100644 Binary files a/docs/screenshots/6-1280.webp and b/docs/screenshots/6-1280.webp differ diff --git a/docs/screenshots/6-640.webp b/docs/screenshots/6-640.webp index c466e7b..37cfbf7 100644 Binary files a/docs/screenshots/6-640.webp and b/docs/screenshots/6-640.webp differ diff --git a/docs/screenshots/6-800.webp b/docs/screenshots/6-800.webp index 35cf87e..e44391b 100644 Binary files a/docs/screenshots/6-800.webp and b/docs/screenshots/6-800.webp differ diff --git a/docs/screenshots/6.webp b/docs/screenshots/6.webp index 3250cc5..e36cb64 100644 Binary files a/docs/screenshots/6.webp and b/docs/screenshots/6.webp differ diff --git a/docs/screenshots/7-1280.webp b/docs/screenshots/7-1280.webp index f883e3c..fce6ab9 100644 Binary files a/docs/screenshots/7-1280.webp and b/docs/screenshots/7-1280.webp differ diff --git a/docs/screenshots/7-640.webp b/docs/screenshots/7-640.webp index 964ff8d..1c7beb9 100644 Binary files a/docs/screenshots/7-640.webp and b/docs/screenshots/7-640.webp differ diff --git a/docs/screenshots/7-800.webp b/docs/screenshots/7-800.webp index 229598a..b10c3bb 100644 Binary files a/docs/screenshots/7-800.webp and b/docs/screenshots/7-800.webp differ diff --git a/docs/screenshots/7.webp b/docs/screenshots/7.webp index 0c2dcd4..854f2b5 100644 Binary files a/docs/screenshots/7.webp and b/docs/screenshots/7.webp differ diff --git a/docs/screenshots/8-1280.webp b/docs/screenshots/8-1280.webp index e63e203..bad3453 100644 Binary files a/docs/screenshots/8-1280.webp and b/docs/screenshots/8-1280.webp differ diff --git a/docs/screenshots/8-640.webp b/docs/screenshots/8-640.webp index 92925a3..ca5136e 100644 Binary files a/docs/screenshots/8-640.webp and b/docs/screenshots/8-640.webp differ diff --git a/docs/screenshots/8-800.webp b/docs/screenshots/8-800.webp index 088a43c..d99bf83 100644 Binary files a/docs/screenshots/8-800.webp and b/docs/screenshots/8-800.webp differ diff --git a/docs/screenshots/8.webp b/docs/screenshots/8.webp index a839abd..3754290 100644 Binary files a/docs/screenshots/8.webp and b/docs/screenshots/8.webp differ diff --git a/docs/screenshots/9-1280.webp b/docs/screenshots/9-1280.webp index 988b627..776520a 100644 Binary files a/docs/screenshots/9-1280.webp and b/docs/screenshots/9-1280.webp differ diff --git a/docs/screenshots/9-640.webp b/docs/screenshots/9-640.webp index 35715e6..16dbcf8 100644 Binary files a/docs/screenshots/9-640.webp and b/docs/screenshots/9-640.webp differ diff --git a/docs/screenshots/9-800.webp b/docs/screenshots/9-800.webp index 87dce25..2985cbb 100644 Binary files a/docs/screenshots/9-800.webp and b/docs/screenshots/9-800.webp differ diff --git a/docs/screenshots/9.webp b/docs/screenshots/9.webp index 6e7249c..cc93045 100644 Binary files a/docs/screenshots/9.webp and b/docs/screenshots/9.webp differ diff --git a/docs/script.js b/docs/script.js index 59ab546..88f9e93 100644 --- a/docs/script.js +++ b/docs/script.js @@ -79,7 +79,28 @@ async function pointDownloadAtVisitor() { } } +// The hero's screenshot swaps in the demo video on click, so none of it loads before. +function setUpDemo() { + const button = document.getElementById("demo"); + if (!button) return; + button.addEventListener("click", () => { + const video = document.createElement("video"); + video.className = "demo-video"; + video.src = button.dataset.video; + video.poster = button.querySelector("img").currentSrc; + video.width = 1920; + video.height = 1080; + video.controls = true; + video.autoplay = true; + video.playsInline = true; + video.setAttribute("aria-label", "BarSQL demo video"); + button.replaceWith(video); + video.focus(); + }); +} + // Lightbox with every screenshot on the page, full size and in order. Arrow keys, swipes and the buttons move between them. +// The hero's screenshot too, though its click plays the demo. function setUpLightbox() { const box = document.getElementById("lightbox"); if (!box) return; @@ -90,7 +111,7 @@ function setUpLightbox() { const prevButton = box.querySelector(".lightbox-prev"); const nextButton = box.querySelector(".lightbox-next"); const focusable = [closeButton, prevButton, nextButton]; - const shots = [...document.querySelectorAll(".shot[data-shot]")]; + const shots = [...document.querySelectorAll("[data-shot]")]; const seen = new Set(); const items = shots @@ -128,7 +149,7 @@ function setUpLightbox() { opener?.focus(); }; - for (const shot of shots) { + for (const shot of document.querySelectorAll(".shot[data-shot]")) { shot.addEventListener("click", () => open(shot.dataset.shot, shot)); } @@ -169,4 +190,5 @@ function setUpLightbox() { setUpTheme(); pointDownloadAtVisitor(); +setUpDemo(); setUpLightbox(); diff --git a/docs/styles.css b/docs/styles.css index 82c71b8..1546cf3 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -349,6 +349,49 @@ p, li, figcaption, dd { text-wrap: pretty; } overflow: hidden; } +/* The hero screenshot, which plays the demo video when clicked */ + +.demo { + position: relative; + display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + cursor: pointer; +} + +.demo img, +.demo-video { + display: block; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + background: var(--surface-2); +} + +.demo-play { + position: absolute; + top: 50%; + left: 50%; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 14px 24px 14px 20px; + border-radius: 999px; + background: rgba(8, 10, 14, 0.82); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 1.05rem; + font-weight: 600; + white-space: nowrap; + transform: translate(-50%, -50%); + transition: background 0.15s ease, transform 0.15s ease; +} +.demo-play .icon { width: 18px; height: 18px; fill: currentColor; stroke: none; } +.demo:hover .demo-play, +.demo:focus-visible .demo-play { background: var(--accent); transform: translate(-50%, -50%) scale(1.04); } + /* Screenshot buttons */ .shot { @@ -726,7 +769,7 @@ kbd { .lightbox img { width: auto; - max-width: min(92vw, 2048px); + max-width: 92vw; max-height: calc(100vh - 150px); border-radius: 10px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); @@ -788,6 +831,7 @@ kbd { .engines { display: grid; grid-template-columns: repeat(2, auto); justify-content: start; } .hero-actions .btn { flex: 1 1 auto; } .hero-shot { margin-top: 48px; } + .demo-play { gap: 8px; padding: 10px 16px 10px 14px; font-size: 0.92rem; } .pillars, .features, .engine-grid { grid-template-columns: minmax(0, 1fr); } .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; } .pillars { margin-top: 56px; } diff --git a/fixtures/demo-video/data/settings.json b/fixtures/demo-video/data/settings.json new file mode 100644 index 0000000..d9a8abd --- /dev/null +++ b/fixtures/demo-video/data/settings.json @@ -0,0 +1,6 @@ +{ + "barsql-ui-zoom-px": "15", + "barsql-sidebar-w": "330", + "barsql-json-w": "400", + "barsql-json-open": "1" +} diff --git a/fixtures/demo-video/staging.sql b/fixtures/demo-video/staging.sql new file mode 100644 index 0000000..575498f --- /dev/null +++ b/fixtures/demo-video/staging.sql @@ -0,0 +1,17 @@ +-- The demo video compares public with this: the same forum a release behind. +CREATE SCHEMA staging; +CREATE TABLE staging.categories (LIKE public.categories INCLUDING ALL); +CREATE TABLE staging.users (LIKE public.users INCLUDING ALL); +ALTER TABLE staging.users DROP COLUMN preferences, DROP COLUMN tags; +CREATE TABLE staging.posts (LIKE public.posts INCLUDING ALL); +ALTER TABLE staging.posts DROP COLUMN metadata, ALTER COLUMN title TYPE character varying(120); + +-- A trigger for the tour to open in the schema tree. +CREATE FUNCTION public.normalize_email() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.email := lower(NEW.email); + RETURN NEW; +END; +$$; +CREATE TRIGGER users_normalize_email BEFORE INSERT OR UPDATE OF email ON public.users + FOR EACH ROW EXECUTE FUNCTION public.normalize_email(); diff --git a/guide/schema.md b/guide/schema.md index e36c987..5e3d3f6 100644 --- a/guide/schema.md +++ b/guide/schema.md @@ -10,7 +10,7 @@ - **Count rows** answers straight away, without opening a tab - **System views** - see what the server is doing (PostgreSQL activity, blocking and long-running queries; MySQL processes and open transactions; SQL Server requests, sessions and blocking; ClickHouse running queries) from the activity button above the tree or a connection's menu, and how a table is doing (statistics, index usage, ClickHouse parts, mutations and merges) from its menu. Each opens as an ordinary SQL tab and runs, so you can tweak it and run it again - Refresh the whole schema, or one schema from its right-click menu -- **ER diagram** of a schema from its right-click menu, in a tab that comes back with the session - every table with all its columns, keys first, laid out so referenced tables stand left of the ones referencing them and the keys cross as little as possible. Drag to pan, zoom with the buttons, `⌘/Ctrl`+scroll or a pinch, and Fit to see it all. Hover or click a table to light up its keys and neighbours, find one by name, show only the key columns, and double-click (or use the open button) to open it +- **ER diagram** of a schema from its right-click menu, in a tab that comes back with the session - every table with all its columns, keys first, laid out so referenced tables stand left of the ones referencing them and the keys cross as little as possible. Drag to pan, zoom with the buttons, `⌘/Ctrl`+scroll or a pinch, and the fit button to see it all. Hover or click a table to light up its keys and neighbours, find one by name, show only the key columns, and double-click (or use the open button) to open it - **Compare schema…** with another schema or database of the same engine - missing and extra tables, added, dropped and changed columns, and a sync script you can copy or open in a tab Columns stay directly under their table. Indexes, constraints and triggers sit below them as collapsed groups and are fetched only when you open one, so expanding a table stays cheap. diff --git a/xtask/src/docs_images.rs b/xtask/src/docs_images.rs index c6f3017..adcad94 100644 --- a/xtask/src/docs_images.rs +++ b/xtask/src/docs_images.rs @@ -1,13 +1,12 @@ -// Landing page images from the README screenshots; the icons command writes the app icon's copies. The page picks -// a scaled copy to fit the screen; the lightbox opens a lossless WebP at the screenshot's native size. +// The README's and landing page's images, from docs/screenshots. The icons command writes the app icon's copies. use std::fs::{self, File}; use std::io::BufWriter; use std::path::{Path, PathBuf}; use image::codecs::jpeg::JpegEncoder; use image::codecs::webp::WebPEncoder; -use image::imageops; -use image::{ExtendedColorType, RgbImage}; +use image::imageops::{self, FilterType}; +use image::{DynamicImage, ExtendedColorType, RgbImage}; use crate::report; @@ -16,30 +15,39 @@ const WIDTHS: [u32; 3] = [640, 800, 1280]; // Link preview, cropped from the top of the first screenshot. const PREVIEW: (u32, u32) = (1200, 630); -pub fn run(root: &Path) -> Result<(), String> { - let (from, docs) = (root.join(".github/screenshots"), root.join("docs")); - let to = docs.join("screenshots"); +// The app's PNGs in `shots` replace the kept screenshots, losslessly. +pub fn keep(shots: &Path, root: &Path) -> Result<(), String> { + let to = root.join("docs/screenshots"); if to.exists() { fs::remove_dir_all(&to).map_err(|e| format!("{}: {e}", to.display()))?; } fs::create_dir_all(&to).map_err(|e| format!("{}: {e}", to.display()))?; - let mut shots: Vec = fs::read_dir(&from) - .map_err(|e| format!("{}: {e}", from.display()))? - .filter_map(|entry| entry.ok().map(|entry| entry.path())) - .filter(|path| path.extension().is_some_and(|ext| ext == "png")) + for path in files(shots, "png")? { + let shot = image::open(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let name = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or_default(); + let kept = to.join(format!("{name}.webp")); + write_webp(&kept, &shot.to_rgb8())?; + report(&kept); + } + Ok(()) +} + +pub fn run(root: &Path) -> Result<(), String> { + let docs = root.join("docs"); + let dir = docs.join("screenshots"); + // Not the copies written beside them. + let shots: Vec = files(&dir, "webp")? + .into_iter() + .filter(|path| path.file_stem().and_then(|stem| stem.to_str()).is_some_and(|stem| !stem.contains('-'))) .collect(); if shots.is_empty() { - return Err(format!("no screenshots in {}", from.display())); + return Err(format!("no screenshots in {}", dir.display())); } - shots.sort(); for path in &shots { - let shot = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?.into_rgb8(); + let shot = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?; let name = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or_default(); - let original = to.join(format!("{name}.webp")); - write_webp(&original, &shot)?; - report(&original); for width in WIDTHS { - let scaled = to.join(format!("{name}-{width}.webp")); + let scaled = dir.join(format!("{name}-{width}.webp")); write_webp(&scaled, &scale(&shot, width))?; report(&scaled); } @@ -56,16 +64,26 @@ pub fn run(root: &Path) -> Result<(), String> { Ok(()) } +fn files(dir: &Path, extension: &str) -> Result, String> { + let mut files: Vec = fs::read_dir(dir) + .map_err(|e| format!("{}: {e}", dir.display()))? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == extension)) + .collect(); + files.sort(); + Ok(files) +} + fn write_webp(path: &Path, image: &RgbImage) -> Result<(), String> { WebPEncoder::new_lossless(create(path)?) .encode(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgb8) .map_err(|e| format!("{}: {e}", path.display())) } -// Averages whole pixel areas, which is fast and sharp enough at the sizes used here. -fn scale(image: &RgbImage, width: u32) -> RgbImage { +// Catmull-Rom, which the image crate widens by the scale, so every source pixel counts. +fn scale(image: &DynamicImage, width: u32) -> RgbImage { let height = (u64::from(image.height()) * u64::from(width) / u64::from(image.width())) as u32; - imageops::thumbnail(image, width, height) + image.resize_exact(width, height, FilterType::CatmullRom).into_rgb8() } fn create(path: &Path) -> Result, String> { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2a5f3fb..b534231 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,6 +9,7 @@ const USAGE: &str = "\ usage: cargo xtask lint cargo xtask e2e [up | down | logs | all] cargo xtask screenshots + cargo xtask demo-video cargo xtask docs-images cargo xtask icons cargo xtask package @@ -20,12 +21,17 @@ e2e The PostgreSQL, MySQL, MariaDB, libSQL and ClickHouse suites, against down and logs manage the stack (COMPOSE=\"podman compose\" swaps the tool); all brings it up, runs the suites and tears it down. BARSQL_E2E_MSSQL=1 adds SQL Server, as Azure SQL Edge on arm64. screenshots - The README screenshots, in .github/screenshots. Restores fixtures/screenshots/forum.sql into the - stack's PostgreSQL, then plays the scenes in a snapshot build on a copy of - fixtures/screenshots/data, and makes the landing page's images from them (docs-images). + The README's and landing page's screenshots, as 1920 × 1080 lossless WebPs in docs/screenshots. + Restores fixtures/screenshots/forum.sql into the stack's PostgreSQL, plays the scenes in a hidden window + of a snapshot build on a copy of fixtures/screenshots/data, and runs docs-images. +demo-video The demo video, as a 1920 × 1080 MP4 in target/demo-video/BarSQL-demo.mp4. Starts the stack's PostgreSQL, + MySQL, ClickHouse, libSQL and SQL Server, restores the forum database with fixtures/demo-video/staging.sql, + tours the app in a hidden window of a snapshot build on a copy of fixtures/demo-video/data, and encodes the + frames with ffmpeg (FFMPEG swaps the binary). The stack stays up; BARSQL_E2E_MSSQL=1 cargo xtask e2e down + stops it. docs-images - The landing page's images in docs/: each README screenshot as a full-size lossless WebP plus 640, 800 - and 1280px copies, and a 1200 × 630 link preview. + From the screenshots in docs/screenshots: 640, 800 and 1280px copies of each and a 1200 × 630 link + preview. icons Every icon file from packaging/icon.svg: the macOS .icns on Apple's grid, the Windows .ico and the Linux PNGs from the stack without its tile, the About dialog's and the landing page's copies, and the title bar's dark and light marks. @@ -40,6 +46,11 @@ crate, and the package scripts read it from there. an explicit version, e.g. 1.6.0"; const LINT_FEATURES: &str = "barsql-db/e2e,barsql-app/e2e,barsql-ui/e2e,barsql/snapshot"; +// The screenshots' and the demo video's window, in points. Both come out at 1920 × 1080 pixels. +const SNAPSHOT_SIZE: &str = "1920x1080"; +// Optimized in snapshot runs only, whose GPU readback and PNG encoding go pixel by pixel: 50 ms a frame, not 450. +const SNAPSHOT_OPTIMIZED: [&str; 7] = + ["gpui-pre-apple", "gpui-pre-windows", "image", "png", "fdeflate", "crc32fast", "simd-adler32"]; fn main() -> ExitCode { let args: Vec = env::args().skip(1).collect(); @@ -47,6 +58,7 @@ fn main() -> ExitCode { Some("lint") => lint(), Some("e2e") => e2e(args.get(1).map(String::as_str)), Some("screenshots") => screenshots(), + Some("demo-video") => demo_video(), Some("docs-images") => docs_images::run(&workspace()), Some("icons") => icons::run(&workspace()), Some("package") => package(), @@ -114,22 +126,26 @@ fn e2e_suites() -> Result<(), String> { } fn compose(args: &[&str]) -> Result<(), String> { - compose_with(args, None) + compose_with(args, None, &mssql_env()) } -fn compose_with(args: &[&str], stdin: Option<&Path>) -> Result<(), String> { +fn compose_with(args: &[&str], stdin: Option<&Path>, envs: &[(&str, &Path)]) -> Result<(), String> { let tool = env::var("COMPOSE").unwrap_or_else(|_| "docker compose".into()); let mut words = tool.split_whitespace(); let program = words.next().ok_or("COMPOSE is empty")?; let args: Vec<&str> = words.chain(args.iter().copied()).collect(); - run_with(program, &args, stdin, &mssql_env()) + run_with(program, &args, stdin, envs) } -// SQL Server's image is amd64 only. Azure SQL Edge runs the same engine on arm64, so it stands in there. fn mssql_env() -> Vec<(&'static str, &'static Path)> { - if env::var("BARSQL_E2E_MSSQL").as_deref() != Ok("1") { - return Vec::new(); + match env::var("BARSQL_E2E_MSSQL").as_deref() { + Ok("1") => mssql_profile(), + _ => Vec::new(), } +} + +// SQL Server's image is amd64 only. Azure SQL Edge runs the same engine on arm64, so it stands in there. +fn mssql_profile() -> Vec<(&'static str, &'static Path)> { let mut envs = vec![("COMPOSE_PROFILES", Path::new("mssql"))]; if env::consts::ARCH == "aarch64" && env::var_os("MSSQL_IMAGE").is_none() { envs.push(("MSSQL_IMAGE", Path::new("mcr.microsoft.com/azure-sql-edge:latest"))); @@ -138,30 +154,96 @@ fn mssql_env() -> Vec<(&'static str, &'static Path)> { envs } -// Recreate the forum database every run. The scenes insert and roll back, which still moves its sequences. fn screenshots() -> Result<(), String> { let root = workspace(); let fixtures = root.join("fixtures/screenshots"); compose(&["up", "-d", "--wait", "postgres"])?; - let psql = ["exec", "-T", "postgres", "psql", "-U", "postgres", "-q", "-v", "ON_ERROR_STOP=1"]; - compose(&[&psql[..], &["-c", "DROP DATABASE IF EXISTS forum WITH (FORCE)"]].concat())?; - compose(&[&psql[..], &["-c", "CREATE DATABASE forum"]].concat())?; - compose_with(&[&psql[..], &["-d", "forum"]].concat(), Some(&fixtures.join("forum.sql")))?; - let data = root.join("target/screenshots/BarSQL-data"); - if data.exists() { - fs::remove_dir_all(&data).map_err(|e| format!("{}: {e}", data.display()))?; - } + restore_forum(&[])?; + let (data, shots) = (root.join("target/screenshots/BarSQL-data"), root.join("target/screenshots/png")); + fresh_dir(&data)?; + fresh_dir(&shots)?; copy_dir(&fixtures.join("data"), &data)?; - let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".into()); let envs = [ ("BARSQL_DATA_DIR", data.as_path()), - ("BARSQL_SCREENSHOTS", Path::new(".github/screenshots")), - ("BARSQL_SNAPSHOT_SIZE", Path::new("2048x1152")), + ("BARSQL_SCREENSHOTS", shots.as_path()), + ("BARSQL_SNAPSHOT_SIZE", Path::new(SNAPSHOT_SIZE)), ]; - run_with(&cargo, &["run", "-p", "barsql", "--features", "snapshot"], None, &envs)?; + run_snapshot(&envs)?; + docs_images::keep(&shots, &root)?; docs_images::run(&root) } +fn run_snapshot(envs: &[(&str, &Path)]) -> Result<(), String> { + let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let optimized: Vec = + SNAPSHOT_OPTIMIZED.iter().map(|name| format!("profile.dev.package.{name}.opt-level=3")).collect(); + let mut args = vec!["run", "-p", "barsql", "--features", "snapshot"]; + for config in &optimized { + args.extend(["--config", config]); + } + run_with(&cargo, &args, None, envs) +} + +// Encodes at 1080p30, from frames at the display's scale. +fn demo_video() -> Result<(), String> { + let ffmpeg = env::var("FFMPEG").unwrap_or_else(|_| "ffmpeg".into()); + if Command::new(&ffmpeg).arg("-version").output().is_err() { + return Err(format!( + "{ffmpeg} not found. Install ffmpeg (brew install ffmpeg, winget install ffmpeg) or set FFMPEG" + )); + } + let root = workspace(); + let services = ["up", "-d", "--wait", "postgres", "mysql", "clickhouse", "turso", "mssql"]; + compose_with(&services, None, &mssql_profile())?; + restore_forum(&[root.join("fixtures/demo-video/staging.sql")])?; + let out = root.join("target/demo-video"); + let (data, frames) = (out.join("BarSQL-data"), out.join("frames")); + fresh_dir(&data)?; + fresh_dir(&frames)?; + copy_dir(&root.join("fixtures/demo-video/data"), &data)?; + let envs = [ + ("BARSQL_DATA_DIR", data.as_path()), + ("BARSQL_DEMO_VIDEO", frames.as_path()), + ("BARSQL_SNAPSHOT_SIZE", Path::new(SNAPSHOT_SIZE)), + ]; + run_snapshot(&envs)?; + let list = frames.join("frames.ffconcat"); + let video = out.join("BarSQL-demo.mp4"); + // BT.709, as players assume for HD, tagged on the frames for x264; scaled once, before fps repeats frames. + let filters = "scale=1920:1080:flags=lanczos:out_color_matrix=bt709,format=yuv420p,\ + setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709,fps=30"; + let encoded = video.to_string_lossy(); + let input = + ["-y", "-hide_banner", "-loglevel", "warning", "-f", "concat", "-safe", "0", "-i", &list.to_string_lossy()]; + let output = ["-vf", filters, "-c:v", "libx264", "-preset", "slow", "-crf", "20", "-movflags", "+faststart"]; + run(&ffmpeg, &[&input[..], &output[..], &[&*encoded]].concat())?; + println!("saved {encoded}"); + // The landing page plays this copy. + let page = root.join("docs/demo.mp4"); + fs::copy(&video, &page).map_err(|e| format!("{}: {e}", page.display()))?; + report(&page); + Ok(()) +} + +// A fresh forum database, then `extra` in it, as rolled-back inserts still move its sequences. +fn restore_forum(extra: &[PathBuf]) -> Result<(), String> { + let psql = ["exec", "-T", "postgres", "psql", "-U", "postgres", "-q", "-v", "ON_ERROR_STOP=1"]; + compose(&[&psql[..], &["-c", "DROP DATABASE IF EXISTS forum WITH (FORCE)"]].concat())?; + compose(&[&psql[..], &["-c", "CREATE DATABASE forum"]].concat())?; + let forum = workspace().join("fixtures/screenshots/forum.sql"); + for script in std::iter::once(&forum).chain(extra) { + compose_with(&[&psql[..], &["-d", "forum"]].concat(), Some(script), &mssql_env())?; + } + Ok(()) +} + +fn fresh_dir(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_dir_all(path).map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + fn copy_dir(from: &Path, to: &Path) -> Result<(), String> { fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?; for entry in fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? {