diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3092a38..590396c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -400,6 +400,10 @@ jobs: python3 tools/release/make_deb.py "$RUNNER_TEMP/stage" "$VERSION" "$out/${base}.deb" # The licence set once, from Linux, rather than three times under the same name. cp LICENSE NOTICE UPSTREAM_NOTICES.md "$out/" + # The launcher's catalog icon: the brand PNG (square, 512 px, well under 1 MiB) as one + # more asset, named in redrob-launcher.json by the publish job (launcher_icon below; + # redrob-launcher docs/release-manifest.md). + cp resources/icons/hicolor/512x512/apps/redrob-query.png "$out/redrob-query.png" ;; esac @@ -563,14 +567,15 @@ jobs: fi done - # The licence artifacts are unconditional. The checksums and redrob-launcher.json are - # the publish job's: it writes both over this same set of files. + # The licence artifacts and the launcher icon are unconditional. The checksums and + # redrob-launcher.json are the publish job's: it writes both over this same set of files. for pattern in \ '^LICENSE$' \ '^NOTICE$' \ - '^UPSTREAM_NOTICES\.md$'; do + '^UPSTREAM_NOTICES\.md$' \ + '^redrob-query\.png$'; do if ! printf '%s\n' $names | grep -qE "$pattern"; then - echo "::error::no asset matching ${pattern}; the licence texts and notices must accompany every binary" + echo "::error::no asset matching ${pattern}; the licence texts, notices and the launcher icon must accompany every binary" exit 1 fi done @@ -583,7 +588,8 @@ jobs: echo "Every built platform asset (${REQUIRED_ARCHIVES}) and the licence files are present." # The shared redrob-labs publish job: SHA256SUMS.txt over every asset, redrob-launcher.json (this - # app reads the launcher's shared account file directly), and the release v created + # app reads the launcher's shared account file directly; `icon` names redrob-query.png, the + # brand PNG the Linux leg uploaded), and the release v created # and PUBLISHED as latest (prerelease when the version carries a `-`). Only for a real tag, never # for a dry run, and only when every job before it succeeded. `tag` and `target` are passed # explicitly because this workflow is dispatched from a branch: github.sha is the branch head, not @@ -600,6 +606,8 @@ jobs: version: ${{ needs.validate.outputs.version }} title: Redrob Query launcher_manifest: true + # The PNG the Linux leg uploaded among release-*: redrob-launcher.json names it as `icon`. + launcher_icon: redrob-query.png publish: true tag: ${{ inputs.tag }} target: ${{ needs.validate.outputs.sha }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5664472..d24c65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ The release workflow's notes point here, so this file is what a download's notes follow the `vMAJOR.MINOR.PATCH` tags that trigger a release; while the major is 0 a minor bump is where behaviour may change. +## 0.4.0 - 2026-10-16 + +### Changed + +- **The window is laid out like Redrob Canvas.** One theme owned by the shell (canvas's Pro + tokens, dark and light grounds, following the system with a Help > Theme override; Inter for the + interface, JetBrains Mono for SQL and grid cells, a system CJK fallback), a thin top bar with File + / Connection / Query / View / Help menus and a right slot showing the active connection with its + read-only / read-write badge beside the Redrob account, a left panel with the connections tree + (grouped by database, connect-state dots, filter) and the active connection's schema tree + (database > schema > tables > columns with dimmed types, filter; double-click inserts the name; + Select top 100 / Copy name / DDL on right-click), tabbed editors (Ctrl+T, Ctrl+W, Ctrl+1…9; a + tab shows its connection and a dirty dot) with the result area under a splitter as Grid / + Messages / Changes tabs, the Ask dock on the right (Ctrl+Shift+A), and a status line at the + bottom. Every string is in English and Korean (Help > Language). +- **Running.** Ctrl+Enter runs the selection or the statement under the cursor, F5 the whole + text, Esc cancels with a spinner on the status line (`Pending::cancel` in the engine). Errors + go to the Messages tab and the red status line; there is no error dialog. +- **Grid.** Monospace cells, header click sorts, column edges drag, click selects a cell and + Ctrl+C copies it (Ctrl+click several rows, Ctrl+C copies them as TSV), NULL dimmed italic, + long text ellipsized with the full value as a tooltip, row range and page controls at the bottom. +- **Reviewed edits** stage in the grid, count on the Changes tab's badge, and show the exact SQL + the engine will run (bound values beside it) before Apply or Discard. +- **First run** opens on an empty state with one action, New connection; the connection form is + a modal with a Test button and inline error text. +- **View > History** lists past runs; click inserts into the editor, double-click opens a tab. + Help > Shortcuts lists the keys. +- **Release manifest.** `redrob-launcher.json` names the launcher icon `redrob-query.png`, + uploaded as one more release asset. + ## 0.3.0 - 2026-10-16 ### Changed diff --git a/CONTRIBUTING.ko.md b/CONTRIBUTING.ko.md index d2b9d01..f936439 100644 --- a/CONTRIBUTING.ko.md +++ b/CONTRIBUTING.ko.md @@ -54,13 +54,13 @@ ruleset은 pull request가 *어느* 브랜치에서 왔는지를 표현할 수 ```bash git switch develop && git pull -git switch -c release/0.3.0 +git switch -c release/0.4.0 # Cargo.toml의 버전을 올리고 CHANGELOG 절을 쓰고 게이트를 돌립니다 # develop으로 풀 리퀘스트를 열어 머지하고, develop -> main 풀 리퀘스트(머지 커밋)를 머지한 뒤 main에 태그를 뗍니다: git switch main && git pull -git tag -a v0.3.0 -m "Redrob Query v0.3.0" -git push origin refs/tags/v0.3.0:refs/tags/v0.3.0 -gh workflow run release.yml --ref main -f tag=v0.3.0 +git tag -a v0.4.0 -m "Redrob Query v0.4.0" +git push origin refs/tags/v0.4.0:refs/tags/v0.4.0 +gh workflow run release.yml --ref main -f tag=v0.4.0 # 그다음 main을 develop으로 다시 머지합니다 ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a2bf5c..a7cd98d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,14 +53,14 @@ from `origin/main`, so tagging straight off `develop` fails instead of shipping. ```bash git switch develop && git pull -git switch -c release/0.3.0 +git switch -c release/0.4.0 # bump the version in Cargo.toml, write the CHANGELOG section, run the gate # open a pull request into develop and merge it; then a pull request develop -> main # (merge commit) and tag main: git switch main && git pull -git tag -a v0.3.0 -m "Redrob Query v0.3.0" -git push origin refs/tags/v0.3.0:refs/tags/v0.3.0 -gh workflow run release.yml --ref main -f tag=v0.3.0 +git tag -a v0.4.0 -m "Redrob Query v0.4.0" +git push origin refs/tags/v0.4.0:refs/tags/v0.4.0 +gh workflow run release.yml --ref main -f tag=v0.4.0 # then merge main back into develop ``` diff --git a/Cargo.lock b/Cargo.lock index e5a8863..941eaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3918,7 +3918,7 @@ dependencies = [ [[package]] name = "redrob-query" -version = "0.3.0" +version = "0.4.0" dependencies = [ "arboard", "eframe", @@ -3930,6 +3930,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "sys-locale", "tokio", "uuid", "webbrowser", @@ -3938,7 +3939,7 @@ dependencies = [ [[package]] name = "redrob-query-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -4883,6 +4884,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 11bbff5..654f824 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" license = "GPL-3.0-or-later" rust-version = "1.95" diff --git a/NOTICE b/NOTICE index d3dc0cb..e4f203b 100644 --- a/NOTICE +++ b/NOTICE @@ -56,6 +56,16 @@ under the Apache License 2.0. Its architecture and user workflows informed this product's design. No DBeaver source code, binaries, plugins, branding, Eclipse RCP/OSGi components, JDBC drivers, Java, or JVM is distributed here. +## Bundled fonts + +Inter, Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter), +and JetBrains Mono, Copyright 2020 The JetBrains Mono Project Authors +(https://github.com/JetBrains/JetBrainsMono), are embedded in the desktop binary +as its interface and code typefaces (apps/redrob-query/assets/fonts). Both are +licensed under the SIL Open Font License, Version 1.1; the licence texts travel +beside the font files (OFL-Inter.txt, OFL-JetBrainsMono.txt). The same files are +what Redrob Canvas draws with, so the two apps share one look. + Third-party components remain subject to their respective licenses. ## Trademarks diff --git a/README.ko.md b/README.ko.md index d64285d..53eda4d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -117,6 +117,7 @@ cargo test -p redrob-query-core --locked -- --ignored | `account.json` | 런처의 Connect나 사용자가 써 둔 이 앱 자체의 Redrob 로그인. 공용 파일보다 우선 | | `workspace.json` | 열린 탭(쿼리 텍스트만)과 실행 이력. 결과 행·비밀번호·프롬프트는 저장하지 않음 | | `ui.ron` | eframe의 창 위치와 패널 크기 | +| `language.json` | 도움말 > 언어: `auto` 또는 코드(`en`, `ko`) | 디렉터리는 Windows `%APPDATA%\Redrob Query`, macOS `~/Library/Application Support/Redrob Query`, 그 외 `$XDG_CONFIG_HOME/redrob-query`(`~/.config/redrob-query`)이며 `REDROB_QUERY_CONFIG_DIR`로 바꿀 수 있습니다. diff --git a/README.md b/README.md index bc2cdc1..6b95f9f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ branch and publishes nothing. | `account.json` | This app's own Redrob sign-in, if the launcher's Connect or you wrote one; wins over the shared file | | `workspace.json` | Open tabs (query text only) and the run history; never result rows, passwords or prompts | | `ui.ron` | eframe's window geometry and panel sizes | +| `language.json` | Help > Language: `auto` or a code (`en`, `ko`) | Directory: `%APPDATA%\Redrob Query` on Windows, `~/Library/Application Support/Redrob Query` on macOS, `$XDG_CONFIG_HOME/redrob-query` (`~/.config/redrob-query`) elsewhere; `REDROB_QUERY_CONFIG_DIR` diff --git a/apps/redrob-query/Cargo.toml b/apps/redrob-query/Cargo.toml index 16f2f9d..fea84bf 100644 --- a/apps/redrob-query/Cargo.toml +++ b/apps/redrob-query/Cargo.toml @@ -23,6 +23,8 @@ env_logger = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +# The system language on first run (Windows has no LANG); the same pin as redrob-canvas. +sys-locale = "=0.3.2" tokio = { workspace = true } uuid = { workspace = true } diff --git a/apps/redrob-query/assets/fonts/Inter-Medium.ttf b/apps/redrob-query/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/apps/redrob-query/assets/fonts/Inter-Medium.ttf differ diff --git a/apps/redrob-query/assets/fonts/Inter-Regular.ttf b/apps/redrob-query/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/apps/redrob-query/assets/fonts/Inter-Regular.ttf differ diff --git a/apps/redrob-query/assets/fonts/Inter-SemiBold.ttf b/apps/redrob-query/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/apps/redrob-query/assets/fonts/Inter-SemiBold.ttf differ diff --git a/apps/redrob-query/assets/fonts/JetBrainsMono-Regular.ttf b/apps/redrob-query/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/apps/redrob-query/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/apps/redrob-query/assets/fonts/OFL-Inter.txt b/apps/redrob-query/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/apps/redrob-query/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/redrob-query/assets/fonts/OFL-JetBrainsMono.txt b/apps/redrob-query/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/apps/redrob-query/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/redrob-query/src/ai_dock.rs b/apps/redrob-query/src/ai_dock.rs index ffea040..5a18e15 100644 --- a/apps/redrob-query/src/ai_dock.rs +++ b/apps/redrob-query/src/ai_dock.rs @@ -1,4 +1,4 @@ -//! The AI dock: ask for a query in plain words, get a draft back, put it in the editor. +//! The Ask dock: ask for a query in plain words, get a draft back, put it in the editor. //! //! Each turn sends only the prompt and the active tab's text (never result rows or credentials) //! to the console with the shared account. Fenced `sql` / `mql` / `json` blocks in the answer each @@ -10,7 +10,8 @@ use redrob_query_core::{ ai::{AiKeySource, AiKeyStatus, NOT_SIGNED_IN_MESSAGE}, }; -use crate::theme; +use crate::i18n::{t, tf}; +use crate::theme::{self, Tokens}; #[derive(Clone, Debug)] pub struct Turn { @@ -71,10 +72,11 @@ impl AiDock { active: Option<(DatabaseKind, QueryLanguage, String)>, events: &mut Vec, ) { + let tokens = Tokens::of(ui); ui.horizontal(|ui| { - ui.heading("Ask"); + ui.label(RichText::new(t("Ask")).font(theme::semibold(12.5))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Clear").clicked() { + if ui.small_button(t("Clear")).clicked() { self.turns.clear(); self.error = None; } @@ -82,23 +84,32 @@ impl AiDock { }); match self.status.source { AiKeySource::None => { - ui.label(RichText::new(NOT_SIGNED_IN_MESSAGE).color(theme::CAUTION)); - if ui.button("Open Redrob Launcher").clicked() { + ui.add( + egui::Label::new(RichText::new(t(NOT_SIGNED_IN_MESSAGE)).color(tokens.warning)) + .wrap(), + ); + if ui.button(t("Open Redrob Launcher")).clicked() { let _ = webbrowser::open(Workspace::launcher_url()); } } AiKeySource::Launcher => { ui.label( - RichText::new(format!("{} · via Redrob Launcher", self.status.label)) - .small() - .weak(), + RichText::new(tf( + "{name} · via Redrob Launcher", + &[("name", &self.status.label)], + )) + .small() + .color(tokens.text_faint), ); } AiKeySource::Own => { ui.label( - RichText::new(format!("{} · this app's own key", self.status.label)) - .small() - .weak(), + RichText::new(tf( + "{name} · this app's own key", + &[("name", &self.status.label)], + )) + .small() + .color(tokens.text_faint), ); } } @@ -113,15 +124,26 @@ impl AiDock { .stick_to_bottom(true) .show(ui, |ui| { if self.turns.is_empty() { - ui.label(RichText::new( - "Describe the query you want. The active tab's text is sent along as context; result rows never are.", - ).weak()); + ui.add( + egui::Label::new( + RichText::new(t( + "Describe the query you want. The active tab's text is sent along as context; result rows never are.", + )) + .color(tokens.text_faint), + ) + .wrap(), + ); } for (index, turn) in self.turns.iter().enumerate() { - let who = if turn.user { "You" } else { "Redrob" }; - ui.label(RichText::new(who).small().strong().color(if turn.user { ui.visuals().text_color() } else { theme::REDROB_BLUE })); + let who = if turn.user { t("You") } else { "Redrob" }; + let colour = if turn.user { + tokens.text + } else { + tokens.accent_text + }; + ui.label(RichText::new(who).small().strong().color(colour)); if turn.user { - ui.label(&turn.text); + ui.add(egui::Label::new(&turn.text).wrap()); } else { render_answer(ui, index, &turn.text, events); } @@ -130,11 +152,11 @@ impl AiDock { if self.pending.is_some() { ui.horizontal(|ui| { ui.spinner(); - ui.label(RichText::new("Thinking…").weak()); + ui.label(RichText::new(t("Thinking…")).color(tokens.text_faint)); }); } if let Some(error) = &self.error { - ui.label(RichText::new(error).color(theme::DANGER)); + ui.add(egui::Label::new(RichText::new(error).color(tokens.danger)).wrap()); } }); @@ -146,9 +168,9 @@ impl AiDock { .desired_rows(3) .desired_width(f32::INFINITY) .hint_text(if active.is_some() { - "e.g. customers who ordered twice this month, newest first" + t("e.g. customers who ordered twice this month, newest first") } else { - "Open a query tab first" + t("Open a query tab first") }), ); let submit = response.has_focus() @@ -159,7 +181,7 @@ impl AiDock { if (ui .add_enabled( can_ask && !self.prompt.trim().is_empty(), - theme::primary_button("Ask (Ctrl+Enter)"), + theme::primary_button(ui, t("Ask (Ctrl+Enter)")), ) .clicked() || (submit && can_ask)) @@ -218,12 +240,12 @@ fn render_answer(ui: &mut Ui, index: usize, text: &str, events: &mut Vec Option { + let left = STATUS_SECS - now.saturating_duration_since(self.since).as_secs_f32(); + (left > 0.0).then_some(left.min(1.0)) + } +} + +/// The shortcuts the shell owns, for Help > Shortcuts and the menus. +const SHORTCUTS: &[(&str, &str)] = &[ + ( + "Ctrl+Enter", + "Run the selection or the statement under the cursor", + ), + ("F5", "Run the whole text"), + ("Esc", "Cancel the run in flight"), + ("Ctrl+T", "New query tab on the active connection"), + ("Ctrl+W", "Close the tab"), + ("Ctrl+1 … Ctrl+9", "Go to tab 1 … 9"), + ("Ctrl+Shift+F", "Format the query"), + ("Ctrl+O", "Open a .sql file into the tab"), + ("Ctrl+S / Ctrl+Shift+S", "Save the tab's text / save as"), + ( + "Ctrl+C", + "Copy the selected cell, or the selected rows as TSV", + ), + ("Ctrl+Shift+A", "Show or hide the Ask dock"), + ("Ctrl+N", "New connection"), + ("Double-click", "Schema tree: insert the name at the caret"), +]; + pub struct QueryApp { workspace: Workspace, profiles: ProfilesPanel, @@ -33,14 +85,25 @@ pub struct QueryApp { show_structure: bool, show_ai: bool, show_history: bool, - notice: Option<(String, bool)>, + shortcuts_open: bool, + about_open: bool, + about_icon: Option, + status: Option, + language: i18n::Language, + theme_pref: ThemePref, tab_counter: usize, dirty_since_save: bool, } impl QueryApp { pub fn new(ctx: &egui::Context) -> Self { - theme::apply(ctx); + let saved = WorkspaceFile::load().unwrap_or_default(); + let theme_pref = ThemePref::from_id(&saved.theme); + ctx.set_fonts(theme::font_definitions()); + theme::apply(ctx, theme_pref); + let language = i18n::Language::load(); + i18n::set_current(language.effective()); + let workspace = match services::config_dir() { Some(dir) => Workspace::persistent(&dir).or_else(|error| { log::warn!("profile storage unavailable ({error}); running without it"); @@ -56,7 +119,6 @@ impl QueryApp { let repaint = ctx.clone(); workspace.set_wake(move || repaint.request_repaint()); - let saved = WorkspaceFile::load().unwrap_or_default(); let mut tabs: Vec = saved .tabs .iter() @@ -82,25 +144,51 @@ impl QueryApp { show_structure: saved.structure_open, show_ai: saved.ai_dock_open, show_history: false, - notice: None, + shortcuts_open: false, + about_open: false, + about_icon: None, + status: None, + language, + theme_pref, tab_counter, dirty_since_save: false, workspace, } } + /// Show `text` on the status line (an error in the error colour). fn notify(&mut self, text: impl Into, is_error: bool) { let text = text.into(); if is_error { log::warn!("{text}"); } - self.notice = Some((text, is_error)); + self.status = Some(Status { + text, + error: is_error, + since: Instant::now(), + }); } fn profile(&self, id: Option) -> Option<&ConnectionProfile> { id.and_then(|id| self.profiles.profile(id)) } + /// The connection the shell acts on: the active tab's, else the selected one. + fn active_profile(&self) -> Option<&ConnectionProfile> { + self.tabs + .get(self.active) + .and_then(|tab| self.profile(tab.connection_id)) + .or_else(|| self.profiles.active()) + } + + fn kinds(&self) -> HashMap { + self.profiles + .profiles + .iter() + .map(|p| (p.id, p.kind)) + .collect() + } + fn save_workspace(&mut self) { let file = WorkspaceFile { schema: 1, @@ -117,11 +205,29 @@ impl QueryApp { history: self.history.clone(), ai_dock_open: self.show_ai, structure_open: self.show_structure, + theme: self.theme_pref.id().to_owned(), }; file.save(); self.dirty_since_save = false; } + fn push_tab(&mut self, mut tab: QueryTab) { + if self.tabs.len() >= crate::workspace::MAX_TABS { + self.notify( + tf( + "At most {n} tabs are kept; close one first.", + &[("n", &crate::workspace::MAX_TABS.to_string())], + ), + true, + ); + return; + } + tab.focus_editor = true; + self.tabs.push(tab); + self.active = self.tabs.len() - 1; + self.dirty_since_save = true; + } + fn open_tab_for(&mut self, profile: &ConnectionProfile, text: Option) { // Re-use an open tab on this connection that is still its untouched starter. if text.is_none() @@ -132,30 +238,52 @@ impl QueryApp { }) { self.active = index; + self.tabs[index].focus_editor = true; return; } self.tab_counter += 1; - let mut tab = match text { + let tab = match text { Some(text) => QueryTab::new( - format!("{} {}", profile.name, self.tab_counter), + tf("Query {n}", &[("n", &self.tab_counter.to_string())]), text, Some(profile), ), None => QueryTab::for_connection(profile, self.tab_counter), }; - tab.focus_editor = true; - if self.tabs.len() >= crate::workspace::MAX_TABS { - self.notify( - format!( - "At most {} tabs are kept; close one first.", - crate::workspace::MAX_TABS - ), - true, - ); + self.push_tab(tab); + } + + /// Ctrl+T / File > New tab: on the active connection, connecting it first if need be. + fn new_tab(&mut self) { + let selected = self.active_profile().cloned().or_else(|| { + self.profiles + .profiles + .iter() + .find(|p| self.profiles.is_connected(p.id)) + .cloned() + }); + match selected { + Some(profile) if self.profiles.is_connected(profile.id) => { + self.tab_counter += 1; + let tab = QueryTab::for_connection(&profile, self.tab_counter); + self.push_tab(tab); + } + Some(profile) => self.profiles.connect(&self.workspace, profile.id), + None => self.notify(t("Select or connect a connection first"), true), + } + } + + fn close_tab(&mut self, index: usize) { + if index >= self.tabs.len() { return; } - self.tabs.push(tab); - self.active = self.tabs.len() - 1; + if let Some(tab) = self.tabs.get_mut(index) { + tab.cancel(); + } + self.tabs.remove(index); + if self.active >= self.tabs.len() { + self.active = self.tabs.len().saturating_sub(1); + } self.dirty_since_save = true; } @@ -172,14 +300,16 @@ impl QueryApp { } } - fn poll(&mut self) { - let mut profile_events = Vec::new(); - self.profiles.poll(&self.workspace, &mut profile_events); - let mut connected_now = Vec::new(); - for event in profile_events { + fn handle_profile_events(&mut self, events: Vec) { + for event in events { match event { ProfilesEvent::OpenQuery(profile) => self.open_tab_for(&profile, None), - ProfilesEvent::Connected(id) => connected_now.push(id), + ProfilesEvent::Connected(id) => { + self.schema.load_root(&self.workspace, id); + if let Some(name) = self.profiles.profile(id).map(|p| p.name.clone()) { + self.notify(tf("Connected to {name}", &[("name", &name)]), false); + } + } ProfilesEvent::Disconnected(id) => { self.schema.forget(id); self.structure.forget(id); @@ -187,13 +317,16 @@ impl QueryApp { ProfilesEvent::Notice(text, is_error) => self.notify(text, is_error), } } - for id in connected_now { - self.schema.load_root(&self.workspace, id); - } + } + + fn poll(&mut self) { + let mut profile_events = Vec::new(); + self.profiles.poll(&self.workspace, &mut profile_events); + self.handle_profile_events(profile_events); self.bind_restored_tabs(); let mut schema_events = Vec::new(); - self.schema.poll(&mut schema_events); + self.schema.poll(&self.kinds(), &mut schema_events); self.handle_schema_events(schema_events); let mut structure_events = Vec::new(); @@ -202,7 +335,7 @@ impl QueryApp { match event { StructureEvent::Changed(id) => { self.schema.load_root(&self.workspace, id); - self.notify("Table change applied", false); + self.notify(t("Table change applied"), false); } StructureEvent::Notice(text, is_error) => self.notify(text, is_error), } @@ -214,9 +347,12 @@ impl QueryApp { match event { ChangesEvent::Applied(result) => { self.notify( - format!( - "Applied {} row change(s) in {} ms", - result.rows_affected, result.elapsed_ms + tf( + "Applied {rows} row change(s) in {ms} ms", + &[ + ("rows", &result.rows_affected.to_string()), + ("ms", &result.elapsed_ms.to_string()), + ], ), false, ); @@ -234,26 +370,14 @@ impl QueryApp { let mut ai_events = Vec::new(); self.ai.poll(&mut ai_events); - for event in ai_events { - match event { - AiEvent::Insert(draft) => { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.insert(draft); - tab.focus_editor = true; - } else { - self.notify("Open a query tab to insert the draft into", true); - } - } - AiEvent::Notice(text, is_error) => self.notify(text, is_error), - } - } + self.handle_ai_events(ai_events); if self.account.poll(&self.workspace) { self.ai.refresh_status(&self.workspace); let text = if self.account.status.signed_in() { - format!("Signed in: {}", self.account.status.label) + tf("Signed in: {name}", &[("name", &self.account.status.label)]) } else { - "Signed out of Redrob".to_owned() + t("Signed out of Redrob").to_owned() }; self.notify(text, false); } @@ -280,6 +404,31 @@ impl QueryApp { } self.dirty_since_save = true; } + for event in self.tabs[index].take_events() { + match event { + TabEvent::Status(text, error) => { + if index == self.active || error { + self.notify(text, error); + } + } + } + } + } + } + + fn handle_ai_events(&mut self, events: Vec) { + for event in events { + match event { + AiEvent::Insert(draft) => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.insert(draft); + tab.focus_editor = true; + } else { + self.notify(t("Open a query tab to insert the draft into"), true); + } + } + AiEvent::Notice(text, is_error) => self.notify(text, is_error), + } } } @@ -290,10 +439,11 @@ impl QueryApp { connection_id, node, } => { - if let Some(kind) = self.profiles.profile(connection_id).map(|p| p.kind) { + if self.show_structure + && let Some(kind) = self.profiles.profile(connection_id).map(|p| p.kind) + { self.structure .show_table(&self.workspace, connection_id, kind, node); - self.show_structure = true; } } SchemaEvent::Preview { @@ -305,314 +455,854 @@ impl QueryApp { self.open_tab_for(&profile, Some(query)); if let Some(tab) = self.tabs.get_mut(self.active) { tab.title.clone_from(&node.name); + tab.saved_text.clone_from(&tab.text); tab.run_now(&self.workspace, None); } } } + SchemaEvent::Insert(name) => match self.tabs.get_mut(self.active) { + Some(tab) => tab.insert_at_cursor(name), + None => self.notify(t("Open a query tab first (Ctrl+T)"), true), + }, + SchemaEvent::OpenText { + connection_id, + title, + text, + } => { + if let Some(profile) = self.profiles.profile(connection_id).cloned() { + self.open_tab_for(&profile, Some(text)); + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.title = title; + tab.saved_text.clone_from(&tab.text); + } + } + } SchemaEvent::Notice(text, is_error) => self.notify(text, is_error), } } } + // ---- shortcuts ---------------------------------------------------------------------------- + + fn handle_shortcuts(&mut self, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + let cmd = Modifiers::COMMAND; + let shift = Modifiers::COMMAND | Modifiers::SHIFT; + // A modal form owns the keyboard while it is up. + if self.profiles.form.is_some() { + return; + } + if ctx.input_mut(|i| i.consume_key(cmd, Key::T)) { + self.new_tab(); + } + if ctx.input_mut(|i| i.consume_key(cmd, Key::W)) { + self.close_tab(self.active); + } + if ctx.input_mut(|i| i.consume_key(cmd, Key::N)) { + self.profiles.new_connection(); + } + if ctx.input_mut(|i| i.consume_key(shift, Key::A)) { + self.show_ai = !self.show_ai; + self.dirty_since_save = true; + } + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::F5)) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.run_all(&self.workspace); + } + if ctx.input(|i| i.key_pressed(Key::Escape)) + && let Some(tab) = self.tabs.get_mut(self.active) + && tab.is_running() + { + tab.cancel(); + } + for (n, key) in [ + Key::Num1, + Key::Num2, + Key::Num3, + Key::Num4, + Key::Num5, + Key::Num6, + Key::Num7, + Key::Num8, + Key::Num9, + ] + .into_iter() + .enumerate() + { + if ctx.input_mut(|i| i.consume_key(cmd, key)) && n < self.tabs.len() { + self.active = n; + } + } + if ctx.input_mut(|i| i.consume_key(shift, Key::S)) { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.save_to_file(true); + } + } else if ctx.input_mut(|i| i.consume_key(cmd, Key::S)) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.save_to_file(false); + } + if ctx.input_mut(|i| i.consume_key(cmd, Key::O)) { + self.open_file(); + } + // Ctrl+C with no text field focused copies from the grid. + let text_focused = ctx.memory(egui::Memory::focused).is_some(); + if !text_focused + && ctx.input_mut(|i| i.consume_key(cmd, Key::C)) + && let Some(grid) = self.tabs.get(self.active).and_then(|t| t.result.as_ref()) + && grid.copy_selection(ctx) + { + self.notify(t("Copied"), false); + } + } + + fn open_file(&mut self) { + let Some(path) = rfd::FileDialog::new() + .add_filter("SQL", &["sql", "txt", "json"]) + .pick_file() + else { + return; + }; + if self.tabs.is_empty() { + self.new_tab(); + } + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.open_file(path); + self.dirty_since_save = true; + } + } + + // ---- top bar ----------------------------------------------------------------------------- + fn top_bar(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label(RichText::new(services::APP_NAME).strong().size(16.0)); - ui.label(RichText::new(env!("CARGO_PKG_VERSION")).small().weak()); - ui.separator(); - ui.toggle_value(&mut self.show_structure, "Structure"); - ui.toggle_value(&mut self.show_history, "History"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.toggle_value(&mut self.show_ai, "Ask").changed() { - self.dirty_since_save = true; - } - ui.separator(); - let status = &self.account.status; - if status.signed_in() { - theme::status_dot(ui, theme::SUCCESS); - ui.label(RichText::new(&status.label).small()) - .on_hover_text(self.account.describe_paths()); - } else { - let weak = ui.visuals().weak_text_color(); - theme::status_dot(ui, weak); - if ui - .small_button("Sign in with Redrob Launcher") - .on_hover_text(self.account.describe_paths()) - .clicked() - { - let _ = webbrowser::open(Workspace::launcher_url()); - } - } + let frame = theme::bar_frame(ui); + egui::Panel::top("top-bar") + .exact_size(theme::TOP_BAR_HEIGHT) + .frame(frame) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.add_space(8.0); + ui.label(RichText::new(services::APP_NAME).font(theme::semibold(13.0))); + ui.add_space(8.0); + ui.separator(); + ui.menu_button(t("File"), |ui| self.file_menu(ui)); + ui.menu_button(t("Connection"), |ui| self.connection_menu(ui)); + ui.menu_button(t("Query"), |ui| self.query_menu(ui)); + ui.menu_button(t("View"), |ui| self.view_menu(ui)); + ui.menu_button(t("Help"), |ui| self.help_menu(ui)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + self.right_slot(ui); + }); + }); }); + } + + fn menu_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool) -> bool { + let clicked = ui + .add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)) + .clicked(); + if clicked { + ui.close(); + } + clicked + } + + fn file_menu(&mut self, ui: &mut Ui) { + let has_tab = !self.tabs.is_empty(); + if Self::menu_item(ui, t("New tab"), "Ctrl+T", true) { + self.new_tab(); + } + if Self::menu_item(ui, t("Close tab"), "Ctrl+W", has_tab) { + self.close_tab(self.active); + } + ui.separator(); + if Self::menu_item(ui, t("Open…"), "Ctrl+O", true) { + self.open_file(); + } + if Self::menu_item(ui, t("Save"), "Ctrl+S", has_tab) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.save_to_file(false); + } + if Self::menu_item(ui, t("Save as…"), "Ctrl+Shift+S", has_tab) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.save_to_file(true); + } + ui.separator(); + if Self::menu_item(ui, t("Quit"), "", true) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + fn connection_menu(&mut self, ui: &mut Ui) { + let active = self.active_profile().cloned(); + let connected = active + .as_ref() + .is_some_and(|p| self.profiles.is_connected(p.id)); + if Self::menu_item(ui, t("New connection…"), "Ctrl+N", true) { + self.profiles.new_connection(); + } + ui.separator(); + let name = active.as_ref().map_or_else(String::new, |p| p.name.clone()); + if Self::menu_item( + ui, + &tf("Connect {name}", &[("name", &name)]), + "", + active.is_some() && !connected, + ) && let Some(p) = &active + { + self.profiles.connect(&self.workspace, p.id); + } + if Self::menu_item( + ui, + &tf("Disconnect {name}", &[("name", &name)]), + "", + connected, + ) && let Some(p) = &active + { + self.profiles.disconnect(&self.workspace, p.id); + } + if Self::menu_item(ui, t("Reload schema"), "", connected) + && let Some(p) = &active + { + self.schema.load_root(&self.workspace, p.id); + } + ui.separator(); + let editable = active.as_ref().is_some_and(|p| !p.built_in); + if Self::menu_item(ui, t("Edit…"), "", editable) + && let Some(p) = &active + { + self.profiles.edit(p.id); + } + if Self::menu_item(ui, t("Remove…"), "", editable) + && let Some(p) = &active + { + self.profiles.ask_remove(p.id); + } + } + + fn query_menu(&mut self, ui: &mut Ui) { + let (can_run, running) = self.tabs.get(self.active).map_or((false, false), |t| { + (t.connection_id.is_some() && !t.is_running(), t.is_running()) }); + if Self::menu_item(ui, t("Run"), "Ctrl+Enter", can_run) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.run_now(&self.workspace, None); + } + if Self::menu_item(ui, t("Run all"), "F5", can_run) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.run_all(&self.workspace); + } + if Self::menu_item(ui, t("Cancel"), "Esc", running) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.cancel(); + } + ui.separator(); + if Self::menu_item(ui, t("Format"), "Ctrl+Shift+F", !self.tabs.is_empty()) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.format_text(); + } + if Self::menu_item(ui, t("Clear result"), "", !self.tabs.is_empty()) + && let Some(tab) = self.tabs.get_mut(self.active) + { + tab.result = None; + tab.source = None; + } } - fn status_bar(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - match &self.notice { - Some((text, is_error)) => { - let colour = theme::tone_for_error(ui, *is_error); - ui.label(RichText::new(text).small().color(colour)); + fn view_menu(&mut self, ui: &mut Ui) { + if ui + .checkbox(&mut self.show_structure, t("Structure panel")) + .changed() + { + self.dirty_since_save = true; + ui.close(); + } + if ui + .checkbox(&mut self.show_history, t("History panel")) + .changed() + { + ui.close(); + } + if ui + .add(egui::Checkbox::new(&mut self.show_ai, t("Ask dock"))) + .on_hover_text("Ctrl+Shift+A") + .changed() + { + self.dirty_since_save = true; + ui.close(); + } + } + + fn help_menu(&mut self, ui: &mut Ui) { + if Self::menu_item(ui, t("Shortcuts"), "", true) { + self.shortcuts_open = true; + } + ui.menu_button(t("Theme"), |ui| { + for pref in ThemePref::ALL { + if ui.radio(self.theme_pref == pref, t(pref.label())).clicked() { + self.theme_pref = pref; + theme::set_pref(ui.ctx(), pref); + self.dirty_since_save = true; + ui.close(); } - None => { - ui.label(RichText::new("Ready").small().weak()); + } + }); + ui.menu_button(t("Language"), |ui| self.language_menu(ui)); + ui.separator(); + if Self::menu_item(ui, t("Report an issue…"), "", true) { + let _ = webbrowser::open("https://github.com/redrob-labs/redrob-query/issues"); + } + if Self::menu_item( + ui, + &tf("About {app}", &[("app", services::APP_NAME)]), + "", + true, + ) { + self.about_open = true; + } + } + + fn language_menu(&mut self, ui: &mut Ui) { + let system = self.language.system(); + if ui + .radio( + self.language.is_auto(), + tf("System ({name})", &[("name", system.name)]), + ) + .on_hover_text(t("Follow the operating system's language")) + .clicked() + { + self.language.set(i18n::AUTO); + ui.close(); + } + ui.separator(); + let effective = self.language.effective(); + for lang in i18n::LANGUAGES { + let selected = !self.language.is_auto() && effective.code == lang.code; + if ui.radio(selected, lang.name).clicked() { + self.language.set(lang.code); + ui.close(); + } + } + } + + /// The right side of the top bar: the Ask toggle, the active connection with its mode badge, + /// and the account. + fn right_slot(&mut self, ui: &mut Ui) { + let tokens = Tokens::of(ui); + let ask = ui + .selectable_label(self.show_ai, t("Ask")) + .on_hover_text(tf( + "{what} (Ctrl+Shift+A)", + &[("what", t("Show or hide the Ask dock"))], + )); + if ask.clicked() { + self.show_ai = !self.show_ai; + self.dirty_since_save = true; + } + ui.separator(); + // The account chip. + let status = &self.account.status; + if status.signed_in() { + theme::chip(ui, Some(tokens.success), &status.label).on_hover_text(format!( + "{}\n{}", + t("Signed in with Redrob Launcher"), + self.account.describe_paths() + )); + } else if ui + .small_button(t("Connect Redrob")) + .on_hover_text(format!( + "{}\n{}", + t("Sign in with Redrob Launcher to use Ask"), + self.account.describe_paths() + )) + .clicked() + { + let _ = webbrowser::open(Workspace::launcher_url()); + } + // The active connection chip, with its read-only / read-write badge. + if let Some(profile) = self.active_profile().cloned() { + let connected = self.profiles.is_connected(profile.id); + let (badge_text, badge_fill) = if profile.read_only { + (t("read-only"), tokens.text_faint) + } else { + (t("read-write"), tokens.warning) + }; + theme::badge(ui, badge_text, badge_fill, egui::Color32::WHITE); + let dot = if connected { + tokens.success + } else if self.profiles.is_connecting(profile.id) { + tokens.warning + } else { + tokens.text_faint + }; + let chip = theme::chip(ui, Some(dot), &profile.name).on_hover_text(format!( + "{} · {}", + kind_name(profile.kind), + if connected { + t("connected") + } else { + t("not connected") } + )); + if chip.double_clicked() && !connected { + self.profiles.connect(&self.workspace, profile.id); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let connected = self - .profiles - .profiles - .iter() - .filter(|p| self.profiles.is_connected(p.id)) - .count(); - ui.label( - RichText::new(format!("{connected} connected · {} tabs", self.tabs.len())) - .small() - .weak(), - ); + } + } + + // ---- status line ------------------------------------------------------------------------- + + fn status_line(&mut self, ui: &mut Ui) { + let frame = theme::bar_frame(ui); + egui::Panel::bottom("status-line") + .exact_size(theme::STATUS_HEIGHT) + .frame(frame) + .show(ui, |ui| { + let tokens = Tokens::of(ui); + ui.horizontal_centered(|ui| { + ui.add_space(8.0); + let running = self.tabs.iter().any(QueryTab::is_running); + if running { + ui.spinner(); + ui.label( + RichText::new(t("Running… Esc cancels")) + .small() + .color(tokens.text_dim), + ); + ui.separator(); + } + match self.status.clone() { + Some(status) => match status.alpha(Instant::now()) { + Some(alpha) => { + let colour = if status.error { + tokens.danger + } else { + tokens.text + }; + ui.add( + egui::Label::new( + RichText::new(&status.text) + .small() + .color(colour.gamma_multiply(alpha)), + ) + .truncate(), + ) + .on_hover_text(&status.text); + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } + None => self.status = None, + }, + None => { + ui.label(RichText::new(t("Ready")).small().color(tokens.text_faint)); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + let connected = self + .profiles + .profiles + .iter() + .filter(|p| self.profiles.is_connected(p.id)) + .count(); + ui.label( + RichText::new(tf( + "{connected} connected · {tabs} tabs", + &[ + ("connected", &connected.to_string()), + ("tabs", &self.tabs.len().to_string()), + ], + )) + .small() + .color(tokens.text_faint), + ); + }); + }); }); - }); } + // ---- centre ------------------------------------------------------------------------------ + fn tab_bar(&mut self, ui: &mut Ui) { + let tokens = Tokens::of(ui); let mut close: Option = None; let mut new_tab = false; - egui::ScrollArea::horizontal() - .id_salt("tabs") - .auto_shrink([false, true]) + egui::Frame::NONE + .fill(tokens.tab_strip) + .inner_margin(egui::Margin::symmetric(6, 2)) .show(ui, |ui| { - ui.horizontal(|ui| { - for (index, tab) in self.tabs.iter().enumerate() { - let mut title = tab.title.clone(); - if tab.is_running() { - title.push_str(" ⟳"); - } else if tab - .result - .as_ref() - .is_some_and(crate::grid::ResultGrid::has_changes) - { - title.push_str(" •"); - } - let selected = index == self.active; - let text = if selected { - RichText::new(title).color(theme::REDROB_BLUE).strong() - } else { - RichText::new(title) - }; - let response = ui.selectable_label(selected, text); - if response.clicked() { - self.active = index; - } - if response.middle_clicked() { - close = Some(index); - } - response.context_menu(|ui| { - if ui.button("Close").clicked() { - close = Some(index); - ui.close(); + egui::ScrollArea::horizontal() + .id_salt("tabs") + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (index, tab) in self.tabs.iter().enumerate() { + let selected = index == self.active; + let connection = tab + .connection_id + .and_then(|id| self.profiles.profile(id)) + .map_or_else( + || t("no connection").to_owned(), + |p| p.name.clone(), + ); + let body = egui::TextStyle::Body.resolve(ui.style()); + let small = egui::TextStyle::Small.resolve(ui.style()); + let mut job = egui::text::LayoutJob::default(); + // The selected tab sits on the selection fill (the accent). + let title_colour = if selected { + tokens.primary_text + } else { + tokens.text + }; + let faint = if selected { + tokens.primary_text.gamma_multiply(0.75) + } else { + tokens.text_faint + }; + job.append( + &tab.title, + 0.0, + egui::TextFormat::simple(body.clone(), title_colour), + ); + job.append( + &format!(" {connection}"), + 0.0, + egui::TextFormat::simple(small, faint), + ); + if tab.is_running() { + job.append( + " ⟳", + 0.0, + egui::TextFormat::simple(body.clone(), tokens.warning), + ); + } else if tab.is_dirty() + || tab + .result + .as_ref() + .is_some_and(crate::grid::ResultGrid::has_changes) + { + job.append( + " ●", + 0.0, + egui::TextFormat::simple(body, title_colour), + ); + } + let response = ui.selectable_label(selected, job); + if response.clicked() { + self.active = index; + } + if response.middle_clicked() { + close = Some(index); + } + response.context_menu(|ui| { + if ui.button(t("Close tab")).clicked() { + close = Some(index); + ui.close(); + } + }); + } + if ui + .add(egui::Button::new("+").frame(false)) + .on_hover_text(tf( + "{what} (Ctrl+T)", + &[("what", t("New query tab on the active connection"))], + )) + .clicked() + { + new_tab = true; } }); - } - if ui - .button("+") - .on_hover_text("New tab on the selected profile") - .clicked() - { - new_tab = true; - } - }); + }); }); if let Some(index) = close { - self.tabs.remove(index); - if self.active >= self.tabs.len() { - self.active = self.tabs.len().saturating_sub(1); - } - self.dirty_since_save = true; + self.close_tab(index); } if new_tab { - let selected = self - .profiles - .selected - .and_then(|id| self.profiles.profile(id)) - .cloned() - .or_else(|| { - self.profiles - .profiles - .iter() - .find(|p| self.profiles.is_connected(p.id)) - .cloned() - }); - match selected { - Some(profile) if self.profiles.is_connected(profile.id) => { - self.tab_counter += 1; - let mut tab = QueryTab::for_connection(&profile, self.tab_counter); - tab.focus_editor = true; - self.tabs.push(tab); - self.active = self.tabs.len() - 1; - self.dirty_since_save = true; - } - Some(profile) => self.profiles.connect(&self.workspace, profile.id), - None => self.notify("Select or connect a profile first", true), - } + self.new_tab(); } } fn history_ui(&mut self, ui: &mut Ui) { - ui.heading("History"); - let mut rerun: Option = None; + let tokens = Tokens::of(ui); + ui.horizontal(|ui| { + ui.label(RichText::new(t("History")).font(theme::semibold(12.5))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("✕").clicked() { + self.show_history = false; + } + if !self.history.is_empty() && ui.small_button(t("Clear")).clicked() { + self.history.clear(); + self.dirty_since_save = true; + } + }); + }); + ui.label( + RichText::new(t( + "Click to insert into the editor; double-click opens a tab.", + )) + .small() + .color(tokens.text_faint), + ); + let mut insert: Option = None; + let mut open: Option = None; egui::ScrollArea::vertical() .id_salt("history") .auto_shrink([false, false]) .show(ui, |ui| { if self.history.is_empty() { - ui.label(RichText::new("Nothing has run yet.").weak()); + ui.label(RichText::new(t("Nothing has run yet.")).color(tokens.text_faint)); } for entry in self.history.iter().rev() { let one_line: String = entry.query.split_whitespace().collect::>().join(" "); let shown: String = one_line.chars().take(90).collect(); - let colour = if entry.ok { - ui.visuals().text_color() - } else { - theme::DANGER - }; + let colour = if entry.ok { tokens.text } else { tokens.danger }; let response = ui.add( egui::Label::new(RichText::new(shown).monospace().small().color(colour)) .sense(egui::Sense::click()) .truncate(), ); - let meta = format!( - "{} · {} rows · {} ms", - entry.connection_name, entry.rows, entry.elapsed_ms + let meta = tf( + "{name} · {rows} rows · {ms} ms", + &[ + ("name", &entry.connection_name), + ("rows", &entry.rows.to_string()), + ("ms", &entry.elapsed_ms.to_string()), + ], ); - ui.label(RichText::new(meta).small().weak()); + ui.label(RichText::new(meta).small().color(tokens.text_faint)); if response.double_clicked() { - rerun = Some(entry.clone()); + open = Some(entry.clone()); + } else if response.clicked() { + insert = Some(entry.query.clone()); } - response - .on_hover_text(format!("{}\n\nDouble-click to open in a tab", entry.query)); + response.on_hover_text(RichText::new(&entry.query).monospace()); ui.separator(); } }); - if let Some(entry) = rerun { + if let Some(text) = insert { + match self.tabs.get_mut(self.active) { + Some(tab) => { + tab.insert(text); + tab.focus_editor = true; + } + None => self.notify(t("Open a query tab first (Ctrl+T)"), true), + } + } + if let Some(entry) = open { if let Some(profile) = self.profile(entry.connection_id).cloned() { self.open_tab_for(&profile, Some(entry.query)); } else { - self.notify("That connection is not available any more", true); + self.notify(t("That connection is not available any more"), true); } } } + /// First run, or no tab open: one primary action. + fn empty_state(&mut self, ui: &mut Ui) { + let tokens = Tokens::of(ui); + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.28); + ui.label(RichText::new(t("Connect a database")).font(theme::semibold(20.0))); + ui.add_space(6.0); + ui.label( + RichText::new(t( + "Add a PostgreSQL, MySQL, SQLite or MongoDB connection, then query it in tabs.", + )) + .color(tokens.text_dim), + ); + ui.add_space(16.0); + if ui + .add(theme::primary_button(ui, t("New connection"))) + .clicked() + { + self.profiles.new_connection(); + } + let demo = self + .profiles + .profiles + .iter() + .find(|p| p.built_in && p.capabilities.connect) + .cloned(); + if let Some(demo) = demo { + ui.add_space(8.0); + if ui + .link( + RichText::new(tf("Try the {name} database", &[("name", &demo.name)])) + .small(), + ) + .clicked() + { + if self.profiles.is_connected(demo.id) { + self.open_tab_for(&demo, None); + } else { + self.profiles.connect(&self.workspace, demo.id); + } + } + } + }); + } + fn central(&mut self, ui: &mut Ui) { - self.tab_bar(ui); - ui.separator(); if self.tabs.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(ui.available_height() * 0.3); - ui.label( - RichText::new("Connect a profile on the left, then open a query tab.").weak(), - ); - ui.label( - RichText::new("The built-in Demo SQLite database works offline.") - .small() - .weak(), - ); - }); + self.empty_state(ui); return; } + self.tab_bar(ui); let active = self.active.min(self.tabs.len() - 1); self.active = active; let profile = self.tabs[active] .connection_id .and_then(|id| self.profiles.profile(id)) .cloned(); - let read_only = profile.as_ref().is_none_or(|p| p.read_only); let collections: Vec = profile .as_ref() .filter(|p| p.kind == DatabaseKind::MongoDb) .map(|p| self.schema.roots(p.id).to_vec()) .unwrap_or_default(); + let kind = profile.as_ref().map(|p| p.kind); - let mut notice = None; + let tokens = Tokens::of(ui); + let editor_frame = egui::Frame::NONE + .fill(tokens.canvas) + .inner_margin(egui::Margin::same(6)); egui::Panel::top("editor-pane") .resizable(true) .default_size(260.0) - .size_range(120.0..=f32::INFINITY) + .size_range(100.0..=f32::INFINITY) + .frame(editor_frame) .show(ui, |ui| { - notice = self.tabs[active].editor_ui( - ui, - &self.workspace, - profile.as_ref(), - &collections, - ); + self.tabs[active].editor_ui(ui, &self.workspace, profile.as_ref(), &collections); }); - if let Some(text) = notice { - self.notify(text, false); + let tab = &mut self.tabs[active]; + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(tokens.canvas)) + .show(ui, |ui| { + tab.result_ui(ui, &self.workspace, &mut self.changes, kind); + }); + } + + // ---- windows ----------------------------------------------------------------------------- + + fn shortcuts_window(&mut self, ctx: &egui::Context) { + if !self.shortcuts_open { + return; } + let mut open = self.shortcuts_open; + egui::Window::new(t("Shortcuts")) + .id(egui::Id::new("shortcuts")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + let tokens = Tokens::of(ui); + egui::Grid::new("shortcuts-grid") + .num_columns(2) + .spacing([18.0, 6.0]) + .show(ui, |ui| { + for (keys, what) in SHORTCUTS { + ui.label(RichText::new(*keys).monospace().color(tokens.accent_text)); + ui.label(t(what)); + ui.end_row(); + } + }); + }); + self.shortcuts_open = open; + } - if self.show_structure { - egui::Panel::right("structure-pane") - .resizable(true) - .default_size(300.0) - .show(ui, |ui| { - self.structure.ui(ui, &self.workspace, read_only); - }); + fn about_window(&mut self, ctx: &egui::Context) { + if !self.about_open { + return; } - let tab = &mut self.tabs[active]; - if let Some(grid) = &mut tab.result - && grid.has_changes() + if self.about_icon.is_none() + && let Ok(icon) = eframe::icon_data::from_png_bytes(ABOUT_ICON_PNG) { - egui::Panel::bottom("changes-pane") - .resizable(true) - .default_size(160.0) - .show(ui, |ui| { - self.changes - .ui(ui, &self.workspace, grid, tab.source.as_ref(), read_only); - }); + let image = egui::ColorImage::from_rgba_unmultiplied( + [icon.width as usize, icon.height as usize], + &icon.rgba, + ); + self.about_icon = + Some(ctx.load_texture("about-icon", image, egui::TextureOptions::LINEAR)); } - egui::CentralPanel::default().show(ui, |ui| match &mut tab.result { - Some(grid) => grid.ui(ui), - None => { - ui.label(RichText::new("Run a query to see its rows here (Ctrl+Enter).").weak()); - } - }); + let mut open = self.about_open; + egui::Window::new(tf("About {app}", &[("app", services::APP_NAME)])) + .id(egui::Id::new("about")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + let tokens = Tokens::of(ui); + ui.set_width(380.0); + ui.vertical_centered(|ui| { + ui.add_space(8.0); + if let Some(texture) = &self.about_icon { + ui.add( + egui::Image::from_texture((texture.id(), texture.size_vec2())) + .fit_to_exact_size(egui::vec2(64.0, 64.0)), + ); + } + ui.add_space(8.0); + ui.label(RichText::new(services::APP_NAME).font(theme::semibold(16.0))); + ui.label(tf( + "Version {version}", + &[("version", env!("CARGO_PKG_VERSION"))], + )); + ui.add_space(8.0); + ui.label( + RichText::new(t( + "GPL-3.0-or-later. Third-party notices: NOTICE and UPSTREAM_NOTICES.md.", + )) + .small() + .color(tokens.text_dim), + ); + ui.add_space(8.0); + }); + }); + self.about_open = open; } } impl eframe::App for QueryApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { self.poll(); + self.handle_shortcuts(ui.ctx()); + + self.top_bar(ui); + self.status_line(ui); - egui::Panel::top("top-bar").show(ui, |ui| self.top_bar(ui)); - egui::Panel::bottom("status-bar").show(ui, |ui| self.status_bar(ui)); + let panel_frame = theme::panel_frame(ui); egui::Panel::left("side") .resizable(true) .default_size(280.0) .size_range(200.0..=600.0) + .frame(panel_frame) .show(ui, |ui| { let mut events = Vec::new(); self.profiles.ui(ui, &self.workspace, &mut events); - for event in events { - match event { - ProfilesEvent::OpenQuery(profile) => self.open_tab_for(&profile, None), - ProfilesEvent::Connected(id) => self.schema.load_root(&self.workspace, id), - ProfilesEvent::Disconnected(id) => { - self.schema.forget(id); - self.structure.forget(id); - } - ProfilesEvent::Notice(text, is_error) => self.notify(text, is_error), - } - } + self.handle_profile_events(events); ui.separator(); - let connected: Vec = self - .profiles - .profiles - .iter() - .filter(|p| self.profiles.is_connected(p.id)) - .cloned() - .collect(); - let refs: Vec<&ConnectionProfile> = connected.iter().collect(); + let profile = self.active_profile().cloned(); + let connected = profile + .as_ref() + .is_some_and(|p| self.profiles.is_connected(p.id)); let mut events = Vec::new(); - self.schema.ui(ui, &self.workspace, &refs, &mut events); + self.schema.ui( + ui, + &self.workspace, + profile.as_ref(), + connected, + &mut events, + ); self.handle_schema_events(events); }); if self.show_ai { @@ -620,6 +1310,7 @@ impl eframe::App for QueryApp { .resizable(true) .default_size(340.0) .size_range(260.0..=700.0) + .frame(panel_frame) .show(ui, |ui| { let active = self.tabs.get(self.active).and_then(|tab| { let profile = tab.connection_id.and_then(|id| self.profiles.profile(id))?; @@ -631,26 +1322,33 @@ impl eframe::App for QueryApp { }); let mut events = Vec::new(); self.ai.ui(ui, &self.workspace, active, &mut events); - for event in events { - match event { - AiEvent::Insert(draft) => { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.insert(draft); - tab.focus_editor = true; - } - } - AiEvent::Notice(text, is_error) => self.notify(text, is_error), - } - } + self.handle_ai_events(events); }); } if self.show_history { egui::Panel::right("history-pane") .resizable(true) .default_size(300.0) + .frame(panel_frame) .show(ui, |ui| self.history_ui(ui)); } - egui::CentralPanel::default().show(ui, |ui| self.central(ui)); + if self.show_structure { + let read_only = self.active_profile().is_none_or(|p| p.read_only); + egui::Panel::right("structure-pane") + .resizable(true) + .default_size(300.0) + .frame(panel_frame) + .show(ui, |ui| { + self.structure.ui(ui, &self.workspace, read_only); + }); + } + let canvas = Tokens::of(ui).canvas; + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(canvas)) + .show(ui, |ui| self.central(ui)); + + self.shortcuts_window(ui.ctx()); + self.about_window(ui.ctx()); if self.dirty_since_save { self.save_workspace(); diff --git a/apps/redrob-query/src/changes.rs b/apps/redrob-query/src/changes.rs index 1eb550c..32a1b11 100644 --- a/apps/redrob-query/src/changes.rs +++ b/apps/redrob-query/src/changes.rs @@ -1,16 +1,17 @@ -//! The Changes panel: the staged cell edits and row deletions of a result, reviewed as a list, -//! then applied through `apply_cell_edits` in one transaction (each row update must hit exactly -//! one row or everything rolls back). Nothing is written until Apply is pressed. +//! The Changes pane: the staged cell edits and row deletions of a result, reviewed as a list with +//! the SQL the engine will run, then applied through `apply_cell_edits` in one transaction (each +//! row update must hit exactly one row or everything rolls back). Nothing is written until Apply. use egui::{RichText, Ui}; use redrob_query_core::{ - DataValue, MutationResult, Pending, Workspace, - cell_edits::{CellChange, CellEditSet, RowEdit}, + DataValue, DatabaseKind, MutationResult, Pending, Workspace, + cell_edits::{CellChange, CellEditSet, RowEdit, build_row_updates}, }; use uuid::Uuid; use crate::grid::{ResultGrid, format_value}; -use crate::theme; +use crate::i18n::{t, tf}; +use crate::theme::{self, Tokens}; /// Which table a result came from, when the editor could tell: a single-table SELECT, or a /// preview opened from the schema tree. @@ -38,12 +39,13 @@ pub fn editable_key(grid: &ResultGrid) -> Result<(String, usize), &'static str> /// The staged state as the engine's change set. Rows whose key is NULL are refused. pub fn build_edit_set(grid: &ResultGrid, source: &ResultSource) -> Result { - let (key, key_index) = editable_key(grid).map_err(str::to_owned)?; + let (key, key_index) = editable_key(grid).map_err(|e| t(e).to_owned())?; let key_of = |row: usize| -> Result { match grid.page.rows.get(row).and_then(|r| r.get(key_index)) { - Some(DataValue::Null) | None => { - Err(format!("row {} has no primary key value", row + 1)) - } + Some(DataValue::Null) | None => Err(tf( + "row {n} has no primary key value", + &[("n", &(row + 1).to_string())], + )), Some(value) => Ok(value.clone()), } }; @@ -53,7 +55,7 @@ pub fn build_edit_set(grid: &ResultGrid, source: &ResultSource) -> Result Result Result, String> { + let updates = build_row_updates(kind, set).map_err(|e| e.to_string())?; + Ok(updates + .into_iter() + .map(|update| { + let values = update + .parameters + .iter() + .enumerate() + .map(|(i, v)| format!("${} = {}", i + 1, literal(v))) + .collect::>() + .join(", "); + format!("{}; -- {values}", update.statement) + }) + .collect()) +} + +/// A value as it would read in SQL, for the preview only. +fn literal(value: &DataValue) -> String { + match value { + DataValue::Null => "NULL".to_owned(), + DataValue::Boolean(_) + | DataValue::Integer(_) + | DataValue::Float(_) + | DataValue::Decimal(_) => format_value(value), + other => format!("'{}'", format_value(other).replace('\'', "''")), + } +} + #[derive(Default)] pub struct ChangesPanel { apply: Option>, pub last: Option<(String, bool)>, } -/// What the panel asks the app to do after an apply. +/// What the pane asks the app to do after an apply. pub enum ChangesEvent { Applied(MutationResult), Notice(String, bool), @@ -108,9 +141,12 @@ impl ChangesPanel { match result { Ok(outcome) => { self.last = Some(( - format!( - "Applied: {} rows changed in {} ms", - outcome.rows_affected, outcome.elapsed_ms + tf( + "Applied: {rows} rows changed in {ms} ms", + &[ + ("rows", &outcome.rows_affected.to_string()), + ("ms", &outcome.elapsed_ms.to_string()), + ], ), true, )); @@ -130,28 +166,77 @@ impl ChangesPanel { workspace: &Workspace, grid: &mut ResultGrid, source: Option<&ResultSource>, + kind: Option, read_only_profile: bool, ) { - ui.horizontal(|ui| { - ui.heading("Changes"); - let count = grid.staged.len() + grid.deleted.len(); - ui.label(RichText::new(format!("{count} staged")).weak()); - }); + let tokens = Tokens::of(ui); if let Some((text, ok)) = &self.last { - ui.label(RichText::new(text).color(if *ok { theme::SUCCESS } else { theme::DANGER })); + ui.label(RichText::new(text).color(if *ok { tokens.success } else { tokens.danger })); } let Some(source) = source else { - ui.label(RichText::new("Edits need a result from one table.").weak()); + ui.add_space(8.0); + ui.label( + RichText::new(t("Edits need a result from one table.")).color(tokens.text_faint), + ); return; }; + if !grid.has_changes() { + ui.add_space(8.0); + ui.label( + RichText::new(t( + "Double-click a cell to edit it; right-click a row number to delete the row. Nothing is written until Apply.", + )) + .color(tokens.text_faint), + ); + return; + } if read_only_profile { - ui.label(RichText::new("This profile is read-only. Untick \"Read-only\" in its connection form to allow reviewed edits.").color(theme::CAUTION)); + ui.label( + RichText::new(t( + "This connection is read-only. Untick Read-only in its connection form to allow reviewed edits.", + )) + .color(tokens.warning), + ); } let set = build_edit_set(grid, source); + let preview = match (&set, kind) { + (Ok(set), Some(kind)) => Some(sql_preview(kind, set)), + _ => None, + }; + egui::Panel::bottom(ui.id().with("changes-actions")) + .exact_size(theme::STATUS_HEIGHT + 8.0) + .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(0, 4))) + .show(ui, |ui| { + ui.horizontal(|ui| { + let can_apply = + set.is_ok() && grid.has_changes() && !self.busy() && !read_only_profile; + if ui + .add_enabled(can_apply, theme::primary_button(ui, t("Apply"))) + .clicked() + && let Ok(set) = &set + { + self.last = None; + self.apply = + Some(workspace.apply_cell_edits(source.connection_id, set.clone())); + } + if ui + .add_enabled( + grid.has_changes() && !self.busy(), + egui::Button::new(t("Discard")), + ) + .clicked() + { + grid.clear_changes(); + self.last = None; + } + if self.busy() { + ui.spinner(); + } + }); + }); egui::ScrollArea::vertical() .id_salt("changes") - .auto_shrink([false, true]) - .max_height(220.0) + .auto_shrink([false, false]) .show(ui, |ui| { for (&(row, column), value) in &grid.staged { let before = grid @@ -162,64 +247,75 @@ impl ChangesPanel { .map(format_value) .unwrap_or_default(); ui.horizontal(|ui| { - ui.label(RichText::new(format!("row {}", row + 1)).small().weak()); + ui.label( + RichText::new(tf("row {n}", &[("n", &(row + 1).to_string())])) + .small() + .color(tokens.text_faint), + ); ui.label(RichText::new(&grid.page.columns[column].name).monospace()); - ui.label(RichText::new(before).weak().strikethrough()); + ui.label( + RichText::new(before) + .monospace() + .color(tokens.text_faint) + .strikethrough(), + ); ui.label("→"); - ui.label(RichText::new(format_value(value)).color(theme::CAUTION)); + ui.label( + RichText::new(format_value(value)) + .monospace() + .color(tokens.warning), + ); }); } for &row in &grid.deleted { ui.horizontal(|ui| { - ui.label(RichText::new(format!("row {}", row + 1)).small().weak()); - ui.label(RichText::new("delete").color(theme::DANGER)); + ui.label( + RichText::new(tf("row {n}", &[("n", &(row + 1).to_string())])) + .small() + .color(tokens.text_faint), + ); + ui.label(RichText::new(t("delete")).color(tokens.danger)); }); } + ui.add_space(6.0); + match (&set, preview) { + (Ok(set), Some(Ok(statements))) => { + ui.label( + RichText::new(tf( + "SQL the engine will run on {table}, keyed by {key}, in one transaction:", + &[ + ( + "table", + &format!( + "{}{}", + set.schema + .as_deref() + .map(|s| format!("{s}.")) + .unwrap_or_default(), + set.table + ), + ), + ("key", &set.primary_key), + ], + )) + .small() + .color(tokens.text_dim), + ); + for statement in statements { + ui.add( + egui::Label::new(RichText::new(statement).monospace().small()) + .wrap(), + ); + } + } + (Ok(_), Some(Err(reason))) => { + ui.label(RichText::new(reason).small().color(tokens.warning)); + } + (Err(reason), _) => { + ui.label(RichText::new(reason).small().color(tokens.warning)); + } + (Ok(_), None) => {} + } }); - match &set { - Ok(set) => { - ui.label( - RichText::new(format!( - "{} row update(s), {} delete(s) on {}{} keyed by {}", - set.rows.len(), - set.deletes.len(), - set.schema - .as_deref() - .map(|s| format!("{s}.")) - .unwrap_or_default(), - set.table, - set.primary_key - )) - .small(), - ); - } - Err(reason) => { - ui.label(RichText::new(reason).small().color(theme::CAUTION)); - } - } - ui.horizontal(|ui| { - let can_apply = set.is_ok() && grid.has_changes() && !self.busy() && !read_only_profile; - if ui - .add_enabled(can_apply, theme::primary_button("Apply")) - .clicked() - && let Ok(set) = set - { - self.last = None; - self.apply = Some(workspace.apply_cell_edits(source.connection_id, set)); - } - if ui - .add_enabled( - grid.has_changes() && !self.busy(), - egui::Button::new("Discard"), - ) - .clicked() - { - grid.clear_changes(); - self.last = None; - } - if self.busy() { - ui.spinner(); - } - }); } } diff --git a/apps/redrob-query/src/editor.rs b/apps/redrob-query/src/editor.rs index ed25ae1..724b8f3 100644 --- a/apps/redrob-query/src/editor.rs +++ b/apps/redrob-query/src/editor.rs @@ -1,12 +1,13 @@ -//! Query tabs: the editor with syntax colouring, the run / format / save shortcuts, and each tab's -//! result grid with its source table (for the changes and structure panels). +//! Query tabs: the editor with syntax colouring above, the result area below (grid, messages, +//! changes), and each tab's connection, dirty state and file. //! //! Ctrl+Enter runs the selection, else the statement under the cursor (SQL) or the whole text -//! (Mongo JSON); a batch of several read-only statements runs them one by one and shows the last -//! result. Ctrl+Shift+F formats. Ctrl+S saves the tab's text to a file (asking for one the first -//! time). The workspace itself (tabs, history) is saved by the app. +//! (Mongo JSON); F5 runs the whole text, a read-only batch statement by statement; Esc cancels the +//! run in flight. Ctrl+Shift+F formats. Ctrl+S saves the tab's text to a file (asking for one the +//! first time). Errors land in the Messages tab and on the status line, never in a dialog. use std::path::PathBuf; +use std::time::Instant; use egui::{RichText, Ui}; use redrob_query_core::{ @@ -15,11 +16,12 @@ use redrob_query_core::{ }; use uuid::Uuid; -use crate::changes::ResultSource; -use crate::grid::ResultGrid; +use crate::changes::{ChangesPanel, ResultSource}; +use crate::grid::{GridAction, ResultGrid}; use crate::highlight::{self, Dialect}; +use crate::i18n::{t, tf}; use crate::mongo::MongoStrip; -use crate::theme; +use crate::theme::{self, Tokens}; const DEFAULT_LIMIT: usize = 500; @@ -33,18 +35,46 @@ pub struct Finished { pub ok: bool, } +/// Which pane the result area shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ResultView { + #[default] + Grid, + Messages, + Changes, +} + +/// One line of the Messages pane. +#[derive(Clone, Debug)] +pub struct Message { + pub text: String, + pub error: bool, + pub at: Instant, +} + +/// What the result area asked the app for this frame. +pub enum TabEvent { + /// Put this on the status line (an error in the error colour). + Status(String, bool), +} + pub struct QueryTab { pub title: String, pub text: String, + /// The text as last opened or saved: a difference is the dirty dot. + pub saved_text: String, pub connection_id: Option, pub dialect: Dialect, pub result: Option, pub source: Option, - pub error: Option, + pub messages: Vec, + pub view: ResultView, pub mongo: MongoStrip, pub limit: usize, + pub offset: usize, pub file: Option, run: Option>, + run_started: Option, keys: Option>>, /// Statements still to run in a batch, and how many ran. queue: Vec, @@ -53,8 +83,9 @@ pub struct QueryTab { cursor_char: usize, finished: Vec, pub read_only_profile: bool, - pending_insert: Option, + pending_insert: Option<(String, bool)>, pub focus_editor: bool, + events: Vec, } impl QueryTab { @@ -66,16 +97,20 @@ impl QueryTab { } Self { title, + saved_text: text.clone(), text, connection_id: connection.map(|p| p.id), dialect, result: None, source: None, - error: None, + messages: Vec::new(), + view: ResultView::Grid, mongo, limit: DEFAULT_LIMIT, + offset: 0, file: None, run: None, + run_started: None, keys: None, queue: Vec::new(), ran_in_batch: 0, @@ -85,26 +120,46 @@ impl QueryTab { read_only_profile: connection.is_none_or(|p| p.read_only), pending_insert: None, focus_editor: false, + events: Vec::new(), } } pub fn for_connection(profile: &ConnectionProfile, index: usize) -> Self { let text = highlight::starter_query(profile.kind).to_owned(); - Self::new(format!("{} {}", profile.name, index), text, Some(profile)) + Self::new( + tf("Query {n}", &[("n", &index.to_string())]), + text, + Some(profile), + ) } pub fn is_running(&self) -> bool { self.run.is_some() } + pub fn is_dirty(&self) -> bool { + self.text != self.saved_text + } + /// Runs finished since the last call. pub fn take_finished(&mut self) -> Vec { std::mem::take(&mut self.finished) } - /// Appends (or replaces an empty tab with) a draft from the AI dock. + /// What the tab asked for since the last call. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Appends (or replaces an empty tab with) a draft from the AI dock or the history. pub fn insert(&mut self, draft: String) { - self.pending_insert = Some(draft); + self.pending_insert = Some((draft, false)); + } + + /// Inserts `text` at the caret (a name from the schema tree). + pub fn insert_at_cursor(&mut self, text: String) { + self.pending_insert = Some((text, true)); + self.focus_editor = true; } pub fn set_connection(&mut self, profile: &ConnectionProfile) { @@ -113,11 +168,29 @@ impl QueryTab { self.read_only_profile = profile.read_only; } + fn message(&mut self, text: impl Into, error: bool) { + let text = text.into(); + self.messages.push(Message { + text: text.clone(), + error, + at: Instant::now(), + }); + if self.messages.len() > 200 { + let drop = self.messages.len() - 200; + self.messages.drain(..drop); + } + if error { + self.view = ResultView::Messages; + } + self.events.push(TabEvent::Status(text, error)); + } + pub fn poll(&mut self, workspace: &Workspace, profile: Option<&ConnectionProfile>) { if let Some(run) = &mut self.run && let Some(result) = run.poll() { self.run = None; + self.run_started = None; let (rows, elapsed, ok) = match &result { Ok(page) => (page.stats.rows_returned, page.stats.elapsed_ms, true), Err(_) => (0, 0, false), @@ -132,10 +205,28 @@ impl QueryTab { }); match result { Ok(page) => { - self.error = None; - let mut grid = ResultGrid::new(page); + let mut line = tf( + "{rows} rows in {ms} ms", + &[ + ("rows", &page.stats.rows_returned.to_string()), + ("ms", &page.stats.elapsed_ms.to_string()), + ], + ); + if page.stats.rows_affected > 0 { + line.push_str(&tf( + " · {n} affected", + &[("n", &page.stats.rows_affected.to_string())], + )); + } + if page.stats.truncated { + line.push_str(" · "); + line.push_str(t("truncated at the limit")); + } + let mut grid = ResultGrid::new(page, self.offset); grid.json_view = self.dialect == Dialect::MongoJson; self.result = Some(grid); + self.view = ResultView::Grid; + self.message(line, false); self.ran_in_batch += 1; if self.queue.is_empty() { self.resolve_source(workspace, profile); @@ -146,7 +237,7 @@ impl QueryTab { } Err(error) => { self.queue.clear(); - self.error = Some(error.to_string()); + self.message(error.to_string(), true); } } } @@ -180,13 +271,14 @@ impl QueryTab { fn start(&mut self, workspace: &Workspace, connection_id: Uuid, query: String) { self.running_text.clone_from(&query); + self.run_started = Some(Instant::now()); self.run = Some(workspace.execute_query(QueryRequest { connection_id, query, language: self.dialect.language(), parameters: Vec::new(), limit: self.limit, - offset: 0, + offset: self.offset, timeout_ms: 30_000, })); } @@ -194,21 +286,41 @@ impl QueryTab { /// Runs `selection` when given, else the statement under the cursor; a read-only batch runs /// statement by statement. pub fn run_now(&mut self, workspace: &Workspace, selection: Option) { + let text = match selection { + Some(s) if !s.trim().is_empty() => s, + _ => highlight::statement_at(&self.text, self.cursor_char, self.dialect), + }; + self.offset = 0; + self.run_text(workspace, text); + } + + /// F5: the whole text. + pub fn run_all(&mut self, workspace: &Workspace) { + self.offset = 0; + let text = self.text.clone(); + self.run_text(workspace, text); + } + + /// The same statement again at the current offset (the page controls). + fn rerun_page(&mut self, workspace: &Workspace) { + let text = self.running_text.clone(); + if text.trim().is_empty() { + return; + } + self.run_text(workspace, text); + } + + fn run_text(&mut self, workspace: &Workspace, text: String) { let Some(connection_id) = self.connection_id else { - self.error = Some("choose a connection for this tab first".to_owned()); + self.message(t("Choose a connection for this tab first"), true); return; }; if self.run.is_some() { return; } - self.error = None; self.ran_in_batch = 0; - let text = match selection { - Some(s) if !s.trim().is_empty() => s, - _ => highlight::statement_at(&self.text, self.cursor_char, self.dialect), - }; if text.trim().is_empty() { - self.error = Some("nothing to run".to_owned()); + self.message(t("Nothing to run"), true); return; } if self.dialect == Dialect::Sql @@ -224,52 +336,117 @@ impl QueryTab { self.start(workspace, connection_id, text); } + /// Esc: stop waiting for the run in flight. + pub fn cancel(&mut self) -> bool { + let Some(run) = &mut self.run else { + return false; + }; + run.cancel(); + self.run = None; + self.run_started = None; + self.queue.clear(); + self.message(t("Cancelled"), false); + true + } + pub fn format_text(&mut self) { self.text = highlight::format(&self.text, self.dialect); } + /// Ctrl+O's result: the file's text replaces the tab's. + pub fn open_file(&mut self, path: PathBuf) { + match std::fs::read_to_string(&path) { + Ok(text) => { + if let Some(stem) = path.file_stem() { + self.title = stem.to_string_lossy().into_owned(); + } + self.text.clone_from(&text); + self.saved_text = text; + self.file = Some(path); + } + Err(error) => self.message( + tf( + "Could not open {path}: {error}", + &[ + ("path", &path.display().to_string()), + ("error", &error.to_string()), + ], + ), + true, + ), + } + } + /// Ctrl+S: write the tab's text to its file, asking for one the first time. - pub fn save_to_file(&mut self, save_as: bool) -> Option { + pub fn save_to_file(&mut self, save_as: bool) { if save_as || self.file.is_none() { let extension = if self.dialect == Dialect::MongoJson { "json" } else { "sql" }; - let picked = rfd::FileDialog::new() + let Some(picked) = rfd::FileDialog::new() .set_file_name(format!( "{}.{extension}", self.title.replace(' ', "-").to_lowercase() )) - .save_file()?; + .save_file() + else { + return; + }; self.file = Some(picked); } - let path = self.file.clone()?; + let Some(path) = self.file.clone() else { + return; + }; match std::fs::write(&path, &self.text) { Ok(()) => { if let Some(stem) = path.file_stem() { self.title = stem.to_string_lossy().into_owned(); } - Some(format!("Saved {}", path.display())) - } - Err(error) => { - self.error = Some(format!("could not save: {error}")); - None + self.saved_text.clone_from(&self.text); + self.events.push(TabEvent::Status( + tf("Saved {path}", &[("path", &path.display().to_string())]), + false, + )); } + Err(error) => self.message( + tf("Could not save: {error}", &[("error", &error.to_string())]), + true, + ), } } - /// Draws the editor half of the tab. Returns a status-line message when there is one. + /// The classification of the statement under the cursor, for the editor's toolbar. + fn classification(&self) -> Option { + (self.dialect == Dialect::Sql).then(|| { + classify_sql(&highlight::statement_at( + &self.text, + self.cursor_char, + self.dialect, + )) + }) + } + + /// Draws the editor half of the tab. pub fn editor_ui( &mut self, ui: &mut Ui, workspace: &Workspace, profile: Option<&ConnectionProfile>, collections: &[redrob_query_core::MetadataNode], - ) -> Option { - let mut notice = None; - if let Some(draft) = self.pending_insert.take() { - if self.text.trim().is_empty() { + ) { + let tokens = Tokens::of(ui); + if let Some((draft, at_cursor)) = self.pending_insert.take() { + if at_cursor { + let at = self + .text + .char_indices() + .nth(self.cursor_char) + .map_or(self.text.len(), |(i, _)| i); + self.text.insert_str(at, &draft); + self.cursor_char += draft.chars().count(); + } else if self.text.trim().is_empty() { self.text = draft; } else { if !self.text.ends_with('\n') { @@ -284,85 +461,79 @@ impl QueryTab { let running = self.run.is_some(); let can_run = self.connection_id.is_some() && !running; if ui - .add_enabled(can_run, theme::primary_button("▶ Run")) - .on_hover_text("Ctrl+Enter: the selection or the statement under the cursor") + .add_enabled(can_run, theme::primary_button(ui, t("▶ Run"))) + .on_hover_text(t( + "Ctrl+Enter: the selection or the statement under the cursor", + )) .clicked() { self.run_now(workspace, None); } + if ui + .add_enabled(can_run, egui::Button::new(t("Run all"))) + .on_hover_text(t("F5: the whole text")) + .clicked() + { + self.run_all(workspace); + } if running { - ui.spinner(); + if ui.button(t("Cancel")).on_hover_text(t("Esc")).clicked() { + self.cancel(); + } if !self.queue.is_empty() || self.ran_in_batch > 0 { ui.label( - RichText::new(format!( - "statement {} of {}", - self.ran_in_batch + 1, - self.ran_in_batch + 1 + self.queue.len() + RichText::new(tf( + "statement {i} of {n}", + &[ + ("i", &(self.ran_in_batch + 1).to_string()), + ("n", &(self.ran_in_batch + 1 + self.queue.len()).to_string()), + ], )) .small(), ); } } - if ui.button("Format").on_hover_text("Ctrl+Shift+F").clicked() { - self.format_text(); - } if ui - .button("Save") - .on_hover_text("Ctrl+S: save the text to a file") + .button(t("Format")) + .on_hover_text("Ctrl+Shift+F") .clicked() { - notice = self.save_to_file(false); + self.format_text(); } - ui.label(RichText::new("Limit").small()); + ui.label(RichText::new(t("Limit")).small().color(tokens.text_faint)); ui.add( egui::DragValue::new(&mut self.limit) .range(1..=10_000) .speed(10), ); - if let Some(profile) = profile { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let classification = if self.dialect == Dialect::Sql { - Some(classify_sql(&highlight::statement_at( - &self.text, - self.cursor_char, - self.dialect, - ))) - } else { - None - }; - if let Some(c) = classification { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if profile.is_some() { + if let Some(c) = self.classification() { let (text, colour) = match c { - QueryClassification::ReadOnly => ("read-only", theme::SUCCESS), + QueryClassification::ReadOnly => (t("read-only"), tokens.success), QueryClassification::Mutation => { - ("mutation (use the grid to edit)", theme::CAUTION) + (t("mutation (edit through the grid)"), tokens.warning) } QueryClassification::Destructive => { - ("destructive (refused)", theme::DANGER) + (t("destructive (refused)"), tokens.danger) } QueryClassification::TransactionControl => { - ("transaction control (refused)", theme::CAUTION) + (t("transaction control (refused)"), tokens.warning) + } + QueryClassification::Unknown => { + (t("unknown (refused)"), tokens.warning) } - QueryClassification::Unknown => ("unknown (refused)", theme::CAUTION), }; ui.label(RichText::new(text).small().color(colour)); } + } else { ui.label( - RichText::new(format!( - "{} · {}", - profile.name, - crate::profiles::kind_name(profile.kind) - )) - .small() - .weak(), + RichText::new(t("No connection: pick one in the Connection menu")) + .small() + .color(tokens.warning), ); - }); - } else { - ui.label( - RichText::new("no connection: connect a profile, then open a tab from it") - .small() - .color(theme::CAUTION), - ); - } + } + }); }); if self.dialect == Dialect::MongoJson && let Some(template) = self.mongo.ui(ui, collections) @@ -376,16 +547,25 @@ impl QueryTab { let job = highlight::layout(ui, text.as_str(), dialect, wrap); ui.fonts_mut(|f| f.layout_job(job)) }; - let output = egui::TextEdit::multiline(&mut self.text) + let height = ui.available_height(); + let mut output = egui::TextEdit::multiline(&mut self.text) .code_editor() .id_salt("query-editor") .desired_width(f32::INFINITY) - .desired_rows(8) + .desired_rows(6) + .min_size(egui::vec2(0.0, height)) .lock_focus(true) .layouter(&mut layouter) .show(ui); if self.focus_editor { output.response.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), output.response.id) { + let range = + egui::text::CCursorRange::one(egui::text::CCursor::new(self.cursor_char)); + state.cursor.set_char_range(Some(range)); + state.store(ui.ctx(), output.response.id); + output.cursor_range = Some(range); + } self.focus_editor = false; } if let Some(range) = output.cursor_range { @@ -396,14 +576,14 @@ impl QueryTab { .filter(|r| !r.is_empty()) .map(|r| r.slice_str(&self.text).to_owned()); let focused = output.response.has_focus(); - let (run, format, save, save_as) = ui.input_mut(|i| { - let cmd = egui::Modifiers::COMMAND; - ( - focused && i.consume_key(cmd, egui::Key::Enter), - focused && i.consume_key(cmd | egui::Modifiers::SHIFT, egui::Key::F), - i.consume_key(cmd, egui::Key::S), - i.consume_key(cmd | egui::Modifiers::SHIFT, egui::Key::S), - ) + let run = + ui.input_mut(|i| focused && i.consume_key(egui::Modifiers::COMMAND, egui::Key::Enter)); + let format = ui.input_mut(|i| { + focused + && i.consume_key( + egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, + egui::Key::F, + ) }); if run { self.run_now(workspace, selection); @@ -411,18 +591,156 @@ impl QueryTab { if format { self.format_text(); } - if save_as { - notice = self.save_to_file(true); - } else if save { - notice = self.save_to_file(false); - } if self.dialect == Dialect::MongoJson && output.response.changed() { self.mongo.sync_from(&self.text); } - if let Some(error) = &self.error { - ui.label(RichText::new(error).color(theme::DANGER)); + } + + /// The result area: a tab strip (grid, messages, changes with its badge) and the pane. + pub fn result_ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + changes: &mut ChangesPanel, + kind: Option, + ) { + let tokens = Tokens::of(ui); + let staged = self + .result + .as_ref() + .map_or(0, |g| g.staged.len() + g.deleted.len()); + let errors = self.messages.iter().filter(|m| m.error).count(); + egui::Frame::NONE + .fill(tokens.tab_strip) + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (view, label, count, count_colour) in [ + (ResultView::Grid, t("Grid"), 0, tokens.accent), + (ResultView::Messages, t("Messages"), errors, tokens.danger), + (ResultView::Changes, t("Changes"), staged, tokens.warning), + ] { + let selected = self.view == view; + let text = if selected { + RichText::new(label).color(tokens.primary_text).strong() + } else { + RichText::new(label) + }; + if ui.selectable_label(selected, text).clicked() { + self.view = view; + } + if count > 0 { + theme::badge( + ui, + &count.to_string(), + count_colour, + egui::Color32::WHITE, + ); + } + ui.add_space(4.0); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(started) = self.run_started { + ui.spinner(); + ui.label( + RichText::new(tf( + "running… {s} s", + &[("s", &format!("{:.1}", started.elapsed().as_secs_f32()))], + )) + .small() + .color(tokens.text_faint), + ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(100)); + } + }); + }); + }); + match self.view { + ResultView::Grid => match &mut self.result { + Some(grid) => match grid.ui(ui) { + GridAction::None => {} + GridAction::PreviousPage => { + self.offset = self.offset.saturating_sub(self.limit); + self.rerun_page(workspace); + } + GridAction::NextPage => { + self.offset += self.limit; + self.rerun_page(workspace); + } + }, + None => { + ui.add_space(12.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t("Run a statement to see its rows here (Ctrl+Enter).")) + .color(tokens.text_faint), + ); + }); + } + }, + ResultView::Messages => self.messages_ui(ui), + ResultView::Changes => match &mut self.result { + Some(grid) => changes.ui( + ui, + workspace, + grid, + self.source.as_ref(), + kind, + self.read_only_profile, + ), + None => { + ui.add_space(12.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t("Edits need a result from one table.")) + .color(tokens.text_faint), + ); + }); + } + }, } - notice + } + + fn messages_ui(&mut self, ui: &mut Ui) { + let tokens = Tokens::of(ui); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button(t("Clear")).clicked() { + self.messages.clear(); + } + }); + }); + egui::ScrollArea::vertical() + .id_salt("messages") + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + if self.messages.is_empty() { + ui.label(RichText::new(t("No messages yet.")).color(tokens.text_faint)); + } + for message in &self.messages { + let colour = if message.error { + tokens.danger + } else { + tokens.text_dim + }; + let ago = message.at.elapsed().as_secs(); + ui.horizontal(|ui| { + ui.label( + RichText::new(tf("{s} s ago", &[("s", &ago.to_string())])) + .small() + .color(tokens.text_faint), + ); + ui.add( + egui::Label::new( + RichText::new(&message.text).monospace().color(colour), + ) + .wrap(), + ); + }); + } + }); } } diff --git a/apps/redrob-query/src/grid.rs b/apps/redrob-query/src/grid.rs index 8cd7cb9..50eca55 100644 --- a/apps/redrob-query/src/grid.rs +++ b/apps/redrob-query/src/grid.rs @@ -1,9 +1,13 @@ -//! The result grid: one `QueryResultPage` shown as a virtualised table. +//! The result grid: one `QueryResultPage` shown as a virtualised table in monospace. //! //! Sorting and filtering act on the page the engine returned (at most the request's limit), never -//! on the database, so they cost nothing and promise nothing more than what was fetched. Cells are -//! formatted from `DataValue` for display and parsed back on edit; an edit is only staged -- the -//! changes panel turns the staged cells into a `CellEditSet` the engine applies in one transaction. +//! on the database, so they cost nothing and promise nothing more than what was fetched; the page +//! controls at the bottom ask the tab for the next or previous page. Cells are formatted from +//! `DataValue` for display and parsed back on edit; an edit is only staged -- the Changes pane turns +//! the staged cells into a `CellEditSet` the engine applies in one transaction. +//! +//! Click selects a cell (its row is highlighted), Ctrl+C copies the cell, or the selected rows as +//! TSV when several are selected (Ctrl+click adds a row); header click sorts; column edges drag. use std::collections::{BTreeMap, BTreeSet}; @@ -11,7 +15,8 @@ use egui::{RichText, Ui}; use egui_extras::{Column, TableBuilder}; use redrob_query_core::{DataValue, QueryResultPage}; -use crate::theme; +use crate::i18n::{t, tf}; +use crate::theme::{self, Tokens}; const ROW_HEIGHT: f32 = 22.0; const MAX_CELL_CHARS: usize = 200; @@ -22,6 +27,14 @@ pub enum SortDirection { Descending, } +/// What the grid asked the tab for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GridAction { + None, + PreviousPage, + NextPage, +} + enum EditAction { Start(usize, usize), Draft(String), @@ -33,6 +46,8 @@ enum EditAction { pub struct ResultGrid { pub page: QueryResultPage, + /// The request offset this page starts at (the page controls). + pub offset: usize, sort: Option<(usize, SortDirection)>, pub filter: String, hidden: BTreeSet, @@ -47,14 +62,17 @@ pub struct ResultGrid { pub key_columns: Vec, pub editable: bool, pub json_view: bool, - pub selected_row: Option, + /// The clicked cell, and every row selected (Ctrl+click adds). + pub selected_cell: Option<(usize, usize)>, + pub selected_rows: BTreeSet, } impl ResultGrid { - pub fn new(page: QueryResultPage) -> Self { + pub fn new(page: QueryResultPage, offset: usize) -> Self { let rows = page.rows.len(); Self { page, + offset, sort: None, filter: String::new(), hidden: BTreeSet::new(), @@ -66,7 +84,8 @@ impl ResultGrid { key_columns: Vec::new(), editable: false, json_view: false, - selected_row: None, + selected_cell: None, + selected_rows: BTreeSet::new(), } } @@ -112,21 +131,26 @@ impl ResultGrid { .or_else(|| self.page.rows.get(row).and_then(|r| r.get(column))) } - /// The whole page as tab-separated text (header first), hidden columns left out. - pub fn to_tsv(&self) -> String { - let columns: Vec = (0..self.page.columns.len()) + fn visible_columns(&self) -> Vec { + (0..self.page.columns.len()) .filter(|c| !self.hidden.contains(c)) - .collect(); + .collect() + } + + fn rows_tsv(&self, rows: impl Iterator, header: bool) -> String { + let columns = self.visible_columns(); let mut out = String::new(); - out.push_str( - &columns - .iter() - .map(|&c| self.page.columns[c].name.clone()) - .collect::>() - .join("\t"), - ); - out.push('\n'); - for &row in &self.order { + if header { + out.push_str( + &columns + .iter() + .map(|&c| self.page.columns[c].name.clone()) + .collect::>() + .join("\t"), + ); + out.push('\n'); + } + for row in rows { let line = columns .iter() .map(|&c| { @@ -143,6 +167,30 @@ impl ResultGrid { out } + /// The whole page as tab-separated text (header first), hidden columns left out. + pub fn to_tsv(&self) -> String { + self.rows_tsv(self.order.iter().copied(), true) + } + + /// Ctrl+C: the selected rows as TSV when more than one is selected, else the selected cell. + pub fn copy_selection(&self, ctx: &egui::Context) -> bool { + if self.selected_rows.len() > 1 { + let rows: Vec = self + .order + .iter() + .copied() + .filter(|r| self.selected_rows.contains(r)) + .collect(); + ctx.copy_text(self.rows_tsv(rows.into_iter(), false)); + return true; + } + if let Some((row, column)) = self.selected_cell { + ctx.copy_text(self.cell(row, column).map(format_value).unwrap_or_default()); + return true; + } + false + } + /// The page as a JSON array of objects, in display order. pub fn to_json(&self) -> String { let documents: Vec = @@ -163,53 +211,60 @@ impl ResultGrid { serde_json::Value::Object(object) } - pub fn ui(&mut self, ui: &mut Ui) { + pub fn ui(&mut self, ui: &mut Ui) -> GridAction { if self.order_dirty || self.order.len() > self.page.rows.len() { self.rebuild_order(); } self.toolbar(ui); - if self.json_view { - self.json_ui(ui); - } else { - self.table_ui(ui); - } + let mut action = GridAction::None; + egui::Panel::bottom(ui.id().with("grid-footer")) + .exact_size(theme::STATUS_HEIGHT) + .frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(6, 2))) + .show(ui, |ui| { + action = self.footer(ui); + }); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui, |ui| { + if self.json_view { + self.json_ui(ui); + } else { + self.table_ui(ui); + } + }); + action } fn toolbar(&mut self, ui: &mut Ui) { + let tokens = Tokens::of(ui); ui.horizontal(|ui| { - let stats = &self.page.stats; - let mut summary = format!("{} rows · {} ms", stats.rows_returned, stats.elapsed_ms); - if stats.rows_affected > 0 { - summary.push_str(&format!(" · {} affected", stats.rows_affected)); - } - ui.label(RichText::new(summary).small()); - if stats.truncated { - ui.label( - RichText::new("truncated at the limit") - .small() - .color(theme::CAUTION), - ); - } if self.editable { - ui.label(RichText::new("editable").small().color(theme::SUCCESS)) - .on_hover_text(format!("Primary key: {}", self.key_columns.join(", "))); + ui.label(RichText::new(t("editable")).small().color(tokens.success)) + .on_hover_text(tf( + "Primary key: {key}", + &[("key", &self.key_columns.join(", "))], + )); } else { - ui.label(RichText::new("read-only").small().weak()) - .on_hover_text( - "Edits need one table with a primary key and a writable profile", - ); + ui.label( + RichText::new(t("read-only")) + .small() + .color(tokens.text_faint), + ) + .on_hover_text(t( + "Edits need one table with a primary key and a writable connection", + )); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.menu_button("Export", |ui| { - if ui.button("Copy as TSV").clicked() { + ui.menu_button(t("Export"), |ui| { + if ui.button(t("Copy as TSV")).clicked() { ui.ctx().copy_text(self.to_tsv()); ui.close(); } - if ui.button("Copy as JSON").clicked() { + if ui.button(t("Copy as JSON")).clicked() { ui.ctx().copy_text(self.to_json()); ui.close(); } - if ui.button("Save TSV…").clicked() { + if ui.button(t("Save TSV…")).clicked() { if let Some(path) = rfd::FileDialog::new() .set_file_name("result.tsv") .save_file() @@ -218,7 +273,7 @@ impl ResultGrid { } ui.close(); } - if ui.button("Save JSON…").clicked() { + if ui.button(t("Save JSON…")).clicked() { if let Some(path) = rfd::FileDialog::new() .set_file_name("result.json") .save_file() @@ -228,7 +283,7 @@ impl ResultGrid { ui.close(); } }); - ui.menu_button("Columns", |ui| { + ui.menu_button(t("Columns"), |ui| { for (index, column) in self.page.columns.iter().enumerate() { let mut shown = !self.hidden.contains(&index); if ui.checkbox(&mut shown, &column.name).changed() { @@ -241,16 +296,16 @@ impl ResultGrid { } if !self.hidden.is_empty() { ui.separator(); - if ui.button("Show all").clicked() { + if ui.button(t("Show all")).clicked() { self.hidden.clear(); } } }); ui.selectable_value(&mut self.json_view, true, "JSON"); - ui.selectable_value(&mut self.json_view, false, "Grid"); + ui.selectable_value(&mut self.json_view, false, t("Table")); let response = ui.add( egui::TextEdit::singleline(&mut self.filter) - .hint_text("Filter rows") + .hint_text(t("Filter rows")) .desired_width(160.0), ); if response.changed() { @@ -260,6 +315,64 @@ impl ResultGrid { }); } + /// Row count and the page controls. + fn footer(&mut self, ui: &mut Ui) -> GridAction { + let tokens = Tokens::of(ui); + let mut action = GridAction::None; + ui.horizontal(|ui| { + let fetched = self.page.rows.len(); + let first = if fetched == 0 { 0 } else { self.offset + 1 }; + let last = self.offset + fetched; + let mut summary = tf( + "rows {first}–{last}", + &[("first", &first.to_string()), ("last", &last.to_string())], + ); + if self.order.len() != fetched { + summary.push_str(&tf(" · {n} shown", &[("n", &self.order.len().to_string())])); + } + summary.push_str(&tf( + " · {ms} ms", + &[("ms", &self.page.stats.elapsed_ms.to_string())], + )); + ui.label(RichText::new(summary).small().color(tokens.text_dim)); + if self.page.stats.truncated { + ui.label( + RichText::new(t("more rows available")) + .small() + .color(tokens.warning), + ); + } + if !self.selected_rows.is_empty() { + ui.label( + RichText::new(tf( + "{n} selected", + &[("n", &self.selected_rows.len().to_string())], + )) + .small() + .color(tokens.text_faint), + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has_next = self.page.next_offset.is_some() || self.page.stats.truncated; + if ui + .add_enabled(has_next, egui::Button::new("▶").small()) + .on_hover_text(t("Next page")) + .clicked() + { + action = GridAction::NextPage; + } + if ui + .add_enabled(self.offset > 0, egui::Button::new("◀").small()) + .on_hover_text(t("Previous page")) + .clicked() + { + action = GridAction::PreviousPage; + } + }); + }); + action + } + fn json_ui(&mut self, ui: &mut Ui) { egui::ScrollArea::both() .id_salt("json-view") @@ -287,21 +400,21 @@ impl ResultGrid { } fn table_ui(&mut self, ui: &mut Ui) { - let columns: Vec = (0..self.page.columns.len()) - .filter(|c| !self.hidden.contains(c)) - .collect(); + let tokens = Tokens::of(ui); + let columns = self.visible_columns(); if columns.is_empty() { - ui.label(RichText::new("No columns to show.").weak()); + ui.label(RichText::new(t("No columns to show.")).color(tokens.text_faint)); return; } let order = self.order.clone(); let mut sort_click: Option = None; let mut edit: Option = None; let mut toggle_delete: Option = None; - let mut select_row: Option = None; + let mut select: Option<(usize, usize, bool)> = None; let editing = self.editing.clone(); let editable = self.editable; - let text_height = ROW_HEIGHT; + let ctrl = ui.input(|i| i.modifiers.command); + let mono = egui::TextStyle::Monospace.resolve(ui.style()); let mut builder = TableBuilder::new(ui) .striped(true) @@ -315,7 +428,7 @@ impl ResultGrid { builder .header(ROW_HEIGHT + 2.0, |mut header| { header.col(|ui| { - ui.label(RichText::new("#").small().weak()); + ui.label(RichText::new("#").small().color(tokens.text_faint)); }); for &c in &columns { header.col(|ui| { @@ -325,14 +438,19 @@ impl ResultGrid { Some((s, SortDirection::Descending)) if s == c => " ▼", _ => "", }; - let label = RichText::new(format!("{}{arrow}", column.name)).strong(); + let label = RichText::new(format!("{}{arrow}", column.name)) + .monospace() + .strong(); let response = ui .add( egui::Label::new(label) .sense(egui::Sense::click()) .truncate(), ) - .on_hover_text(format!("{} · click to sort", column.data_type)); + .on_hover_text(tf( + "{type} · click to sort", + &[("type", &column.data_type)], + )); if response.clicked() { sort_click = Some(c); } @@ -340,32 +458,46 @@ impl ResultGrid { } }) .body(|body| { - body.rows(text_height, order.len(), |mut row| { + body.rows(ROW_HEIGHT, order.len(), |mut row| { let index = row.index(); let page_row = order[index]; let deleted = self.deleted.contains(&page_row); - row.set_selected(self.selected_row == Some(page_row)); + row.set_selected( + self.selected_rows.contains(&page_row) + || self.selected_cell.is_some_and(|(r, _)| r == page_row), + ); row.col(|ui| { - let text = RichText::new((page_row + 1).to_string()).small().weak(); + let text = RichText::new((self.offset + page_row + 1).to_string()) + .small() + .color(tokens.text_faint); let text = if deleted { - text.strikethrough().color(theme::DANGER) + text.strikethrough().color(tokens.danger) } else { text }; let response = ui.add(egui::Label::new(text).sense(egui::Sense::click())); if response.clicked() { - select_row = Some(page_row); + select = Some((page_row, 0, ctrl)); } response.context_menu(|ui| { - if ui.button("Copy row as JSON").clicked() { + if ui.button(t("Copy row as JSON")).clicked() { ui.ctx().copy_text( serde_json::to_string_pretty(&self.row_json(page_row)) .unwrap_or_default(), ); ui.close(); } + if ui.button(t("Copy row as TSV")).clicked() { + ui.ctx() + .copy_text(self.rows_tsv(std::iter::once(page_row), false)); + ui.close(); + } if editable { - let label = if deleted { "Undo delete" } else { "Delete row" }; + let label = if deleted { + t("Undo delete") + } else { + t("Delete row") + }; if ui.button(label).clicked() { toggle_delete = Some(page_row); ui.close(); @@ -402,49 +534,70 @@ impl ResultGrid { } let value = self.cell(page_row, c); let staged = self.staged.contains_key(&(page_row, c)); + let is_null = matches!(value, Some(DataValue::Null) | None); let shown = value.map_or_else(String::new, format_cell); - let mut text = RichText::new(shown).monospace(); + let mut text = RichText::new(&shown).monospace(); if deleted { - text = text.strikethrough().color(theme::DANGER); + text = text.strikethrough().color(tokens.danger); } else if staged { - text = text.color(theme::CAUTION); - } else if matches!(value, Some(DataValue::Null) | None) { - text = text.weak().italics(); + text = text.color(tokens.warning); + } else if is_null { + text = text.color(tokens.text_faint).italics(); + } + let cell_selected = self.selected_cell == Some((page_row, c)); + if cell_selected { + ui.painter().rect_stroke( + ui.max_rect(), + 0.0, + egui::Stroke::new(1.0, tokens.accent), + egui::StrokeKind::Inside, + ); } + let width = ui.available_width(); let response = ui.add( egui::Label::new(text) .sense(egui::Sense::click()) .truncate(), ); if response.clicked() { - select_row = Some(page_row); + select = Some((page_row, c, ctrl)); } if response.double_clicked() && editable && !deleted { edit = Some(EditAction::Start(page_row, c)); } if let Some(value) = value { + // The full value when the cell could not show all of it. + let needed = ui.fonts_mut(|f| { + f.layout_no_wrap(shown.clone(), mono.clone(), tokens.text) + .size() + .x + }); let full = format_value(value); - if full.chars().count() > MAX_CELL_CHARS || full.contains('\n') { - response - .clone() - .on_hover_text(full.chars().take(2000).collect::()); + if needed > width + || full.chars().count() > MAX_CELL_CHARS + || full.contains('\n') + { + response.clone().on_hover_text( + RichText::new(full.chars().take(2000).collect::()) + .monospace(), + ); } } response.context_menu(|ui| { - if ui.button("Copy value").clicked() { + if ui.button(t("Copy value")).clicked() { ui.ctx() .copy_text(value.map(format_value).unwrap_or_default()); ui.close(); } - if editable && !deleted && ui.button("Edit cell").clicked() { + if editable && !deleted && ui.button(t("Edit cell")).clicked() { edit = Some(EditAction::Start(page_row, c)); ui.close(); } - if editable && !deleted && ui.button("Set NULL").clicked() { + if editable && !deleted && ui.button(t("Set NULL")).clicked() { edit = Some(EditAction::SetNull(page_row, c)); ui.close(); } - if staged && ui.button("Revert cell").clicked() { + if staged && ui.button(t("Revert cell")).clicked() { edit = Some(EditAction::Revert(page_row, c)); ui.close(); } @@ -454,8 +607,16 @@ impl ResultGrid { }); }); - if let Some(row) = select_row { - self.selected_row = Some(row); + if let Some((row, column, add)) = select { + self.selected_cell = Some((row, column)); + if add { + if !self.selected_rows.remove(&row) { + self.selected_rows.insert(row); + } + } else { + self.selected_rows.clear(); + self.selected_rows.insert(row); + } } if let Some(c) = sort_click { self.sort = match self.sort { diff --git a/apps/redrob-query/src/i18n.rs b/apps/redrob-query/src/i18n.rs new file mode 100644 index 0000000..9c33da8 --- /dev/null +++ b/apps/redrob-query/src/i18n.rs @@ -0,0 +1,579 @@ +//! One interface language for the whole app, the way Redrob Canvas does it. +//! +//! On first run the language is the system's (`sys_locale`: `GetUserPreferredUILanguages` on +//! Windows, `CFLocaleCopyPreferredLanguages` on macOS, `LANGUAGE` / `LC_ALL` / `LC_MESSAGES` / +//! `LANG` elsewhere; `REDROB_QUERY_LOCALE` overrides all of that). Help > Language sets an override +//! persisted in `/language.json` (`{ "language": "auto" | "" }`). Every string +//! the shell draws goes through [`t`] / [`tf`]: English is the source, Korean the one catalog. + +use std::cell::Cell; +use std::path::PathBuf; + +use crate::services; + +/// `REDROB_QUERY_LOCALE=ko-KR`: the system language, for tests and screenshots. +pub const LOCALE_ENV: &str = "REDROB_QUERY_LOCALE"; +/// The preference file under the config directory. +pub const PREF_FILE: &str = "language.json"; +/// The preference value that follows the system. +pub const AUTO: &str = "auto"; + +/// A language the shell has strings for. +#[derive(Debug, PartialEq, Eq)] +pub struct LangInfo { + pub code: &'static str, + /// The name in that language, for the menu. + pub name: &'static str, +} + +pub const LANGUAGES: &[LangInfo] = &[ + LangInfo { + code: "en", + name: "English", + }, + LangInfo { + code: "ko", + name: "한국어", + }, +]; + +/// A language by code (case-insensitive). +pub fn by_code(code: &str) -> Option<&'static LangInfo> { + LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)) +} + +pub fn english() -> &'static LangInfo { + &LANGUAGES[0] +} + +/// The language for a locale tag such as `ko_KR.UTF-8`, `ko-KR` or `en-US`: the primary subtag +/// decides. `None` for a language without a catalog and for `C` / `POSIX`, which state no +/// preference (so the next tag decides). +pub fn from_tag(tag: &str) -> Option<&'static LangInfo> { + let base = tag + .split(['.', '@']) + .next() + .unwrap_or("") + .trim() + .replace('_', "-") + .to_ascii_lowercase(); + let primary = base.split('-').find(|p| !p.is_empty())?; + if matches!(primary, "c" | "posix") { + return None; + } + by_code(primary) +} + +/// The first supported language among `tags` (most preferred first); English when none is. +pub fn from_tags>(tags: &[S]) -> &'static LangInfo { + tags.iter() + .find_map(|t| from_tag(t.as_ref())) + .unwrap_or_else(english) +} + +/// The system's language: `REDROB_QUERY_LOCALE`, else the OS preferred-languages list. +pub fn system() -> &'static LangInfo { + if let Ok(tag) = std::env::var(LOCALE_ENV) + && !tag.trim().is_empty() + { + return from_tags(&[tag]); + } + let tags: Vec = + std::panic::catch_unwind(|| sys_locale::get_locales().take(64).collect()) + .unwrap_or_default(); + from_tags(&tags) +} + +fn pref_path() -> Option { + services::config_dir() + .filter(|_| services::prefs_enabled()) + .map(|d| d.join(PREF_FILE)) +} + +/// The `language` value of `language.json`: a code or `auto`; `auto` for anything unreadable. +pub fn parse_pref(text: &str) -> String { + serde_json::from_str::(text) + .ok() + .and_then(|v| { + v.get("language") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .filter(|p| p.eq_ignore_ascii_case(AUTO) || by_code(p).is_some()) + .unwrap_or_else(|| AUTO.to_owned()) +} + +fn load_pref() -> String { + pref_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map_or_else(|| AUTO.to_owned(), |t| parse_pref(&t)) +} + +fn save_pref(pref: &str) { + let Some(path) = pref_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let text = serde_json::json!({ "language": pref }).to_string() + "\n"; + if let Err(e) = std::fs::write(&path, text) { + log::warn!("couldn't save {}: {e}", path.display()); + } +} + +/// The language setting: the persisted preference and the system language it resolves against. +#[derive(Debug)] +pub struct Language { + pref: String, + system: &'static LangInfo, +} + +impl Language { + pub fn load() -> Self { + Self { + pref: load_pref(), + system: system(), + } + } + + /// The language the app is drawn in. + pub fn effective(&self) -> &'static LangInfo { + if self.pref.eq_ignore_ascii_case(AUTO) { + self.system + } else { + by_code(&self.pref).unwrap_or(self.system) + } + } + + pub fn is_auto(&self) -> bool { + self.pref.eq_ignore_ascii_case(AUTO) + } + + pub fn system(&self) -> &'static LangInfo { + self.system + } + + /// Set and persist the preference (`auto` or a code). + pub fn set(&mut self, pref: &str) { + self.pref = if pref.eq_ignore_ascii_case(AUTO) || by_code(pref).is_some() { + pref.to_ascii_lowercase() + } else { + AUTO.to_owned() + }; + save_pref(&self.pref); + set_current(self.effective()); + } +} + +thread_local! { + static CURRENT: Cell<&'static LangInfo> = const { Cell::new(&LANGUAGES[0]) }; +} + +/// Set the language the shell's strings are drawn in (at start and on a change). +pub fn set_current(lang: &'static LangInfo) { + CURRENT.set(lang); +} + +pub fn current() -> &'static LangInfo { + CURRENT.get() +} + +/// Translate an English source string into the current language; an unknown string comes back +/// unchanged. +pub fn t(s: &str) -> &str { + tr(current().code, s) +} + +/// [`t`] for an explicit language code. +pub fn tr<'a>(code: &str, s: &'a str) -> &'a str { + match code { + "ko" => KO.iter().find(|(en, _)| *en == s).map_or(s, |(_, ko)| ko), + _ => s, + } +} + +/// `{name}` placeholders in a translated template. +pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { + let mut out = template.to_owned(); + for (name, value) in args { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +/// [`t`] then [`fmt`]. +pub fn tf(s: &str, args: &[(&str, &str)]) -> String { + fmt(t(s), args) +} + +/// The Korean catalog (English is the source). +const KO: &[(&str, &str)] = &[ + ("Ask", "Ask"), + ("Clear", "지우기"), + ("Open Redrob Launcher", "Redrob Launcher 열기"), + ( + "{name} · via Redrob Launcher", + "{name} · Redrob Launcher 계정", + ), + ("{name} · this app's own key", "{name} · 이 앱의 자체 키"), + ( + "Describe the query you want. The active tab's text is sent along as context; result rows never are.", + "원하는 쿼리를 말로 설명하세요. 현재 탭의 텍스트는 맥락으로 함께 보내지만 결과 행은 절대 보내지 않습니다.", + ), + ("You", "나"), + ("Thinking…", "생각 중…"), + ( + "e.g. customers who ordered twice this month, newest first", + "예: 이번 달 두 번 주문한 고객, 최신순", + ), + ("Open a query tab first", "먼저 쿼리 탭을 여세요"), + ("Ask (Ctrl+Enter)", "질문 (Ctrl+Enter)"), + ("Insert into editor", "편집기에 넣기"), + ("Copy", "복사"), + ( + "Not signed in. Sign in with Redrob Launcher and this app will use that account.", + "로그인되지 않았습니다. Redrob Launcher에서 로그인하면 이 앱이 그 계정을 사용합니다.", + ), + ( + "At most {n} tabs are kept; close one first.", + "탭은 최대 {n}개까지 둘 수 있습니다. 먼저 하나를 닫으세요.", + ), + ("Query {n}", "쿼리 {n}"), + ( + "Select or connect a connection first", + "먼저 연결을 선택하거나 연결하세요", + ), + ("Connected to {name}", "{name}에 연결됨"), + ("Table change applied", "테이블 변경을 적용했습니다"), + ( + "Applied {rows} row change(s) in {ms} ms", + "행 변경 {rows}건을 {ms} ms에 적용했습니다", + ), + ("Signed in: {name}", "로그인: {name}"), + ("Signed out of Redrob", "Redrob에서 로그아웃됨"), + ( + "Open a query tab to insert the draft into", + "초안을 넣을 쿼리 탭을 여세요", + ), + ( + "Open a query tab first (Ctrl+T)", + "먼저 쿼리 탭을 여세요 (Ctrl+T)", + ), + ("Copied", "복사했습니다"), + ("File", "파일"), + ("Connection", "연결"), + ("Query", "쿼리"), + ("View", "보기"), + ("Help", "도움말"), + ("New tab", "새 탭"), + ("Close tab", "탭 닫기"), + ("Open…", "열기…"), + ("Save", "저장"), + ("Save as…", "다른 이름으로 저장…"), + ("Quit", "종료"), + ("New connection…", "새 연결…"), + ("Connect {name}", "{name} 연결"), + ("Disconnect {name}", "{name} 연결 해제"), + ("Reload schema", "스키마 다시 읽기"), + ("Edit…", "편집…"), + ("Remove…", "삭제…"), + ("Run", "실행"), + ("Run all", "전체 실행"), + ("Cancel", "취소"), + ("Format", "정리"), + ("Clear result", "결과 지우기"), + ("Structure panel", "구조 패널"), + ("History panel", "기록 패널"), + ("Ask dock", "Ask 도크"), + ("Shortcuts", "단축키"), + ("Theme", "테마"), + ("Language", "언어"), + ("System", "시스템"), + ("Light", "밝게"), + ("Dark", "어둡게"), + ("Report an issue…", "문제 신고…"), + ("About {app}", "{app} 정보"), + ("System ({name})", "시스템 ({name})"), + ( + "Follow the operating system's language", + "운영 체제의 언어를 따릅니다", + ), + ("{what} (Ctrl+Shift+A)", "{what} (Ctrl+Shift+A)"), + ("Show or hide the Ask dock", "Ask 도크 표시/숨기기"), + ( + "Signed in with Redrob Launcher", + "Redrob Launcher로 로그인됨", + ), + ("Connect Redrob", "Redrob 연결"), + ( + "Sign in with Redrob Launcher to use Ask", + "Ask를 쓰려면 Redrob Launcher에서 로그인하세요", + ), + ("read-only", "읽기 전용"), + ("read-write", "읽기·쓰기"), + ("connected", "연결됨"), + ("not connected", "연결 안 됨"), + ("Running… Esc cancels", "실행 중… Esc로 취소"), + ("Ready", "준비"), + ( + "{connected} connected · {tabs} tabs", + "연결 {connected} · 탭 {tabs}", + ), + ("no connection", "연결 없음"), + ("{what} (Ctrl+T)", "{what} (Ctrl+T)"), + ( + "New query tab on the active connection", + "현재 연결에 새 쿼리 탭", + ), + ("History", "기록"), + ( + "Click to insert into the editor; double-click opens a tab.", + "클릭하면 편집기에 넣고, 두 번 클릭하면 탭으로 엽니다.", + ), + ("Nothing has run yet.", "아직 실행한 것이 없습니다."), + ( + "{name} · {rows} rows · {ms} ms", + "{name} · {rows}행 · {ms} ms", + ), + ( + "That connection is not available any more", + "그 연결은 더 이상 없습니다", + ), + ("Connect a database", "데이터베이스에 연결"), + ( + "Add a PostgreSQL, MySQL, SQLite or MongoDB connection, then query it in tabs.", + "PostgreSQL, MySQL, SQLite, MongoDB 연결을 추가하고 탭에서 쿼리하세요.", + ), + ("New connection", "새 연결"), + ("Try the {name} database", "{name} 데이터베이스로 시작"), + ("Version {version}", "버전 {version}"), + ( + "GPL-3.0-or-later. Third-party notices: NOTICE and UPSTREAM_NOTICES.md.", + "GPL-3.0-or-later. 서드파티 고지: NOTICE 및 UPSTREAM_NOTICES.md.", + ), + ( + "row {n} has no primary key value", + "{n}행에 기본 키 값이 없습니다", + ), + ( + "the primary key column itself cannot be edited", + "기본 키 열 자체는 편집할 수 없습니다", + ), + ( + "the table has no primary key", + "테이블에 기본 키가 없습니다", + ), + ( + "select the primary key column to edit", + "편집하려면 기본 키 열을 함께 선택하세요", + ), + ( + "composite primary keys cannot be edited here yet", + "복합 기본 키는 아직 여기서 편집할 수 없습니다", + ), + ( + "Applied: {rows} rows changed in {ms} ms", + "적용: {rows}행을 {ms} ms에 변경", + ), + ( + "Edits need a result from one table.", + "편집은 한 테이블의 결과에서만 가능합니다.", + ), + ( + "Double-click a cell to edit it; right-click a row number to delete the row. Nothing is written until Apply.", + "셀을 두 번 클릭해 편집하고, 행 번호를 오른쪽 클릭해 행을 삭제합니다. 적용을 누르기 전에는 아무것도 쓰지 않습니다.", + ), + ( + "This connection is read-only. Untick Read-only in its connection form to allow reviewed edits.", + "이 연결은 읽기 전용입니다. 연결 양식에서 읽기 전용을 해제하면 검토 편집을 할 수 있습니다.", + ), + ("Apply", "적용"), + ("Discard", "버리기"), + ("row {n}", "{n}행"), + ("delete", "삭제"), + ( + "SQL the engine will run on {table}, keyed by {key}, in one transaction:", + "엔진이 {table}에 {key} 키로 한 트랜잭션으로 실행할 SQL:", + ), + ("{rows} rows in {ms} ms", "{rows}행 · {ms} ms"), + (" · {n} affected", " · {n}행 영향"), + ("truncated at the limit", "제한에서 잘림"), + ( + "Choose a connection for this tab first", + "먼저 이 탭의 연결을 선택하세요", + ), + ("Nothing to run", "실행할 것이 없습니다"), + ("Cancelled", "취소했습니다"), + ( + "Could not open {path}: {error}", + "{path}을(를) 열 수 없습니다: {error}", + ), + ("Saved {path}", "{path} 저장함"), + ("Could not save: {error}", "저장할 수 없습니다: {error}"), + ("▶ Run", "▶ 실행"), + ( + "Ctrl+Enter: the selection or the statement under the cursor", + "Ctrl+Enter: 선택 영역 또는 커서가 있는 문장", + ), + ("F5: the whole text", "F5: 전체 텍스트"), + ("Esc", "Esc"), + ("statement {i} of {n}", "문장 {i}/{n}"), + ("Limit", "제한"), + ( + "mutation (edit through the grid)", + "변경 문장 (그리드에서 편집하세요)", + ), + ("destructive (refused)", "파괴적 (거부됨)"), + ("transaction control (refused)", "트랜잭션 제어 (거부됨)"), + ("unknown (refused)", "알 수 없음 (거부됨)"), + ( + "No connection: pick one in the Connection menu", + "연결 없음: 연결 메뉴에서 선택하세요", + ), + ("Grid", "그리드"), + ("Messages", "메시지"), + ("Changes", "변경"), + ("running… {s} s", "실행 중… {s}초"), + ( + "Run a statement to see its rows here (Ctrl+Enter).", + "문장을 실행하면 여기에 행이 보입니다 (Ctrl+Enter).", + ), + ("No messages yet.", "메시지가 없습니다."), + ("{s} s ago", "{s}초 전"), + ("editable", "편집 가능"), + ("Primary key: {key}", "기본 키: {key}"), + ( + "Edits need one table with a primary key and a writable connection", + "편집에는 기본 키가 있는 한 테이블과 쓰기 가능한 연결이 필요합니다", + ), + ("Export", "내보내기"), + ("Copy as TSV", "TSV로 복사"), + ("Copy as JSON", "JSON으로 복사"), + ("Save TSV…", "TSV 저장…"), + ("Save JSON…", "JSON 저장…"), + ("Columns", "열"), + ("Show all", "모두 표시"), + ("Table", "표"), + ("Filter rows", "행 필터"), + ("rows {first}–{last}", "{first}–{last}행"), + (" · {n} shown", " · {n}행 표시"), + (" · {ms} ms", " · {ms} ms"), + ("more rows available", "더 많은 행이 있습니다"), + ("{n} selected", "{n}행 선택"), + ("Next page", "다음 페이지"), + ("Previous page", "이전 페이지"), + ("No columns to show.", "표시할 열이 없습니다."), + ("{type} · click to sort", "{type} · 클릭해 정렬"), + ("Copy row as JSON", "행을 JSON으로 복사"), + ("Copy row as TSV", "행을 TSV로 복사"), + ("Undo delete", "삭제 취소"), + ("Delete row", "행 삭제"), + ("Copy value", "값 복사"), + ("Edit cell", "셀 편집"), + ("Set NULL", "NULL로 설정"), + ("Revert cell", "셀 되돌리기"), + ("Connections", "연결"), + ("Reload the connection list", "연결 목록 다시 읽기"), + ("Filter", "필터"), + ("Built-in", "내장"), + ("Your connections", "내 연결"), + ( + "None yet. Press + to add one.", + "아직 없습니다. +를 눌러 추가하세요.", + ), + ("Remove connection", "연결 삭제"), + ( + "Remove \"{name}\" and its saved password? The database itself is not touched.", + "\"{name}\"과(와) 저장된 비밀번호를 삭제할까요? 데이터베이스 자체는 건드리지 않습니다.", + ), + ("Remove", "삭제"), + ("New query tab", "새 쿼리 탭"), + ("Disconnect", "연결 해제"), + ("Connect", "연결"), + ("Saved connection \"{name}\"", "연결 \"{name}\" 저장함"), + ("Edit connection", "연결 편집"), + ("Name", "이름"), + ("Database", "데이터베이스"), + ("Browse…", "찾아보기…"), + ("Host", "호스트"), + ("Port", "포트"), + ("User", "사용자"), + ("Password", "비밀번호"), + ("unchanged unless typed", "입력하지 않으면 그대로"), + ("Options", "옵션"), + ("Edits", "편집"), + ( + "Read-only (untick to allow reviewed edits)", + "읽기 전용 (해제하면 검토 편집 가능)", + ), + ("Test", "테스트"), + ( + "Connect once with these settings and report", + "이 설정으로 한 번 연결해 결과를 보여줍니다", + ), + ("DDL {table}", "DDL {table}"), + ("Schema", "스키마"), + ( + "Select a connection to browse its tables.", + "연결을 선택하면 테이블을 볼 수 있습니다.", + ), + ( + "{name} is not connected. Double-click it to connect.", + "{name}은(는) 연결되지 않았습니다. 두 번 클릭해 연결하세요.", + ), + ("Filter tables", "테이블 필터"), + ("Reload", "다시 읽기"), + ("Loading…", "읽는 중…"), + ("No tables or collections.", "테이블이나 컬렉션이 없습니다."), + ("Select top 100", "상위 100행 조회"), + ("Copy name", "이름 복사"), + ("Insert name", "이름 넣기"), + ("(default)", "(기본)"), + ("Double-click to insert", "두 번 클릭하면 넣습니다"), + ( + "Rebuilt from the column metadata; constraints and defaults are not included.", + "열 메타데이터로 재구성했습니다. 제약과 기본값은 들어 있지 않습니다.", + ), + ("Structure", "구조"), + ("+ Column", "+ 열"), + ("Add a column to this table", "이 테이블에 열 추가"), + ( + "Select a table in the schema tree.", + "스키마 트리에서 테이블을 선택하세요.", + ), + ("No columns reported.", "열 정보가 없습니다."), + ("Foreign keys", "외래 키"), + ("Indexes", "인덱스"), + ("Create table", "테이블 만들기"), + ("Add column", "열 추가"), + ("Column", "열"), + ("Type", "형식"), + ("Nullable", "NULL 허용"), + ("Primary", "기본 키"), + ("+ column", "+ 열"), + ( + "This connection is read-only; the change will be refused.", + "이 연결은 읽기 전용이라 변경이 거부됩니다.", + ), + ( + "Run the selection or the statement under the cursor", + "선택 영역 또는 커서가 있는 문장 실행", + ), + ("Run the whole text", "전체 텍스트 실행"), + ("Cancel the run in flight", "실행 중인 쿼리 취소"), + ("Close the tab", "탭 닫기"), + ("Go to tab 1 … 9", "1 … 9번 탭으로"), + ("Format the query", "쿼리 정리"), + ("Open a .sql file into the tab", "탭에 .sql 파일 열기"), + ( + "Save the tab's text / save as", + "탭 텍스트 저장 / 다른 이름으로 저장", + ), + ( + "Copy the selected cell, or the selected rows as TSV", + "선택한 셀 복사, 여러 행이면 TSV로 복사", + ), + ( + "Schema tree: insert the name at the caret", + "스키마 트리: 커서 위치에 이름 넣기", + ), +]; diff --git a/apps/redrob-query/src/main.rs b/apps/redrob-query/src/main.rs index 4fa10d9..345d6e1 100644 --- a/apps/redrob-query/src/main.rs +++ b/apps/redrob-query/src/main.rs @@ -21,6 +21,7 @@ mod changes; mod editor; mod grid; mod highlight; +mod i18n; mod mongo; mod profiles; mod schema; diff --git a/apps/redrob-query/src/profiles.rs b/apps/redrob-query/src/profiles.rs index 53de218..cfddc63 100644 --- a/apps/redrob-query/src/profiles.rs +++ b/apps/redrob-query/src/profiles.rs @@ -1,8 +1,9 @@ -//! The Profiles tree and the connection form. +//! The Connections tree and the connection form. //! //! Built-in profiles (the Demo `SQLite` database) are immutable; user profiles can be added, edited, -//! tested and removed. Passwords go to the OS keyring through the engine and are never kept here -//! beyond the form's own text field, which is cleared when the form closes. +//! tested and removed. Profiles are grouped by database kind, each row has a connect-state dot, +//! and a filter box narrows the tree. Passwords go to the OS keyring through the engine and are +//! never kept here beyond the form's own text field, which is cleared when the form closes. use std::collections::HashMap; @@ -13,7 +14,8 @@ use redrob_query_core::{ }; use uuid::Uuid; -use crate::theme; +use crate::i18n::{t, tf}; +use crate::theme::{self, Tokens}; /// What the panel asks the app to do. #[derive(Debug)] @@ -31,6 +33,7 @@ pub struct ProfilesPanel { pub profiles: Vec, pub status: HashMap, pub selected: Option, + pub filter: String, list: Option>>, connects: HashMap>, disconnects: HashMap>, @@ -46,6 +49,7 @@ impl ProfilesPanel { profiles: Vec::new(), status: HashMap::new(), selected: None, + filter: String::new(), list: Some(workspace.list_profiles()), connects: HashMap::new(), disconnects: HashMap::new(), @@ -70,12 +74,41 @@ impl ProfilesPanel { .is_some_and(|s| s.state == ConnectionState::Connected) } + pub fn is_connecting(&self, id: Uuid) -> bool { + self.connects.contains_key(&id) + } + pub fn connect(&mut self, workspace: &Workspace, id: Uuid) { self.connects .entry(id) .or_insert_with(|| workspace.connect(id)); } + pub fn disconnect(&mut self, workspace: &Workspace, id: Uuid) { + self.disconnects + .entry(id) + .or_insert_with(|| workspace.disconnect(id)); + } + + pub fn new_connection(&mut self) { + self.form = Some(ProfileForm::new_profile()); + } + + pub fn edit(&mut self, id: Uuid) { + if let Some(profile) = self.profile(id).cloned() { + self.form = Some(ProfileForm::edit(profile)); + } + } + + pub fn ask_remove(&mut self, id: Uuid) { + self.confirm_remove = Some(id); + } + + /// The profile the user has a hand on: the selected one. + pub fn active(&self) -> Option<&ConnectionProfile> { + self.selected.and_then(|id| self.profile(id)) + } + /// Drains finished operations; call once per frame before drawing. pub fn poll(&mut self, workspace: &Workspace, events: &mut Vec) { if let Some(list) = &mut self.list @@ -108,6 +141,7 @@ impl ProfilesPanel { let message = status.message.clone(); self.status.insert(id, status); if connected { + self.selected = Some(id); events.push(ProfilesEvent::Connected(id)); if let Some(profile) = self.profile(id).cloned() { events.push(ProfilesEvent::OpenQuery(profile)); @@ -165,54 +199,79 @@ impl ProfilesPanel { } } + /// The tree: a header with the filter box and New, then the groups. pub fn ui(&mut self, ui: &mut Ui, workspace: &Workspace, events: &mut Vec) { + let tokens = Tokens::of(ui); ui.horizontal(|ui| { - ui.heading("Profiles"); + ui.label(RichText::new(t("Connections")).font(theme::semibold(12.5))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .button("+ New") - .on_hover_text("Add a connection profile") + .small_button("+") + .on_hover_text(t("New connection…")) .clicked() { - self.form = Some(ProfileForm::new_profile()); + self.new_connection(); } if ui .small_button("⟳") - .on_hover_text("Reload profiles") + .on_hover_text(t("Reload the connection list")) .clicked() { self.refresh(workspace); } + ui.add( + egui::TextEdit::singleline(&mut self.filter) + .hint_text(t("Filter")) + .desired_width(ui.available_width()), + ); }); }); for warning in &self.load_warnings { - ui.label(RichText::new(warning).small().color(theme::CAUTION)); + ui.label(RichText::new(warning).small().color(tokens.warning)); } - ui.separator(); let mut action: Option<(Uuid, RowAction)> = None; + let filter = self.filter.trim().to_lowercase(); egui::ScrollArea::vertical() .id_salt("profiles") .auto_shrink([false, true]) .max_height(ui.available_height() * 0.45) .show(ui, |ui| { - let (built_in, user): (Vec<_>, Vec<_>) = - self.profiles.iter().partition(|p| p.built_in); + let shown: Vec<&ConnectionProfile> = self + .profiles + .iter() + .filter(|p| filter.is_empty() || p.name.to_lowercase().contains(&filter)) + .collect(); + let built_in: Vec<&ConnectionProfile> = + shown.iter().copied().filter(|p| p.built_in).collect(); if !built_in.is_empty() { - ui.label(RichText::new("Built-in").small().weak()); + group_label(ui, t("Built-in")); for profile in built_in { if let Some(a) = self.profile_row(ui, profile) { action = Some(a); } } } - ui.label(RichText::new("Your connections").small().weak()); - if user.is_empty() { - ui.label(RichText::new("None yet. Press New to add one.").weak()); + let mut kinds: Vec = Vec::new(); + for profile in shown.iter().filter(|p| !p.built_in) { + if !kinds.contains(&profile.kind) { + kinds.push(profile.kind); + } + } + if kinds.is_empty() && filter.is_empty() { + group_label(ui, t("Your connections")); + ui.label( + RichText::new(t("None yet. Press + to add one.")) + .small() + .color(tokens.text_faint), + ); } - for profile in user { - if let Some(a) = self.profile_row(ui, profile) { - action = Some(a); + for kind in kinds { + group_label(ui, kind_name(kind)); + for profile in shown.iter().filter(|p| !p.built_in && p.kind == kind) { + if let Some(a) = self.profile_row(ui, profile) { + action = Some(a); + } } } }); @@ -230,56 +289,13 @@ impl ProfilesPanel { self.connect(workspace, id); } } - RowAction::Disconnect => { - self.disconnects - .entry(id) - .or_insert_with(|| workspace.disconnect(id)); - } - RowAction::Edit => { - if let Some(profile) = self.profile(id).cloned() { - self.form = Some(ProfileForm::edit(profile)); - } - } + RowAction::Disconnect => self.disconnect(workspace, id), + RowAction::Edit => self.edit(id), RowAction::Remove => self.confirm_remove = Some(id), } } - if let Some(id) = self.confirm_remove { - let name = self.profile(id).map(|p| p.name.clone()).unwrap_or_default(); - let mut open = true; - let mut decided = None; - egui::Window::new("Remove profile") - .collapsible(false) - .resizable(false) - .open(&mut open) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ui.ctx(), |ui| { - ui.label(format!( - "Remove \"{name}\" and its saved password? The database itself is not touched." - )); - ui.horizontal(|ui| { - if ui - .add(egui::Button::new(RichText::new("Remove").color(egui::Color32::WHITE)).fill(theme::DANGER)) - .clicked() - { - decided = Some(true); - } - if ui.button("Cancel").clicked() { - decided = Some(false); - } - }); - }); - match decided { - Some(true) => { - self.confirm_remove = None; - self.removes.insert(id, workspace.remove_profile(id)); - } - Some(false) => self.confirm_remove = None, - None if !open => self.confirm_remove = None, - None => {} - } - } - + self.remove_modal(ui.ctx(), workspace); if let Some(form) = &mut self.form { let mut keep = true; form.ui(ui.ctx(), workspace, &mut keep); @@ -289,22 +305,59 @@ impl ProfilesPanel { } } + fn remove_modal(&mut self, ctx: &egui::Context, workspace: &Workspace) { + let Some(id) = self.confirm_remove else { + return; + }; + let name = self.profile(id).map(|p| p.name.clone()).unwrap_or_default(); + let modal = egui::Modal::new(egui::Id::new("remove-profile")).show(ctx, |ui| { + ui.set_width(380.0); + ui.heading(t("Remove connection")); + ui.label(tf( + "Remove \"{name}\" and its saved password? The database itself is not touched.", + &[("name", &name)], + )); + ui.add_space(8.0); + let mut decided = None; + ui.horizontal(|ui| { + if ui.add(theme::danger_button(ui, t("Remove"))).clicked() { + decided = Some(true); + } + if ui.button(t("Cancel")).clicked() { + decided = Some(false); + } + }); + decided + }); + match modal.inner { + Some(true) => { + self.confirm_remove = None; + self.removes.insert(id, workspace.remove_profile(id)); + } + Some(false) => self.confirm_remove = None, + None if modal.should_close() => self.confirm_remove = None, + None => {} + } + } + fn profile_row(&self, ui: &mut Ui, profile: &ConnectionProfile) -> Option<(Uuid, RowAction)> { + let tokens = Tokens::of(ui); let mut action = None; let connected = self.is_connected(profile.id); let connecting = self.connects.contains_key(&profile.id); let selected = self.selected == Some(profile.id); ui.horizontal(|ui| { let dot = if connected { - theme::SUCCESS + tokens.success } else if !profile.capabilities.connect { - theme::DANGER + tokens.danger } else { - ui.visuals().weak_text_color() + tokens.text_faint }; theme::status_dot(ui, dot); - let label = format!("{} {}", kind_glyph(profile.kind), profile.name); - let response = ui.selectable_label(selected, label); + let response = ui + .selectable_label(selected, &profile.name) + .on_hover_text(describe(profile, self.status.get(&profile.id))); if response.clicked() { action = Some((profile.id, RowAction::Select)); } @@ -313,25 +366,25 @@ impl ProfilesPanel { } response.context_menu(|ui| { if connected { - if ui.button("New query tab").clicked() { + if ui.button(t("New query tab")).clicked() { action = Some((profile.id, RowAction::Connect)); ui.close(); } - if ui.button("Disconnect").clicked() { + if ui.button(t("Disconnect")).clicked() { action = Some((profile.id, RowAction::Disconnect)); ui.close(); } - } else if ui.button("Connect").clicked() { + } else if ui.button(t("Connect")).clicked() { action = Some((profile.id, RowAction::Connect)); ui.close(); } if !profile.built_in { ui.separator(); - if ui.button("Edit…").clicked() { + if ui.button(t("Edit…")).clicked() { action = Some((profile.id, RowAction::Edit)); ui.close(); } - if ui.button("Remove…").clicked() { + if ui.button(t("Remove…")).clicked() { action = Some((profile.id, RowAction::Remove)); ui.close(); } @@ -341,31 +394,28 @@ impl ProfilesPanel { if connecting { ui.spinner(); } else if connected { - if ui.small_button("Query").clicked() { + if ui.small_button(t("Query")).clicked() { action = Some((profile.id, RowAction::Connect)); } - } else if profile.capabilities.connect && ui.small_button("Connect").clicked() { + } else if profile.capabilities.connect && ui.small_button(t("Connect")).clicked() { action = Some((profile.id, RowAction::Connect)); } }); }); - if selected { - ui.indent("detail", |ui| { - let text = describe(profile); - ui.label(RichText::new(text).small().weak()); - if let Some(status) = self.status.get(&profile.id) { - let mut line = status.message.clone(); - if let Some(ms) = status.latency_ms { - line.push_str(&format!(" ({ms} ms)")); - } - ui.label(RichText::new(line).small()); - } - }); - } action } } +fn group_label(ui: &mut Ui, text: &str) { + let tokens = Tokens::of(ui); + ui.add_space(4.0); + ui.label( + RichText::new(text.to_uppercase()) + .small() + .color(tokens.text_faint), + ); +} + enum RowAction { Select, Connect, @@ -374,16 +424,6 @@ enum RowAction { Remove, } -pub fn kind_glyph(kind: DatabaseKind) -> &'static str { - match kind { - DatabaseKind::PostgreSql => "PG", - DatabaseKind::MySql => "My", - DatabaseKind::SQLite => "Lite", - DatabaseKind::MongoDb => "Mongo", - DatabaseKind::SqlServer => "MSSQL", - } -} - pub fn kind_name(kind: DatabaseKind) -> &'static str { match kind { DatabaseKind::PostgreSql => "PostgreSQL", @@ -394,7 +434,8 @@ pub fn kind_name(kind: DatabaseKind) -> &'static str { } } -fn describe(profile: &ConnectionProfile) -> String { +/// The tooltip of a row: kind, host or file, database, mode, and the last status. +fn describe(profile: &ConnectionProfile, status: Option<&ConnectionStatus>) -> String { let c = &profile.config; let mut text = kind_name(profile.kind).to_owned(); match profile.kind { @@ -415,13 +456,24 @@ fn describe(profile: &ConnectionProfile) -> String { } } } - if profile.read_only { - text.push_str(" · read-only"); + text.push_str(" · "); + text.push_str(if profile.read_only { + t("read-only") + } else { + t("read-write") + }); + if let Some(status) = status { + text.push('\n'); + text.push_str(&status.message); + if let Some(ms) = status.latency_ms { + text.push_str(&format!(" ({ms} ms)")); + } } text } -/// The connection form: a window over the app with the fields of one profile. +/// The connection form: a modal over the app with the fields of one profile, a Test button and +/// inline error text. pub struct ProfileForm { pub profile: ConnectionProfile, pub password: String, @@ -509,7 +561,10 @@ impl ProfileForm { events.push(ProfilesEvent::Notice(warning, false)); } events.push(ProfilesEvent::Notice( - format!("Saved profile \"{}\"", outcome.profile.name), + tf( + "Saved connection \"{name}\"", + &[("name", &outcome.profile.name)], + ), false, )); self.password.clear(); @@ -559,34 +614,34 @@ impl ProfileForm { pub fn ui(&mut self, ctx: &egui::Context, workspace: &Workspace, keep: &mut bool) { let title = if self.editing { - "Edit connection" + t("Edit connection") } else { - "New connection" + t("New connection") }; - let mut open = true; - egui::Window::new(title) - .collapsible(false) - .resizable(false) - .default_width(460.0) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .open(&mut open) - .show(ctx, |ui| self.fields(ui, workspace, keep)); - if !open { + let modal = egui::Modal::new(egui::Id::new("connection-form")).show(ctx, |ui| { + ui.set_width(480.0); + ui.heading(title); + ui.add_space(6.0); + self.fields(ui, workspace, keep); + }); + if modal.should_close() { + self.password.clear(); *keep = false; } } fn fields(&mut self, ui: &mut Ui, workspace: &Workspace, keep: &mut bool) { + let tokens = Tokens::of(ui); let busy = self.test.is_some() || self.save.is_some(); egui::Grid::new("profile-form") .num_columns(2) .spacing([12.0, 8.0]) .show(ui, |ui| { - ui.label("Name"); - ui.add(egui::TextEdit::singleline(&mut self.profile.name).desired_width(300.0)); + ui.label(t("Name")); + ui.add(egui::TextEdit::singleline(&mut self.profile.name).desired_width(320.0)); ui.end_row(); - ui.label("Database"); + ui.label(t("Database")); let before = self.profile.kind; egui::ComboBox::from_id_salt("kind") .selected_text(kind_name(self.profile.kind)) @@ -612,7 +667,7 @@ impl ProfileForm { ui.end_row(); if self.profile.kind == DatabaseKind::SQLite { - ui.label("File"); + ui.label(t("File")); ui.horizontal(|ui| { let path = self .profile @@ -621,10 +676,10 @@ impl ProfileForm { .get_or_insert_with(String::new); ui.add( egui::TextEdit::singleline(path) - .desired_width(220.0) + .desired_width(236.0) .hint_text(":memory:"), ); - if ui.button("Browse…").clicked() + if ui.button(t("Browse…")).clicked() && let Some(picked) = rfd::FileDialog::new() .add_filter("SQLite", &["db", "sqlite", "sqlite3"]) .pick_file() @@ -634,50 +689,50 @@ impl ProfileForm { }); ui.end_row(); } else { - ui.label("Host"); + ui.label(t("Host")); ui.add( egui::TextEdit::singleline( self.profile.config.host.get_or_insert_with(String::new), ) - .desired_width(300.0), + .desired_width(320.0), ); ui.end_row(); if !(self.profile.kind == DatabaseKind::MongoDb && self.profile.config.srv) { - ui.label("Port"); + ui.label(t("Port")); ui.add( egui::TextEdit::singleline(&mut self.port_text).desired_width(100.0), ); ui.end_row(); } - ui.label("Database"); + ui.label(t("Database")); ui.add( egui::TextEdit::singleline( self.profile.config.database.get_or_insert_with(String::new), ) - .desired_width(300.0), + .desired_width(320.0), ); ui.end_row(); - ui.label("User"); + ui.label(t("User")); ui.add( egui::TextEdit::singleline( self.profile.config.username.get_or_insert_with(String::new), ) - .desired_width(300.0), + .desired_width(320.0), ); ui.end_row(); - ui.label("Password"); + ui.label(t("Password")); ui.add( egui::TextEdit::singleline(&mut self.password) .password(true) - .desired_width(300.0) + .desired_width(320.0) .hint_text(if self.editing { - "unchanged unless typed" + t("unchanged unless typed") } else { "" }), ); ui.end_row(); - ui.label("Options"); + ui.label(t("Options")); ui.horizontal(|ui| { ui.checkbox(&mut self.profile.config.tls, "TLS"); if self.profile.kind == DatabaseKind::MongoDb { @@ -695,56 +750,63 @@ impl ProfileForm { ui.end_row(); } } - ui.label("Edits"); + ui.label(t("Edits")); ui.checkbox( &mut self.profile.read_only, - "Read-only (uncheck to allow reviewed edits)", + t("Read-only (untick to allow reviewed edits)"), ); ui.end_row(); }); + ui.add_space(6.0); if let Some((text, ok)) = &self.test_result { - let colour = if *ok { theme::SUCCESS } else { theme::DANGER }; - ui.label(RichText::new(text).color(colour)); + let colour = if *ok { tokens.success } else { tokens.danger }; + ui.add(egui::Label::new(RichText::new(text).color(colour)).wrap()); } if let Some(error) = &self.error { - ui.label(RichText::new(error).color(theme::DANGER)); + ui.add(egui::Label::new(RichText::new(error).color(tokens.danger)).wrap()); } ui.add_space(6.0); ui.horizontal(|ui| { - if ui.add_enabled(!busy, egui::Button::new("Test")).clicked() { - self.commit_fields(); - self.error = None; - self.test_result = None; - match self.profile.validate() { - Ok(()) => { - self.test = - Some(workspace.test_connection(self.profile.clone(), self.secret())) - } - Err(error) => self.error = Some(error.to_string()), - } - } if ui - .add_enabled(!busy, theme::primary_button("Save")) + .add_enabled(!busy, egui::Button::new(t("Test"))) + .on_hover_text(t("Connect once with these settings and report")) .clicked() { self.commit_fields(); self.error = None; + self.test_result = None; match self.profile.validate() { Ok(()) => { - self.save = - Some(workspace.save_profile(self.profile.clone(), self.secret())) + self.test = + Some(workspace.test_connection(self.profile.clone(), self.secret())) } Err(error) => self.error = Some(error.to_string()), } } - if ui.button("Cancel").clicked() { - self.password.clear(); - *keep = false; - } if busy { ui.spinner(); } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled(!busy, theme::primary_button(ui, t("Save"))) + .clicked() + { + self.commit_fields(); + self.error = None; + match self.profile.validate() { + Ok(()) => { + self.save = + Some(workspace.save_profile(self.profile.clone(), self.secret())) + } + Err(error) => self.error = Some(error.to_string()), + } + } + if ui.button(t("Cancel")).clicked() { + self.password.clear(); + *keep = false; + } + }); }); } } diff --git a/apps/redrob-query/src/schema.rs b/apps/redrob-query/src/schema.rs index b20f9ea..6df2e57 100644 --- a/apps/redrob-query/src/schema.rs +++ b/apps/redrob-query/src/schema.rs @@ -1,6 +1,10 @@ -//! The Schema browser: tables/views/collections of each connected profile, with columns loaded on -//! expand. Nodes come from the engine's `load_metadata`, which returns the root (tables) for a -//! connection and the children (columns, or a Mongo collection's sampled fields) for a node id. +//! The Schema tree of the active connection: database > (schema >) tables and views > columns, +//! with columns loaded on expand. Nodes come from the engine's `load_metadata`, which returns the +//! root (tables) for a connection and the children (columns, or a Mongo collection's sampled +//! fields) for a node id. +//! +//! Double-click inserts the name into the editor; the context menu offers Select top 100, Copy +//! name and DDL (a CREATE TABLE built from the column metadata, opened in a new tab). use std::collections::HashMap; @@ -11,20 +15,30 @@ use redrob_query_core::{ }; use uuid::Uuid; -use crate::theme; +use crate::i18n::{t, tf}; +use crate::theme::{self, Tokens}; -/// What the browser asks the app to do. +/// What the tree asks the app to do. #[derive(Debug)] pub enum SchemaEvent { - /// Show this table in the structure panel and (double-click) run a starter SELECT on it. + /// A table was clicked: show it in the structure panel. Select { connection_id: Uuid, node: MetadataNode, }, + /// Select top 100: open a tab with a bounded SELECT on the table and run it. Preview { connection_id: Uuid, node: MetadataNode, }, + /// Put this name at the editor's caret. + Insert(String), + /// Open a new tab with this text (the DDL). + OpenText { + connection_id: Uuid, + title: String, + text: String, + }, Notice(String, bool), } @@ -35,6 +49,8 @@ struct Tree { loading: HashMap, Pending>>, expanded: HashMap, filter: String, + /// Tables whose DDL was asked for before their columns were loaded. + ddl_wanted: Vec, } #[derive(Default)] @@ -75,8 +91,8 @@ impl SchemaBrowser { .map_or(&[], |tree| tree.roots.as_slice()) } - pub fn poll(&mut self, events: &mut Vec) { - for tree in self.trees.values_mut() { + pub fn poll(&mut self, kinds: &HashMap, events: &mut Vec) { + for (connection_id, tree) in &mut self.trees { let mut done = Vec::new(); for (parent, pending) in &mut tree.loading { if let Some(result) = pending.poll() { @@ -88,7 +104,19 @@ impl SchemaBrowser { match (parent, result) { (None, Ok(nodes)) => tree.roots = nodes, (Some(id), Ok(nodes)) => { - tree.children.insert(id, nodes); + tree.children.insert(id.clone(), nodes); + if let Some(at) = tree.ddl_wanted.iter().position(|w| *w == id) + && let Some(node) = tree.roots.iter().find(|n| n.id == id) + && let Some(kind) = kinds.get(connection_id) + { + tree.ddl_wanted.remove(at); + let columns = tree.children.get(&id).cloned().unwrap_or_default(); + events.push(SchemaEvent::OpenText { + connection_id: *connection_id, + title: tf("DDL {table}", &[("table", &node.name)]), + text: ddl_for(*kind, node, &columns), + }); + } } (_, Err(error)) => events.push(SchemaEvent::Notice(error.to_string(), true)), } @@ -96,25 +124,41 @@ impl SchemaBrowser { } } + /// The tree of `profile` (the active connection), or a hint when nothing is connected. pub fn ui( &mut self, ui: &mut Ui, workspace: &Workspace, - connected: &[&ConnectionProfile], + profile: Option<&ConnectionProfile>, + connected: bool, events: &mut Vec, ) { - ui.heading("Schema"); - if connected.is_empty() { - ui.label(RichText::new("Connect a profile to browse its tables.").weak()); + let tokens = Tokens::of(ui); + ui.label(RichText::new(t("Schema")).font(theme::semibold(12.5))); + let Some(profile) = profile else { + ui.label( + RichText::new(t("Select a connection to browse its tables.")) + .small() + .color(tokens.text_faint), + ); + return; + }; + if !connected { + ui.label( + RichText::new(tf( + "{name} is not connected. Double-click it to connect.", + &[("name", &profile.name)], + )) + .small() + .color(tokens.text_faint), + ); return; } egui::ScrollArea::vertical() .id_salt("schema") .auto_shrink([false, false]) .show(ui, |ui| { - for profile in connected { - self.connection_ui(ui, workspace, profile, events); - } + self.connection_ui(ui, workspace, profile, events); }); } @@ -125,33 +169,40 @@ impl SchemaBrowser { profile: &ConnectionProfile, events: &mut Vec, ) { + let tokens = Tokens::of(ui); let connection_id = profile.id; + let kind = profile.kind; let tree = self.trees.entry(connection_id).or_default(); let root_loading = tree.loading.contains_key(&None); - let header = egui::CollapsingHeader::new(RichText::new(&profile.name).strong()) - .id_salt(("schema-root", connection_id)) - .default_open(true); let mut to_load: Vec = Vec::new(); let mut reload = false; let mut selected = self.selected.clone(); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut tree.filter) + .hint_text(t("Filter tables")) + .desired_width(ui.available_width() - 32.0), + ); + if ui.small_button("⟳").on_hover_text(t("Reload")).clicked() { + reload = true; + } + }); + let database = database_name(profile); + let header = egui::CollapsingHeader::new(RichText::new(&database).strong()) + .id_salt(("schema-root", connection_id)) + .default_open(true); header.show(ui, |ui| { - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut tree.filter) - .hint_text("Filter tables") - .desired_width(ui.available_width() - 40.0), - ); - if ui.small_button("⟳").on_hover_text("Reload").clicked() { - reload = true; - } - }); if root_loading { ui.horizontal(|ui| { ui.spinner(); - ui.label(RichText::new("Loading…").weak()); + ui.label(RichText::new(t("Loading…")).color(tokens.text_faint)); }); } else if tree.roots.is_empty() { - ui.label(RichText::new("No tables or collections.").weak()); + ui.label( + RichText::new(t("No tables or collections.")) + .small() + .color(tokens.text_faint), + ); } let filter = tree.filter.to_lowercase(); // Group by schema attribute when present (PostgreSQL, MySQL). @@ -180,8 +231,12 @@ impl SchemaBrowser { _ => "▦", }; ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; let arrow = if expanded { "▾" } else { "▸" }; - if ui.small_button(arrow).clicked() { + if ui + .add(egui::Button::new(RichText::new(arrow).small()).frame(false)) + .clicked() + { let now = !expanded; tree.expanded.insert(node.id.clone(), now); if now @@ -191,8 +246,10 @@ impl SchemaBrowser { to_load.push(node.id.clone()); } } - let response = - ui.selectable_label(is_selected, format!("{glyph} {}", node.name)); + let response = ui.selectable_label( + is_selected, + RichText::new(format!("{glyph} {}", node.name)).monospace(), + ); if response.clicked() { selected = Some((connection_id, node.id.clone())); events.push(SchemaEvent::Select { @@ -201,36 +258,59 @@ impl SchemaBrowser { }); } if response.double_clicked() { - events.push(SchemaEvent::Preview { - connection_id, - node: node.clone(), - }); + events.push(SchemaEvent::Insert(quoted_name(kind, node))); } response.context_menu(|ui| { - if ui.button("Preview rows").clicked() { + if ui.button(t("Select top 100")).clicked() { events.push(SchemaEvent::Preview { connection_id, node: node.clone(), }); ui.close(); } - if ui.button("Copy name").clicked() { + if ui.button(t("Copy name")).clicked() { ui.ctx().copy_text(node.name.clone()); ui.close(); } + if ui.button(t("Insert name")).clicked() { + events.push(SchemaEvent::Insert(quoted_name(kind, node))); + ui.close(); + } + if kind != DatabaseKind::MongoDb && ui.button("DDL").clicked() { + match tree.children.get(&node.id) { + Some(columns) => events.push(SchemaEvent::OpenText { + connection_id, + title: tf("DDL {table}", &[("table", &node.name)]), + text: ddl_for(kind, node, columns), + }), + None => { + tree.ddl_wanted.push(node.id.clone()); + if !tree.loading.contains_key(&Some(node.id.clone())) { + to_load.push(node.id.clone()); + } + } + } + ui.close(); + } }); }); if expanded { ui.indent(("cols", &node.id), |ui| match tree.children.get(&node.id) { Some(children) => { for column in children { - column_row(ui, column); + if let Some(name) = column_row(ui, column) { + events.push(SchemaEvent::Insert(name)); + } } } None => { ui.horizontal(|ui| { ui.spinner(); - ui.label(RichText::new("Loading…").weak().small()); + ui.label( + RichText::new(t("Loading…")) + .small() + .color(tokens.text_faint), + ); }); } }); @@ -239,7 +319,7 @@ impl SchemaBrowser { }; if grouped { egui::CollapsingHeader::new(RichText::new(if schema.is_empty() { - "(default)".to_owned() + t("(default)").to_owned() } else { schema.clone() })) @@ -268,16 +348,59 @@ impl SchemaBrowser { } } -fn column_row(ui: &mut Ui, column: &MetadataNode) { +/// A column row: name in monospace, type dimmed; double-click gives the name to insert. +fn column_row(ui: &mut Ui, column: &MetadataNode) -> Option { + let tokens = Tokens::of(ui); + let mut insert = None; ui.horizontal(|ui| { - ui.label(RichText::new(&column.name).monospace().small()); + let response = ui + .add( + egui::Label::new(RichText::new(&column.name).monospace().small()) + .sense(egui::Sense::click()), + ) + .on_hover_text(t("Double-click to insert")); + if response.double_clicked() { + insert = Some(column.name.clone()); + } + response.context_menu(|ui| { + if ui.button(t("Copy name")).clicked() { + ui.ctx().copy_text(column.name.clone()); + ui.close(); + } + if ui.button(t("Insert name")).clicked() { + insert = Some(column.name.clone()); + ui.close(); + } + }); if let Some(data_type) = &column.data_type { - ui.label(RichText::new(data_type).small().weak()); + ui.label(RichText::new(data_type).small().color(tokens.text_faint)); } if column.nullable == Some(false) { - ui.label(RichText::new("not null").small().color(theme::CAUTION)); + ui.label(RichText::new("not null").small().color(tokens.warning)); } }); + insert +} + +/// The database the tree is rooted at: the configured database, the `SQLite` file name, or the host. +fn database_name(profile: &ConnectionProfile) -> String { + let c = &profile.config; + match profile.kind { + DatabaseKind::SQLite => c + .file_path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .map_or_else( + || ":memory:".to_owned(), + |n| n.to_string_lossy().into_owned(), + ), + _ => c + .database + .clone() + .filter(|d| !d.is_empty()) + .or_else(|| c.host.clone()) + .unwrap_or_else(|| profile.name.clone()), + } } /// `schema`/`table` out of a root node id (`table:.` or `table:`, @@ -294,28 +417,68 @@ pub fn split_table_id(node: &MetadataNode, kind: DatabaseKind) -> (Option String { + match kind { + DatabaseKind::MySql => format!("`{}`", name.replace('`', "``")), + DatabaseKind::MongoDb => name.to_owned(), + _ => format!("\"{}\"", name.replace('"', "\"\"")), + } +} + +/// The table's qualified, quoted name as the dialect writes it. +fn quoted_name(kind: DatabaseKind, node: &MetadataNode) -> String { + let (schema, table) = split_table_id(node, kind); + match schema { + Some(s) => format!("{}.{}", quote(kind, &s), quote(kind, &table)), + None => quote(kind, &table), + } +} + /// A bounded SELECT (or find) on a table, for a preview tab. pub fn preview_query(kind: DatabaseKind, node: &MetadataNode) -> String { let (schema, table) = split_table_id(node, kind); match kind { DatabaseKind::MongoDb => format!( - "{{\n \"collection\": {},\n \"operation\": \"find\",\n \"filter\": {{}},\n \"limit\": 50\n}}", + "{{\n \"collection\": {},\n \"operation\": \"find\",\n \"filter\": {{}},\n \"limit\": 100\n}}", serde_json::Value::String(table) ), - DatabaseKind::MySql => { - let target = match schema { - Some(s) => format!("`{}`.`{}`", s.replace('`', "``"), table.replace('`', "``")), - None => format!("`{}`", table.replace('`', "``")), - }; - format!("SELECT *\nFROM {target}\nLIMIT 200;") - } _ => { - let quote = |n: &str| format!("\"{}\"", n.replace('"', "\"\"")); let target = match schema { - Some(s) => format!("{}.{}", quote(&s), quote(&table)), - None => quote(&table), + Some(s) => format!("{}.{}", quote(kind, &s), quote(kind, &table)), + None => quote(kind, &table), }; - format!("SELECT *\nFROM {target}\nLIMIT 200;") + format!("SELECT *\nFROM {target}\nLIMIT 100;") } } } + +/// A CREATE TABLE statement rebuilt from the column metadata (name, type, nullability): what the +/// tree knows, not the server's own DDL, and said so in a leading comment. +pub fn ddl_for(kind: DatabaseKind, node: &MetadataNode, columns: &[MetadataNode]) -> String { + let mut out = format!( + "-- {}\n", + t("Rebuilt from the column metadata; constraints and defaults are not included.") + ); + let name = quoted_name(kind, node); + if node.kind == MetadataKind::View { + out.push_str(&format!("-- {name} is a view.\n")); + } + out.push_str(&format!("CREATE TABLE {name} (\n")); + let lines: Vec = columns + .iter() + .map(|c| { + let mut line = format!(" {}", quote(kind, &c.name)); + if let Some(data_type) = &c.data_type { + line.push(' '); + line.push_str(data_type); + } + if c.nullable == Some(false) { + line.push_str(" NOT NULL"); + } + line + }) + .collect(); + out.push_str(&lines.join(",\n")); + out.push_str("\n);\n"); + out +} diff --git a/apps/redrob-query/src/structure.rs b/apps/redrob-query/src/structure.rs index 3d44025..4562def 100644 --- a/apps/redrob-query/src/structure.rs +++ b/apps/redrob-query/src/structure.rs @@ -9,8 +9,9 @@ use redrob_query_core::{ }; use uuid::Uuid; +use crate::i18n::t; use crate::schema::split_table_id; -use crate::theme; +use crate::theme::{self, Tokens}; pub enum StructureEvent { /// A DDL change was applied: reload the schema tree of this connection. @@ -117,13 +118,13 @@ impl StructurePanel { pub fn ui(&mut self, ui: &mut Ui, workspace: &Workspace, read_only_profile: bool) { ui.horizontal(|ui| { - ui.heading("Structure"); + ui.label(RichText::new(t("Structure")).font(theme::semibold(12.5))); if let Some(target) = &self.target { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if target.kind != DatabaseKind::MongoDb && ui - .small_button("+ Column") - .on_hover_text("Add a column to this table") + .small_button(t("+ Column")) + .on_hover_text(t("Add a column to this table")) .clicked() { let (schema, table) = split_table_id(&target.node, target.kind); @@ -138,7 +139,7 @@ impl StructurePanel { } }); let Some(target) = self.target.clone() else { - ui.label(RichText::new("Select a table in the schema browser.").weak()); + ui.label(RichText::new(t("Select a table in the schema tree.")).weak()); return; }; ui.label(RichText::new(&target.node.name).strong().monospace()); @@ -149,13 +150,13 @@ impl StructurePanel { .id_salt("structure") .auto_shrink([false, false]) .show(ui, |ui| { - ui.label(RichText::new("Columns").small().strong()); + ui.label(RichText::new(t("Columns")).small().strong()); match &self.columns { None => { ui.spinner(); } Some(columns) if columns.is_empty() => { - ui.label(RichText::new("No columns reported.").weak()); + ui.label(RichText::new(t("No columns reported.")).weak()); } Some(columns) => { let keys = self.keys.clone().unwrap_or_default(); @@ -168,7 +169,7 @@ impl StructurePanel { let is_key = keys.primary.contains(&column.name); let name = RichText::new(&column.name).monospace(); ui.label(if is_key { - name.color(theme::REDROB_BLUE) + name.color(Tokens::of(ui).accent_text) } else { name }); @@ -200,7 +201,7 @@ impl StructurePanel { if let Some(keys) = &self.keys { if !keys.foreign.is_empty() { ui.add_space(6.0); - ui.label(RichText::new("Foreign keys").small().strong()); + ui.label(RichText::new(t("Foreign keys")).small().strong()); for fk in &keys.foreign { let mut line = format!( "{} → {} ({})", @@ -224,7 +225,7 @@ impl StructurePanel { } if !keys.indexes.is_empty() { ui.add_space(6.0); - ui.label(RichText::new("Indexes").small().strong()); + ui.label(RichText::new(t("Indexes")).small().strong()); for index in &keys.indexes { let mut tags = Vec::new(); if index.primary { @@ -243,7 +244,7 @@ impl StructurePanel { ui.label( RichText::new(tags.join(", ")) .small() - .color(theme::REDROB_BLUE), + .color(Tokens::of(ui).accent_text), ); } }); @@ -391,9 +392,9 @@ impl TableChangeForm { busy: bool, ) { let title = if self.create { - "Create table" + t("Create table") } else { - "Add column" + t("Add column") }; let mut open = true; egui::Window::new(title) @@ -408,7 +409,7 @@ impl TableChangeForm { .spacing([12.0, 6.0]) .show(ui, |ui| { if matches!(self.kind, DatabaseKind::PostgreSql | DatabaseKind::MySql) { - ui.label("Schema"); + ui.label(t("Schema")); ui.add( egui::TextEdit::singleline(&mut self.schema) .desired_width(200.0) @@ -416,7 +417,7 @@ impl TableChangeForm { ); ui.end_row(); } - ui.label("Table"); + ui.label(t("Table")); ui.add_enabled( self.create, egui::TextEdit::singleline(&mut self.table).desired_width(300.0), @@ -425,9 +426,13 @@ impl TableChangeForm { }); ui.add_space(6.0); ui.label( - RichText::new(if self.create { "Columns" } else { "Column" }) - .small() - .strong(), + RichText::new(if self.create { + t("Columns") + } else { + t("Column") + }) + .small() + .strong(), ); let mut remove: Option = None; let create = self.create; @@ -436,10 +441,10 @@ impl TableChangeForm { .num_columns(5) .spacing([10.0, 4.0]) .show(ui, |ui| { - ui.label(RichText::new("Name").small().weak()); - ui.label(RichText::new("Type").small().weak()); - ui.label(RichText::new("Nullable").small().weak()); - ui.label(RichText::new("Primary").small().weak()); + ui.label(RichText::new(t("Name")).small().weak()); + ui.label(RichText::new(t("Type")).small().weak()); + ui.label(RichText::new(t("Nullable")).small().weak()); + ui.label(RichText::new(t("Primary")).small().weak()); ui.label(""); ui.end_row(); for (index, column) in self.columns.iter_mut().enumerate() { @@ -483,7 +488,7 @@ impl TableChangeForm { if let Some(index) = remove { self.columns.remove(index); } - if self.create && ui.small_button("+ column").clicked() { + if self.create && ui.small_button(t("+ column")).clicked() { self.columns.push(ColumnRow::default()); } if let Ok(change) = self.change() @@ -494,22 +499,24 @@ impl TableChangeForm { } if read_only { ui.label( - RichText::new("This profile is read-only; the change will be refused.") - .color(theme::CAUTION), + RichText::new(t( + "This connection is read-only; the change will be refused.", + )) + .color(Tokens::of(ui).warning), ); } if let Some(error) = &self.error { - ui.label(RichText::new(error).color(theme::DANGER)); + ui.label(RichText::new(error).color(Tokens::of(ui).danger)); } ui.add_space(6.0); ui.horizontal(|ui| { if ui - .add_enabled(!busy && !read_only, theme::primary_button("Apply")) + .add_enabled(!busy && !read_only, theme::primary_button(ui, t("Apply"))) .clicked() { *submit = true; } - if ui.button("Cancel").clicked() { + if ui.button(t("Cancel")).clicked() { *keep = false; } if busy { diff --git a/apps/redrob-query/src/theme.rs b/apps/redrob-query/src/theme.rs index d2f0061..3cc1cef 100644 --- a/apps/redrob-query/src/theme.rs +++ b/apps/redrob-query/src/theme.rs @@ -1,41 +1,408 @@ -//! The shell's look: egui's dark and light themes with the design system's tokens where they are -//! what distinguishes a Redrob window (redrob-design-system `20-color.md`: "Blue acts", ground -//! `deep` is Redrob Black). Every colour literal in the shell lives here, named after its token. +//! The shell's look, the same system Redrob Canvas draws with: one set of colour and shape tokens +//! per ground (dark, light), one egui style built from them at start-up and never reset by a view, +//! and one font set (Inter for the interface, `JetBrains Mono` for SQL and grid cells, a system CJK +//! face as the fallback for Hangul, Han and Kana) installed once. +//! +//! The dark ground is the Pro (Medium Gray) palette, the light ground Pro (Light); both come from +//! the canvas design tokens so the two apps sit together. Which ground draws follows the operating +//! system unless Help > Theme pins one ([`ThemePref`]). Widgets read [`Tokens::of`] instead of +//! hard-coding colours. -use egui::Color32; +use std::sync::Arc; -/// `--blue-6`, Redrob Blue: the one accent. Buttons that act, the selected tab, the caret. +use egui::{ + Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, + Visuals, +}; + +/// Redrob Blue (`--blue-6`): the one brand accent, for the primary button and the selected tab. pub const REDROB_BLUE: Color32 = Color32::from_rgb(0x2B, 0x52, 0xFF); -/// `--redrob-black`: the deep ground behind the brand lockup (kept for the About surface). -#[allow(dead_code)] -pub const REDROB_BLACK: Color32 = Color32::from_rgb(0x0A, 0x0B, 0x0C); -/// `--red-6`, danger: destructive classifications and failed runs. -pub const DANGER: Color32 = Color32::from_rgb(0xE5, 0x3E, 0x3E); -/// `--green-6`, success: a connected profile, an applied change set. -pub const SUCCESS: Color32 = Color32::from_rgb(0x1F, 0xA9, 0x71); -/// `--amber-6`, caution: an elevated-risk mutation, a truncated result. -pub const CAUTION: Color32 = Color32::from_rgb(0xE0, 0x9A, 0x1F); - -/// Applies the shell's style on top of egui's theme: the accent, slightly roomier spacing, and -/// a monospace size that reads as code without crowding the grid. -pub fn apply(ctx: &egui::Context) { - ctx.all_styles_mut(|style| { - style.visuals.selection.bg_fill = REDROB_BLUE.linear_multiply(0.35); - style.visuals.selection.stroke.color = REDROB_BLUE; - style.visuals.hyperlink_color = REDROB_BLUE; - style.visuals.text_cursor.stroke.color = REDROB_BLUE; - style.visuals.widgets.active.bg_fill = REDROB_BLUE.linear_multiply(0.6); + +/// Help > Theme: follow the system, or pin a ground. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum ThemePref { + #[default] + System, + Light, + Dark, +} + +impl ThemePref { + pub const ALL: [ThemePref; 3] = [ThemePref::System, ThemePref::Light, ThemePref::Dark]; + + /// The preference file's value. + pub fn id(self) -> &'static str { + match self { + ThemePref::System => "system", + ThemePref::Light => "light", + ThemePref::Dark => "dark", + } + } + + pub fn from_id(id: &str) -> Self { + match id { + "light" => ThemePref::Light, + "dark" => ThemePref::Dark, + _ => ThemePref::System, + } + } + + /// The menu entry (an English source string for `i18n::t`). + pub fn label(self) -> &'static str { + match self { + ThemePref::System => "System", + ThemePref::Light => "Light", + ThemePref::Dark => "Dark", + } + } + + fn preference(self) -> egui::ThemePreference { + match self { + ThemePref::System => egui::ThemePreference::System, + ThemePref::Light => egui::ThemePreference::Light, + ThemePref::Dark => egui::ThemePreference::Dark, + } + } +} + +/// Colour and shape tokens of one ground. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub dark: bool, + /// Window chrome: the top bar, the status line, the panels. + pub chrome: Color32, + /// The ground behind the editor and the grid. + pub canvas: Color32, + /// The dock behind cards. + pub dock: Color32, + pub card: Color32, + pub card_border: Color32, + /// Inputs, fields, dropdowns. + pub field: Color32, + pub field_border: Color32, + pub hover: Color32, + pub pressed: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_faint: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub accent_border: Color32, + pub accent_text: Color32, + pub separator: Color32, + pub shadow: Color32, + pub primary_bg: Color32, + pub primary_text: Color32, + pub danger: Color32, + pub warning: Color32, + pub success: Color32, + pub radius_sm: u8, + pub radius: u8, + pub radius_lg: u8, + /// The tab strip above a panel's content. + pub tab_strip: Color32, + /// The selected list row. + pub row_selected: Color32, + /// Every other grid row. + pub row_stripe: Color32, +} + +impl Tokens { + /// The dark ground: canvas's Pro (Medium Gray). + pub const DARK: Tokens = Tokens { + dark: true, + chrome: Color32::from_rgb(83, 83, 83), + canvas: Color32::from_rgb(40, 40, 40), + dock: Color32::from_rgb(66, 66, 66), + card: Color32::from_rgb(83, 83, 83), + card_border: Color32::from_rgb(66, 66, 66), + field: Color32::from_rgb(69, 69, 69), + field_border: Color32::from_rgb(104, 104, 104), + hover: Color32::from_rgb(98, 98, 98), + pressed: Color32::from_rgb(112, 112, 112), + text: Color32::from_rgb(238, 238, 238), + text_dim: Color32::from_rgb(212, 212, 212), + text_faint: Color32::from_rgb(160, 160, 160), + accent: Color32::from_rgb(55, 142, 240), + accent_soft: Color32::from_rgb(110, 110, 110), + accent_border: Color32::from_rgb(55, 142, 240), + accent_text: Color32::WHITE, + separator: Color32::from_rgb(62, 62, 62), + shadow: Color32::from_black_alpha(150), + primary_bg: Color32::from_rgb(55, 142, 240), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(236, 91, 98), + warning: Color32::from_rgb(232, 176, 70), + success: Color32::from_rgb(92, 196, 132), + radius_sm: 3, + radius: 4, + radius_lg: 6, + tab_strip: Color32::from_rgb(74, 74, 74), + row_selected: Color32::from_rgb(107, 107, 107), + row_stripe: Color32::from_rgb(46, 46, 46), + }; + + /// The light ground: canvas's Pro (Light). + pub const LIGHT: Tokens = Tokens { + dark: false, + chrome: Color32::from_rgb(240, 240, 240), + canvas: Color32::from_rgb(252, 252, 252), + dock: Color32::from_rgb(226, 226, 226), + card: Color32::from_rgb(240, 240, 240), + card_border: Color32::from_rgb(214, 214, 214), + field: Color32::WHITE, + field_border: Color32::from_rgb(186, 186, 186), + hover: Color32::from_rgb(226, 226, 226), + pressed: Color32::from_rgb(206, 206, 206), + text: Color32::from_rgb(20, 20, 20), + text_dim: Color32::from_rgb(64, 64, 64), + text_faint: Color32::from_rgb(120, 120, 120), + accent: Color32::from_rgb(55, 142, 240), + accent_soft: Color32::from_rgb(214, 214, 214), + accent_border: Color32::from_rgb(55, 142, 240), + accent_text: Color32::from_rgb(16, 98, 196), + separator: Color32::from_rgb(214, 214, 214), + shadow: Color32::from_black_alpha(50), + primary_bg: Color32::from_rgb(55, 142, 240), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(200, 48, 56), + warning: Color32::from_rgb(176, 112, 10), + success: Color32::from_rgb(28, 140, 84), + radius_sm: 3, + radius: 4, + radius_lg: 6, + tab_strip: Color32::from_rgb(226, 226, 226), + row_selected: Color32::from_rgb(204, 204, 204), + row_stripe: Color32::from_rgb(244, 244, 244), + }; + + /// The tokens of the ground `ui` draws in. + pub fn of(ui: &egui::Ui) -> Tokens { + if ui.visuals().dark_mode { + Tokens::DARK + } else { + Tokens::LIGHT + } + } + + fn visuals(&self) -> Visuals { + let t = self; + let mut v = if t.dark { + Visuals::dark() + } else { + Visuals::light() + }; + v.panel_fill = t.chrome; + v.window_fill = t.card; + v.window_stroke = Stroke::new(1.0, t.card_border); + v.extreme_bg_color = t.field; + v.faint_bg_color = t.row_stripe; + v.code_bg_color = t.field; + v.override_text_color = Some(t.text); + v.hyperlink_color = t.accent; + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + v.window_corner_radius = CornerRadius::same(t.radius_lg); + v.menu_corner_radius = CornerRadius::same(t.radius); + v.window_shadow = egui::Shadow { + offset: [0, 10], + blur: 32, + spread: 0, + color: t.shadow, + }; + v.popup_shadow = egui::Shadow { + offset: [0, 6], + blur: 20, + spread: 0, + color: t.shadow, + }; + v.selection.bg_fill = t.accent; + v.selection.stroke = Stroke::new(1.0, t.accent_text); + v.text_cursor.stroke.color = t.accent; + v.slider_trailing_fill = true; + v.handle_shape = egui::style::HandleShape::Circle; + v.striped = true; + v.indent_has_left_vline = false; + let r = CornerRadius::same(t.radius_sm); + let w = &mut v.widgets; + w.noninteractive.bg_fill = t.card; + w.noninteractive.weak_bg_fill = t.card; + w.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); + w.noninteractive.fg_stroke = Stroke::new(1.0, t.text_dim); + w.noninteractive.corner_radius = r; + for (wv, bg, stroke) in [ + (&mut w.inactive, t.field, t.field_border), + (&mut w.hovered, t.hover, t.field_border), + (&mut w.active, t.pressed, t.accent_border), + (&mut w.open, t.hover, t.field_border), + ] { + wv.bg_fill = bg; + wv.weak_bg_fill = bg; + wv.bg_stroke = Stroke::new(1.0, stroke); + wv.fg_stroke = Stroke::new(1.0, t.text); + wv.corner_radius = r; + wv.expansion = 0.0; + } + v + } +} + +/// The one style, built from the tokens: both of egui's theme branches get their ground's +/// palette, so following the system needs no re-apply. Called once at start-up. +pub fn apply(ctx: &egui::Context, pref: ThemePref) { + ctx.set_theme(pref.preference()); + for (theme, tokens) in [ + (egui::Theme::Dark, Tokens::DARK), + (egui::Theme::Light, Tokens::LIGHT), + ] { + let mut style = (*ctx.style_of(theme)).clone(); + style.visuals = tokens.visuals(); + style.text_styles = [ + (TextStyle::Small, FontId::proportional(10.5)), + (TextStyle::Body, FontId::proportional(12.5)), + (TextStyle::Button, FontId::proportional(12.5)), + (TextStyle::Heading, semibold(15.0)), + (TextStyle::Monospace, FontId::monospace(12.5)), + ] + .into(); style.spacing.item_spacing = egui::vec2(8.0, 6.0); style.spacing.button_padding = egui::vec2(10.0, 4.0); - if let Some(mono) = style.text_styles.get_mut(&egui::TextStyle::Monospace) { - mono.size = 13.0; + style.spacing.interact_size = egui::vec2(24.0, 24.0); + style.spacing.slider_width = 150.0; + style.spacing.combo_width = 120.0; + style.spacing.menu_margin = egui::Margin::same(6); + style.spacing.window_margin = egui::Margin::same(16); + style.spacing.icon_width = 14.0; + style.spacing.tooltip_width = 320.0; + style.interaction.tooltip_delay = TOOLTIP_DELAY; + let mut scroll = egui::style::ScrollStyle::thin(); + scroll.interact_handle_opacity = 1.0; + scroll.interact_background_opacity = 0.4; + scroll.floating_width = 4.0; + style.spacing.scroll = scroll; + ctx.set_style_of(theme, style); + } +} + +/// Help > Theme: re-point egui at a ground without rebuilding the styles. +pub fn set_pref(ctx: &egui::Context, pref: ThemePref) { + ctx.set_theme(pref.preference()); +} + +/// Seconds the pointer rests on a control before its tooltip shows. +pub const TOOLTIP_DELAY: f32 = 0.35; + +/// Height of the top bar, in points (canvas's). +pub const TOP_BAR_HEIGHT: f32 = 28.0; +/// Height of the status line, in points. +pub const STATUS_HEIGHT: f32 = 24.0; + +// --------------------------------------------------------------------------------------------- +// Fonts +// --------------------------------------------------------------------------------------------- + +const INTER_REGULAR: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); +const INTER_MEDIUM: &[u8] = include_bytes!("../assets/fonts/Inter-Medium.ttf"); +const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf"); +const JETBRAINS_MONO: &[u8] = include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf"); + +/// System fonts with Hangul, Han and Kana, in the order tried. The first that reads is appended +/// to every family; none means those glyphs draw as boxes, as egui's defaults would. +const CJK_CANDIDATES: &[&str] = &[ + // Linux + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + // macOS + "/System/Library/Fonts/AppleSDGothicNeo.ttc", + "/System/Library/Fonts/Supplemental/AppleGothic.ttf", + // Windows + "C:\\Windows\\Fonts\\malgun.ttf", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\YuGothR.ttc", +]; + +/// The font set: Inter first in the proportional family, `JetBrains Mono` first in the monospace +/// family, the `medium` and `semibold` weights as named families, egui's defaults behind them for +/// symbols and emoji, and the system CJK fallback last in every family. +pub fn font_definitions() -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + for (name, bytes) in [ + ("Inter", INTER_REGULAR), + ("Inter-Medium", INTER_MEDIUM), + ("Inter-SemiBold", INTER_SEMIBOLD), + ("JetBrainsMono", JETBRAINS_MONO), + ] { + fonts + .font_data + .insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + } + fonts + .families + .entry(FontFamily::Proportional) + .or_default() + .insert(0, "Inter".to_owned()); + fonts + .families + .entry(FontFamily::Monospace) + .or_default() + .insert(0, "JetBrainsMono".to_owned()); + let fallback: Vec = fonts + .families + .get(&FontFamily::Proportional) + .cloned() + .unwrap_or_default(); + for (family, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] { + let mut stack = vec![primary.to_owned()]; + stack.extend(fallback.iter().cloned()); + fonts + .families + .insert(FontFamily::Name(family.into()), stack); + } + if let Some((path, bytes)) = CJK_CANDIDATES + .iter() + .find_map(|path| std::fs::read(path).ok().map(|bytes| (*path, bytes))) + { + log::info!("CJK fallback font: {path}"); + fonts.font_data.insert( + "system-cjk".to_owned(), + Arc::new(FontData::from_owned(bytes)), + ); + for stack in fonts.families.values_mut() { + stack.push("system-cjk".to_owned()); } - }); + } else { + log::info!("no CJK system font found; Hangul, Han and Kana draw as boxes"); + } + fonts +} + +/// Inter `SemiBold` at `size` (headings, the brand). +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("semibold".into())) } +// --------------------------------------------------------------------------------------------- +// Widgets +// --------------------------------------------------------------------------------------------- + /// A filled accent button (the primary action of a panel). -pub fn primary_button(text: &str) -> egui::Button<'_> { - egui::Button::new(egui::RichText::new(text).color(Color32::WHITE)).fill(REDROB_BLUE) +pub fn primary_button(ui: &egui::Ui, text: &str) -> egui::Button<'static> { + let t = Tokens::of(ui); + egui::Button::new(egui::RichText::new(text).color(t.primary_text)) + .fill(t.primary_bg) + .stroke(Stroke::new(1.0, t.primary_bg)) +} + +/// A filled danger button (Remove, Discard). +pub fn danger_button(ui: &egui::Ui, text: &str) -> egui::Button<'static> { + let t = Tokens::of(ui); + egui::Button::new(egui::RichText::new(text).color(Color32::WHITE)) + .fill(t.danger) + .stroke(Stroke::new(1.0, t.danger)) } /// A small status dot: green connected, grey not, red unsupported. @@ -44,11 +411,58 @@ pub fn status_dot(ui: &mut egui::Ui, colour: Color32) { ui.painter().circle_filled(rect.center(), 4.0, colour); } -/// The caution/danger/neutral colour for a message that may be a warning. -pub fn tone_for_error(ui: &egui::Ui, is_error: bool) -> Color32 { - if is_error { - DANGER - } else { - ui.visuals().text_color() - } +/// The frame every side panel and bar draws in: the chrome fill with a 1 px separator stroke. +pub fn panel_frame(ui: &egui::Ui) -> egui::Frame { + let visuals = ui.visuals(); + egui::Frame::NONE + .fill(visuals.panel_fill) + .inner_margin(egui::Margin::same(8)) + .stroke(Stroke::new( + 1.0, + visuals.widgets.noninteractive.bg_stroke.color, + )) +} + +/// A bar frame (top bar, status line): the chrome fill and the separator, no inner margin. +pub fn bar_frame(ui: &egui::Ui) -> egui::Frame { + let visuals = ui.visuals(); + egui::Frame::NONE + .fill(visuals.panel_fill) + .stroke(Stroke::new( + 1.0, + visuals.widgets.noninteractive.bg_stroke.color, + )) +} + +/// A small rounded badge: `text` on `fill` (a count, read-only / read-write). +pub fn badge(ui: &mut egui::Ui, text: &str, fill: Color32, text_colour: Color32) -> egui::Response { + let galley = + ui.painter() + .layout_no_wrap(text.to_owned(), FontId::proportional(10.0), text_colour); + let size = galley.size() + egui::vec2(10.0, 3.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(9), fill); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, text_colour); + response +} + +/// A chip in the top bar's right slot: a dot, then text, in a bordered pill. +pub fn chip(ui: &mut egui::Ui, dot: Option, text: &str) -> egui::Response { + let t = Tokens::of(ui); + egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.field_border)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if let Some(colour) = dot { + status_dot(ui, colour); + } + ui.label(egui::RichText::new(text).small()); + }); + }) + .response } diff --git a/apps/redrob-query/src/workspace.rs b/apps/redrob-query/src/workspace.rs index f5f27d8..1535780 100644 --- a/apps/redrob-query/src/workspace.rs +++ b/apps/redrob-query/src/workspace.rs @@ -60,6 +60,8 @@ pub struct WorkspaceFile { /// The AI dock and the structure panel are remembered open or closed. pub ai_dock_open: bool, pub structure_open: bool, + /// Help > Theme: `system`, `light` or `dark`. + pub theme: String, } impl WorkspaceFile { diff --git a/crates/redrob-query-core/src/workspace.rs b/crates/redrob-query-core/src/workspace.rs index 7ca6f10..48109ec 100644 --- a/crates/redrob-query-core/src/workspace.rs +++ b/crates/redrob-query-core/src/workspace.rs @@ -34,6 +34,8 @@ use crate::{ /// A result that is on its way. Poll it once per frame; it answers exactly once. pub struct Pending { receiver: Option>>, + /// The engine task behind the answer, so the shell's Esc can stop waiting for it. + task: Option, } impl Pending { @@ -43,6 +45,16 @@ impl Pending { let _ = sender.send(value); Self { receiver: Some(receiver), + task: None, + } + } + + /// Stop waiting: the engine task is aborted at its next await point (a driver that is + /// mid-request finishes or times out on its own), and the next [`Self::poll`] answers with a + /// cancellation error. + pub fn cancel(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); } } @@ -58,9 +70,12 @@ impl Pending { Err(oneshot::error::TryRecvError::Empty) => None, Err(oneshot::error::TryRecvError::Closed) => { self.receiver = None; - Some(Err(DataError::Unsupported( - "the operation was dropped before it answered".to_owned(), - ))) + let message = if self.task.is_none() { + "cancelled" + } else { + "the operation was dropped before it answered" + }; + Some(Err(DataError::Unsupported(message.to_owned()))) } } } @@ -125,7 +140,7 @@ impl Workspace { let (sender, receiver) = oneshot::channel(); let future = make(Arc::clone(&self.service)); let wake = self.wake.lock().ok().and_then(|slot| slot.clone()); - self.runtime.spawn(async move { + let handle = self.runtime.spawn(async move { let value = future.await; let _ = sender.send(value); if let Some(wake) = wake { @@ -134,6 +149,7 @@ impl Workspace { }); Pending { receiver: Some(receiver), + task: Some(handle.abort_handle()), } }