From c05d2850420a2c163b8f62c33a6cef7c0ae1ad8d Mon Sep 17 00:00:00 2001 From: Vladislav Stepanov <8uk.8ak@gmail.com> Date: Fri, 14 Apr 2023 23:18:58 +0400 Subject: Workspace reorder (#868) * Try different workspace structure Move main crate (atuin) to be on the same level with other crates in this workspace * extract common dependencies to the workspace definition * fix base64 v0.21 deprecation warning * questionable: update deps & fix chrono deprecations possible panic sites are unchanged, they're just more visible now * Revert "questionable: update deps & fix chrono deprecations" This reverts commit 993e60f8dea81a1625a04285a617959ad09a0866. --- Cargo.lock | 325 +- Cargo.toml | 107 +- atuin-client/Cargo.toml | 58 +- atuin-client/src/encryption.rs | 8 +- atuin-common/Cargo.toml | 19 +- atuin-server/Cargo.toml | 48 +- atuin/Cargo.toml | 86 + atuin/src/command/client.rs | 61 + atuin/src/command/client/history.rs | 298 + atuin/src/command/client/import.rs | 152 + atuin/src/command/client/search.rs | 189 + atuin/src/command/client/search/cursor.rs | 333 ++ atuin/src/command/client/search/duration.rs | 62 + atuin/src/command/client/search/engines.rs | 46 + atuin/src/command/client/search/engines/db.rs | 33 + atuin/src/command/client/search/engines/skim.rs | 145 + atuin/src/command/client/search/history_list.rs | 183 + atuin/src/command/client/search/interactive.rs | 588 ++ atuin/src/command/client/stats.rs | 181 + atuin/src/command/client/sync.rs | 74 + atuin/src/command/client/sync/login.rs | 147 + atuin/src/command/client/sync/logout.rs | 19 + atuin/src/command/client/sync/register.rs | 49 + atuin/src/command/client/sync/status.rs | 35 + atuin/src/command/contributors.rs | 75 + atuin/src/command/init.rs | 144 + atuin/src/command/mod.rs | 87 + atuin/src/command/server.rs | 44 + atuin/src/main.rs | 45 + .../ratatui/.github/ISSUE_TEMPLATE/bug_report.md | 60 + .../src/ratatui/.github/ISSUE_TEMPLATE/config.yml | 1 + .../.github/ISSUE_TEMPLATE/feature_request.md | 32 + atuin/src/ratatui/.github/workflows/cd.yml | 19 + atuin/src/ratatui/.github/workflows/ci.yml | 76 + atuin/src/ratatui/.gitignore | 6 + atuin/src/ratatui/LICENSE | 21 + atuin/src/ratatui/README.md | 136 + atuin/src/ratatui/backend/crossterm.rs | 241 + atuin/src/ratatui/backend/mod.rs | 58 + atuin/src/ratatui/backend/termion.rs | 275 + atuin/src/ratatui/buffer.rs | 736 +++ atuin/src/ratatui/layout.rs | 560 ++ atuin/src/ratatui/mod.rs | 177 + atuin/src/ratatui/style.rs | 310 + atuin/src/ratatui/symbols.rs | 233 + atuin/src/ratatui/terminal.rs | 487 ++ atuin/src/ratatui/text.rs | 430 ++ atuin/src/ratatui/widgets/barchart.rs | 219 + atuin/src/ratatui/widgets/block.rs | 573 ++ atuin/src/ratatui/widgets/canvas/line.rs | 95 + atuin/src/ratatui/widgets/canvas/map.rs | 48 + atuin/src/ratatui/widgets/canvas/mod.rs | 510 ++ atuin/src/ratatui/widgets/canvas/points.rs | 30 + atuin/src/ratatui/widgets/canvas/rectangle.rs | 52 + atuin/src/ratatui/widgets/canvas/world.rs | 6299 ++++++++++++++++++++ atuin/src/ratatui/widgets/chart.rs | 660 ++ atuin/src/ratatui/widgets/clear.rs | 37 + atuin/src/ratatui/widgets/gauge.rs | 313 + atuin/src/ratatui/widgets/list.rs | 268 + atuin/src/ratatui/widgets/mod.rs | 184 + atuin/src/ratatui/widgets/paragraph.rs | 214 + atuin/src/ratatui/widgets/reflow.rs | 534 ++ atuin/src/ratatui/widgets/sparkline.rs | 155 + atuin/src/ratatui/widgets/table.rs | 504 ++ atuin/src/ratatui/widgets/tabs.rs | 129 + atuin/src/shell/atuin.bash | 34 + atuin/src/shell/atuin.fish | 40 + atuin/src/shell/atuin.nu | 44 + atuin/src/shell/atuin.zsh | 57 + src/command/client.rs | 61 - src/command/client/history.rs | 298 - src/command/client/import.rs | 152 - src/command/client/search.rs | 189 - src/command/client/search/cursor.rs | 333 -- src/command/client/search/duration.rs | 62 - src/command/client/search/engines.rs | 46 - src/command/client/search/engines/db.rs | 33 - src/command/client/search/engines/skim.rs | 145 - src/command/client/search/history_list.rs | 183 - src/command/client/search/interactive.rs | 588 -- src/command/client/stats.rs | 181 - src/command/client/sync.rs | 74 - src/command/client/sync/login.rs | 147 - src/command/client/sync/logout.rs | 19 - src/command/client/sync/register.rs | 49 - src/command/client/sync/status.rs | 35 - src/command/contributors.rs | 75 - src/command/init.rs | 144 - src/command/mod.rs | 87 - src/command/server.rs | 44 - src/main.rs | 45 - src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md | 60 - src/ratatui/.github/ISSUE_TEMPLATE/config.yml | 1 - .../.github/ISSUE_TEMPLATE/feature_request.md | 32 - src/ratatui/.github/workflows/cd.yml | 19 - src/ratatui/.github/workflows/ci.yml | 76 - src/ratatui/.gitignore | 6 - src/ratatui/LICENSE | 21 - src/ratatui/README.md | 136 - src/ratatui/backend/crossterm.rs | 241 - src/ratatui/backend/mod.rs | 58 - src/ratatui/backend/termion.rs | 275 - src/ratatui/buffer.rs | 736 --- src/ratatui/layout.rs | 560 -- src/ratatui/mod.rs | 177 - src/ratatui/style.rs | 310 - src/ratatui/symbols.rs | 233 - src/ratatui/terminal.rs | 487 -- src/ratatui/text.rs | 430 -- src/ratatui/widgets/barchart.rs | 219 - src/ratatui/widgets/block.rs | 573 -- src/ratatui/widgets/canvas/line.rs | 95 - src/ratatui/widgets/canvas/map.rs | 48 - src/ratatui/widgets/canvas/mod.rs | 510 -- src/ratatui/widgets/canvas/points.rs | 30 - src/ratatui/widgets/canvas/rectangle.rs | 52 - src/ratatui/widgets/canvas/world.rs | 6299 -------------------- src/ratatui/widgets/chart.rs | 660 -- src/ratatui/widgets/clear.rs | 37 - src/ratatui/widgets/gauge.rs | 313 - src/ratatui/widgets/list.rs | 268 - src/ratatui/widgets/mod.rs | 184 - src/ratatui/widgets/paragraph.rs | 214 - src/ratatui/widgets/reflow.rs | 534 -- src/ratatui/widgets/sparkline.rs | 155 - src/ratatui/widgets/table.rs | 504 -- src/ratatui/widgets/tabs.rs | 129 - src/shell/atuin.bash | 34 - src/shell/atuin.fish | 40 - src/shell/atuin.nu | 44 - src/shell/atuin.zsh | 57 - 131 files changed, 18210 insertions(+), 18135 deletions(-) create mode 100644 atuin/Cargo.toml create mode 100644 atuin/src/command/client.rs create mode 100644 atuin/src/command/client/history.rs create mode 100644 atuin/src/command/client/import.rs create mode 100644 atuin/src/command/client/search.rs create mode 100644 atuin/src/command/client/search/cursor.rs create mode 100644 atuin/src/command/client/search/duration.rs create mode 100644 atuin/src/command/client/search/engines.rs create mode 100644 atuin/src/command/client/search/engines/db.rs create mode 100644 atuin/src/command/client/search/engines/skim.rs create mode 100644 atuin/src/command/client/search/history_list.rs create mode 100644 atuin/src/command/client/search/interactive.rs create mode 100644 atuin/src/command/client/stats.rs create mode 100644 atuin/src/command/client/sync.rs create mode 100644 atuin/src/command/client/sync/login.rs create mode 100644 atuin/src/command/client/sync/logout.rs create mode 100644 atuin/src/command/client/sync/register.rs create mode 100644 atuin/src/command/client/sync/status.rs create mode 100644 atuin/src/command/contributors.rs create mode 100644 atuin/src/command/init.rs create mode 100644 atuin/src/command/mod.rs create mode 100644 atuin/src/command/server.rs create mode 100644 atuin/src/main.rs create mode 100644 atuin/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 atuin/src/ratatui/.github/ISSUE_TEMPLATE/config.yml create mode 100644 atuin/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 atuin/src/ratatui/.github/workflows/cd.yml create mode 100644 atuin/src/ratatui/.github/workflows/ci.yml create mode 100644 atuin/src/ratatui/.gitignore create mode 100644 atuin/src/ratatui/LICENSE create mode 100644 atuin/src/ratatui/README.md create mode 100644 atuin/src/ratatui/backend/crossterm.rs create mode 100644 atuin/src/ratatui/backend/mod.rs create mode 100644 atuin/src/ratatui/backend/termion.rs create mode 100644 atuin/src/ratatui/buffer.rs create mode 100644 atuin/src/ratatui/layout.rs create mode 100644 atuin/src/ratatui/mod.rs create mode 100644 atuin/src/ratatui/style.rs create mode 100644 atuin/src/ratatui/symbols.rs create mode 100644 atuin/src/ratatui/terminal.rs create mode 100644 atuin/src/ratatui/text.rs create mode 100644 atuin/src/ratatui/widgets/barchart.rs create mode 100644 atuin/src/ratatui/widgets/block.rs create mode 100644 atuin/src/ratatui/widgets/canvas/line.rs create mode 100644 atuin/src/ratatui/widgets/canvas/map.rs create mode 100644 atuin/src/ratatui/widgets/canvas/mod.rs create mode 100644 atuin/src/ratatui/widgets/canvas/points.rs create mode 100644 atuin/src/ratatui/widgets/canvas/rectangle.rs create mode 100644 atuin/src/ratatui/widgets/canvas/world.rs create mode 100644 atuin/src/ratatui/widgets/chart.rs create mode 100644 atuin/src/ratatui/widgets/clear.rs create mode 100644 atuin/src/ratatui/widgets/gauge.rs create mode 100644 atuin/src/ratatui/widgets/list.rs create mode 100644 atuin/src/ratatui/widgets/mod.rs create mode 100644 atuin/src/ratatui/widgets/paragraph.rs create mode 100644 atuin/src/ratatui/widgets/reflow.rs create mode 100644 atuin/src/ratatui/widgets/sparkline.rs create mode 100644 atuin/src/ratatui/widgets/table.rs create mode 100644 atuin/src/ratatui/widgets/tabs.rs create mode 100644 atuin/src/shell/atuin.bash create mode 100644 atuin/src/shell/atuin.fish create mode 100644 atuin/src/shell/atuin.nu create mode 100644 atuin/src/shell/atuin.zsh delete mode 100644 src/command/client.rs delete mode 100644 src/command/client/history.rs delete mode 100644 src/command/client/import.rs delete mode 100644 src/command/client/search.rs delete mode 100644 src/command/client/search/cursor.rs delete mode 100644 src/command/client/search/duration.rs delete mode 100644 src/command/client/search/engines.rs delete mode 100644 src/command/client/search/engines/db.rs delete mode 100644 src/command/client/search/engines/skim.rs delete mode 100644 src/command/client/search/history_list.rs delete mode 100644 src/command/client/search/interactive.rs delete mode 100644 src/command/client/stats.rs delete mode 100644 src/command/client/sync.rs delete mode 100644 src/command/client/sync/login.rs delete mode 100644 src/command/client/sync/logout.rs delete mode 100644 src/command/client/sync/register.rs delete mode 100644 src/command/client/sync/status.rs delete mode 100644 src/command/contributors.rs delete mode 100644 src/command/init.rs delete mode 100644 src/command/mod.rs delete mode 100644 src/command/server.rs delete mode 100644 src/main.rs delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/config.yml delete mode 100644 src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 src/ratatui/.github/workflows/cd.yml delete mode 100644 src/ratatui/.github/workflows/ci.yml delete mode 100644 src/ratatui/.gitignore delete mode 100644 src/ratatui/LICENSE delete mode 100644 src/ratatui/README.md delete mode 100644 src/ratatui/backend/crossterm.rs delete mode 100644 src/ratatui/backend/mod.rs delete mode 100644 src/ratatui/backend/termion.rs delete mode 100644 src/ratatui/buffer.rs delete mode 100644 src/ratatui/layout.rs delete mode 100644 src/ratatui/mod.rs delete mode 100644 src/ratatui/style.rs delete mode 100644 src/ratatui/symbols.rs delete mode 100644 src/ratatui/terminal.rs delete mode 100644 src/ratatui/text.rs delete mode 100644 src/ratatui/widgets/barchart.rs delete mode 100644 src/ratatui/widgets/block.rs delete mode 100644 src/ratatui/widgets/canvas/line.rs delete mode 100644 src/ratatui/widgets/canvas/map.rs delete mode 100644 src/ratatui/widgets/canvas/mod.rs delete mode 100644 src/ratatui/widgets/canvas/points.rs delete mode 100644 src/ratatui/widgets/canvas/rectangle.rs delete mode 100644 src/ratatui/widgets/canvas/world.rs delete mode 100644 src/ratatui/widgets/chart.rs delete mode 100644 src/ratatui/widgets/clear.rs delete mode 100644 src/ratatui/widgets/gauge.rs delete mode 100644 src/ratatui/widgets/list.rs delete mode 100644 src/ratatui/widgets/mod.rs delete mode 100644 src/ratatui/widgets/paragraph.rs delete mode 100644 src/ratatui/widgets/reflow.rs delete mode 100644 src/ratatui/widgets/sparkline.rs delete mode 100644 src/ratatui/widgets/table.rs delete mode 100644 src/ratatui/widgets/tabs.rs delete mode 100644 src/shell/atuin.bash delete mode 100644 src/shell/atuin.fish delete mode 100644 src/shell/atuin.nu delete mode 100644 src/shell/atuin.zsh diff --git a/Cargo.lock b/Cargo.lock index 8037efa9..ecdc65c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,7 +45,7 @@ checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -76,7 +76,7 @@ dependencies = [ "atuin-client", "atuin-common", "atuin-server", - "base64 0.20.0", + "base64 0.21.0", "bitflags", "cassowary", "chrono", @@ -114,7 +114,7 @@ version = "14.0.0" dependencies = [ "async-trait", "atuin-common", - "base64 0.20.0", + "base64 0.21.0", "chrono", "clap", "config", @@ -247,12 +247,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" - [[package]] name = "base64" version = "0.21.0" @@ -343,49 +337,54 @@ dependencies = [ [[package]] name = "clap" -version = "4.0.18" +version = "4.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" +checksum = "906f7fe1da4185b7a282b2bc90172a496f9def1aca4545fe7526810741591e14" dependencies = [ - "atty", - "bitflags", + "clap_builder", "clap_derive", - "clap_lex", "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "351f9ad9688141ed83dfd8f5fb998a06225ef444b48ff4dc43de6d409b7fd10b" +dependencies = [ + "bitflags", + "clap_lex", + "is-terminal", "strsim", "termcolor", ] [[package]] name = "clap_complete" -version = "4.0.3" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfe581a2035db4174cdbdc91265e1aba50f381577f0510d0ad36c7bc59cc84a3" +checksum = "01c22dcfb410883764b29953103d9ef7bb8fe21b3fa1158bc99986c2067294bd" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.0.18" +version = "4.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1b0f6422af32d5da0c58e2703320f379216ee70198241c84173a8c5ac28f3" +checksum = "81d7dc0031c3a59a04fc2ba395c8e2dd463cba1859275f065d225f6122221b45" dependencies = [ "heck", - "proc-macro-error", "proc-macro2", "quote", - "syn", + "syn 2.0.14", ] [[package]] name = "clap_lex" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" -dependencies = [ - "os_str_bytes", -] +checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" [[package]] name = "colored" @@ -414,16 +413,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.1" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89eab4d20ce20cea182308bca13088fecea9c05f6776cf287205d41a0ed3c847" +checksum = "c3d79fbe8970a77e3e34151cc13d3b3e248aa0faaecb9f6091fa07ebefe5ad60" dependencies = [ "encode_unicode", + "lazy_static", "libc", - "once_cell", - "terminal_size", "unicode-width", - "winapi", + "windows-sys 0.42.0", ] [[package]] @@ -468,9 +466,9 @@ checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" [[package]] name = "crossbeam-channel" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" +checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" dependencies = [ "cfg-if", "crossbeam-utils", @@ -498,9 +496,9 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.26.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77f67c7faacd4db07a939f55d66a983a5355358a1f17d32cc9a8d01d1266b9ce" +checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" dependencies = [ "bitflags", "crossterm_winapi", @@ -626,13 +624,13 @@ dependencies = [ [[package]] name = "errno" -version = "0.2.8" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ "errno-dragonfly", "libc", - "winapi", + "windows-sys 0.48.0", ] [[package]] @@ -681,7 +679,7 @@ dependencies = [ "futures-core", "futures-sink", "pin-project", - "spin 0.9.4", + "spin 0.9.8", ] [[package]] @@ -751,7 +749,7 @@ checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -868,12 +866,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.2.6" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" [[package]] name = "hex" @@ -1024,12 +1019,13 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.1" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfddc9561e8baf264e0e45e197fd7696320026eb10a8180340debc27b18f535b" +checksum = "cef509aa9bc73864d6756f0d34d35504af3cf0844373afe9b8669a5b8005a729" dependencies = [ "console", "number_prefix", + "portable-atomic", "unicode-width", ] @@ -1054,12 +1050,13 @@ dependencies = [ [[package]] name = "io-lifetimes" -version = "1.0.3" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" dependencies = [ + "hermit-abi 0.3.1", "libc", - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -1070,14 +1067,14 @@ checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" [[package]] name = "is-terminal" -version = "0.4.1" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927609f78c2913a6f6ac3c27a4fe87f43e2a35367c0c4b0f8265e8f49a104330" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi 0.3.1", "io-lifetimes", "rustix", - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -1112,9 +1109,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.138" +version = "0.2.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" +checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5" [[package]] name = "libsodium-sys" @@ -1141,9 +1138,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.1.4" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" +checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f" [[package]] name = "lock_api" @@ -1184,7 +1181,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn", + "syn 1.0.99", ] [[package]] @@ -1372,12 +1369,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" -[[package]] -name = "os_str_bytes" -version = "6.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" - [[package]] name = "overload" version = "0.1.1" @@ -1487,7 +1478,7 @@ checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -1509,49 +1500,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" [[package]] -name = "ppv-lite86" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" - -[[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "portable-atomic" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", -] +checksum = "26f6a7b87c2e435a3241addceeeff740ff8b7e76b74c13bf9acb17fa454ea00b" [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "ppv-lite86" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" dependencies = [ "proc-macro2", ] @@ -1710,9 +1683,20 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.0.0" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678cf63ab3491898c0d021b493c94c9b221d91295294a2a5746eacbe5928322" +dependencies = [ + "libc", + "rtoolbox", + "winapi", +] + +[[package]] +name = "rtoolbox" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b763cb66df1c928432cc35053f8bd4cec3335d8559fc16010017d16b3c1680" +checksum = "034e22c514f5c0cb8a10ff341b9b048b5ceb21591f31c8f44c43b960f9b3524a" dependencies = [ "libc", "winapi", @@ -1735,16 +1719,16 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.36.5" +version = "0.37.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3807b5d10909833d3e9acd1eb5fb988f79376ff10fce42937de71a449c4c588" +checksum = "85597d61f83914ddeba6a47b3b8ffe7365107221c2e557ed94426489fefb5f77" dependencies = [ "bitflags", "errno", "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys 0.42.0", + "windows-sys 0.48.0", ] [[package]] @@ -1873,7 +1857,7 @@ checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -1960,9 +1944,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" dependencies = [ "libc", "signal-hook-registry", @@ -2039,9 +2023,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.4" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6002a767bff9e83f8eeecf883ecb8011875a21ae8da43bffb817a57e78cc09" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -2149,7 +2133,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-rt", - "syn", + "syn 1.0.99", "url", ] @@ -2198,23 +2182,22 @@ dependencies = [ ] [[package]] -name = "sync_wrapper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8" - -[[package]] -name = "synstructure" -version = "0.12.6" +name = "syn" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +checksum = "fcf316d5356ed6847742d036f8a39c3b8435cac10bd528a4bd461928a6ab34d5" dependencies = [ "proc-macro2", "quote", - "syn", - "unicode-xid", + "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8" + [[package]] name = "termcolor" version = "1.1.3" @@ -2224,16 +2207,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminal_size" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "thiserror" version = "1.0.38" @@ -2251,15 +2224,16 @@ checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] name = "thread_local" -version = "1.1.4" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" dependencies = [ + "cfg-if", "once_cell", ] @@ -2336,7 +2310,7 @@ checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -2453,7 +2427,7 @@ checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] @@ -2526,12 +2500,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" -[[package]] -name = "unicode-xid" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -2636,7 +2604,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 1.0.99", "wasm-bindgen-shared", ] @@ -2670,7 +2638,7 @@ checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2771,21 +2739,51 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", + "windows_aarch64_gnullvm 0.42.0", "windows_aarch64_msvc 0.42.0", "windows_i686_gnu 0.42.0", "windows_i686_msvc 0.42.0", "windows_x86_64_gnu 0.42.0", - "windows_x86_64_gnullvm", + "windows_x86_64_gnullvm 0.42.0", "windows_x86_64_msvc 0.42.0", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + [[package]] name = "windows_aarch64_msvc" version = "0.36.1" @@ -2798,6 +2796,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + [[package]] name = "windows_i686_gnu" version = "0.36.1" @@ -2810,6 +2814,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + [[package]] name = "windows_i686_msvc" version = "0.36.1" @@ -2822,6 +2832,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + [[package]] name = "windows_x86_64_gnu" version = "0.36.1" @@ -2834,12 +2850,24 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + [[package]] name = "windows_x86_64_msvc" version = "0.36.1" @@ -2852,6 +2880,12 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + [[package]] name = "winreg" version = "0.10.1" @@ -2863,21 +2897,20 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.3.3" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44bf07cb3e50ea2003396695d58bf46bc9887a1f362260446fad6bc4e79bd36c" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.14", ] diff --git a/Cargo.toml b/Cargo.toml index b517a41a..cb86975f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,88 +1,53 @@ -[package] -name = "atuin" +[workspace] +members = [ + "atuin", + "atuin-client", + "atuin-server", + "atuin-common", +] + +[workspace.package] version = "14.0.0" authors = ["Ellie Huxtable "] -edition = "2021" rust-version = "1.59" license = "MIT" -description = "atuin - magical shell history" homepage = "https://atuin.sh" repository = "https://github.com/ellie/atuin" readme = "README.md" -[package.metadata.binstall] -pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }.tar.gz" -bin-dir = "{ name }-v{ version }-{ target }/{ bin }{ binary-ext }" -pkg-fmt = "tgz" - -[package.metadata.deb] -maintainer = "Ellie Huxtable " -copyright = "2021, Ellie Huxtable " -license-file = ["LICENSE"] -depends = "$auto" -section = "utility" - -[package.metadata.rpm] -package = "atuin" - -[package.metadata.rpm.cargo] -buildflags = ["--release"] - -[package.metadata.rpm.targets] -atuin = { path = "/usr/bin/atuin" } - -[workspace] -members = ["./atuin-client", "./atuin-server", "./atuin-common"] - -[features] -# TODO(conradludgate) -# Currently, this keeps the same default built behaviour for v0.8 -# We should rethink this by the time we hit a new breaking change -default = ["client", "sync", "server"] -client = ["atuin-client"] -sync = ["atuin-client/sync"] -server = ["atuin-server", "tracing-subscriber"] - -[dependencies] -atuin-server = { path = "atuin-server", version = "14.0.0", optional = true } -atuin-client = { path = "atuin-client", version = "14.0.0", optional = true, default-features = false } -atuin-common = { path = "atuin-common", version = "14.0.0" } - +[workspace.dependencies] +async-trait = "0.1.58" +base64 = "0.21" log = "0.4" -env_logger = "0.10.0" chrono = { version = "0.4", features = ["serde"] } -eyre = "0.6" +clap = { version = "4.0.18", features = ["derive"] } +config = { version = "0.13", default-features = false, features = ["toml"] } directories = "4" -indicatif = "0.17.1" +eyre = "0.6" +fs-err = "2.9" +interim = { version = "0.1.0", features = ["chrono"] } +itertools = "0.10.5" +rand = { version = "0.8.5", features = ["std"] } +semver = "1.0.14" serde = { version = "1.0.145", features = ["derive"] } serde_json = "1.0.86" -crossterm = { version = "0.26", features = ["use-dev-tty"] } -unicode-width = "0.1" -itertools = "0.10.5" +sodiumoxide = "0.2.6" tokio = { version = "1", features = ["full"] } -async-trait = "0.1.58" -interim = { version = "0.1.0", features = ["chrono"] } -base64 = "0.20.0" -crossbeam-channel = "0.5.1" -clap = { version = "4.0.18", features = ["derive"] } -clap_complete = "4.0.3" -fs-err = "2.9" +uuid = { version = "1.2", features = ["v4"] } whoami = "1.1.2" -rpassword = "7.0" -semver = "1.0.14" -runtime-format = "0.1.2" -tiny-bip39 = "1" -futures-util = "0.3" -fuzzy-matcher = "0.3.7" -colored = "2.0.0" - -# ratatui -bitflags = "1.3" -cassowary = "0.3" -unicode-segmentation = "1.2" -[dependencies.tracing-subscriber] -version = "0.3" +[workspace.dependencies.reqwest] +version = "0.11" +features = [ + "json", + "rustls-tls-native-roots", +] default-features = false -features = ["ansi", "fmt", "registry", "env-filter"] -optional = true + +[workspace.dependencies.sqlx] +version = "0.6" +features = [ + "runtime-tokio-rustls", + "chrono", + "postgres", +] diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml index 1eda2c29..0f498d3c 100644 --- a/atuin-client/Cargo.toml +++ b/atuin-client/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "atuin-client" -version = "14.0.0" -authors = ["Ellie Huxtable "] edition = "2018" -license = "MIT" description = "client library for atuin" -homepage = "https://atuin.sh" -repository = "https://github.com/ellie/atuin" + +version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -25,47 +26,40 @@ sync = [ [dependencies] atuin-common = { path = "../atuin-common", version = "14.0.0" } -log = "0.4" -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4.0.18", features = ["derive"] } -eyre = "0.6" -directories = "4" -uuid = { version = "1.2", features = ["v4"] } -whoami = "1.1.2" -interim = { version = "0.1.0", features = ["chrono"] } -config = { version = "0.13", default-features = false, features = ["toml"] } -serde = { version = "1.0.145", features = ["derive"] } -serde_json = "1.0.86" +log = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +eyre = { workspace = true } +directories = { workspace = true } +uuid = { workspace = true } +whoami = { workspace = true } +interim = { workspace = true } +config = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } parse_duration = "2.1.1" -async-trait = "0.1.58" -itertools = "0.10.5" +async-trait = { workspace = true } +itertools = { workspace = true } shellexpand = "2" -sqlx = { version = "0.6", features = [ - "runtime-tokio-rustls", - "chrono", - "sqlite", -] } +sqlx = { workspace = true, features = ["sqlite"] } minspan = "0.1.1" regex = "1.5.4" serde_regex = "1.1.0" -fs-err = "2.9" +fs-err = { workspace = true } sql-builder = "3" lazy_static = "1" memchr = "2.5" # sync urlencoding = { version = "2.1.0", optional = true } -sodiumoxide = { version = "0.2.6", optional = true } -reqwest = { version = "0.11", features = [ - "json", - "rustls-tls-native-roots", -], default-features = false, optional = true } +sodiumoxide = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } hex = { version = "0.4", optional = true } sha2 = { version = "0.10", optional = true } rmp-serde = { version = "1.1.1", optional = true } -base64 = { version = "0.20.0", optional = true } -tokio = { version = "1", features = ["full"] } -semver = "1.0.14" +base64 = { workspace = true, optional = true } +tokio = { workspace = true } +semver = { workspace = true } [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/atuin-client/src/encryption.rs b/atuin-client/src/encryption.rs index 40badb5e..fe19ce9b 100644 --- a/atuin-client/src/encryption.rs +++ b/atuin-client/src/encryption.rs @@ -10,6 +10,7 @@ use std::{io::prelude::*, path::PathBuf}; +use base64::prelude::{Engine, BASE64_STANDARD}; use eyre::{eyre, Context, Result}; use fs_err as fs; use serde::{Deserialize, Serialize}; @@ -72,14 +73,15 @@ pub fn load_encoded_key(settings: &Settings) -> Result { pub type Key = secretbox::Key; pub fn encode_key(key: secretbox::Key) -> Result { let buf = rmp_serde::to_vec(&key).wrap_err("could not encode key to message pack")?; - let buf = base64::encode(buf); + let buf = BASE64_STANDARD.encode(buf); Ok(buf) } pub fn decode_key(key: String) -> Result { - let buf = - base64::decode(key.trim_end()).wrap_err("encryption key is not a valid base64 encoding")?; + let buf = BASE64_STANDARD + .decode(key.trim_end()) + .wrap_err("encryption key is not a valid base64 encoding")?; let buf: secretbox::Key = rmp_serde::from_slice(&buf) .wrap_err("encryption key is not a valid message pack encoding")?; diff --git a/atuin-common/Cargo.toml b/atuin-common/Cargo.toml index d065a32d..94225e6f 100644 --- a/atuin-common/Cargo.toml +++ b/atuin-common/Cargo.toml @@ -1,17 +1,18 @@ [package] name = "atuin-common" -version = "14.0.0" -authors = ["Ellie Huxtable "] edition = "2018" -license = "MIT" description = "common library for atuin" -homepage = "https://atuin.sh" -repository = "https://github.com/ellie/atuin" + +version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono = { version = "0.4", features = ["serde"] } -serde = { version = "1.0.145", features = ["derive"] } -uuid = { version = "1.2", features = ["v4"] } -rand = { version = "0.8.5", features = ["std"] } +chrono = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } +rand = { workspace = true } diff --git a/atuin-server/Cargo.toml b/atuin-server/Cargo.toml index 9b4df477..9a32f0d2 100644 --- a/atuin-server/Cargo.toml +++ b/atuin-server/Cargo.toml @@ -1,41 +1,35 @@ [package] name = "atuin-server" -version = "14.0.0" -authors = ["Ellie Huxtable "] edition = "2018" -license = "MIT" description = "server library for atuin" -homepage = "https://atuin.sh" -repository = "https://github.com/ellie/atuin" + +version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } [dependencies] atuin-common = { path = "../atuin-common", version = "14.0.0" } tracing = "0.1" -chrono = { version = "0.4", features = ["serde"] } -eyre = "0.6" -uuid = { version = "1.2", features = ["v4"] } -whoami = "1.1.2" -config = { version = "0.13", default-features = false, features = ["toml"] } -serde = { version = "1.0.145", features = ["derive"] } -serde_json = "1.0.86" -sodiumoxide = "0.2.6" -base64 = "0.21.0" -rand = "0.8.4" -tokio = { version = "1", features = ["full"] } -sqlx = { version = "0.6", features = [ - "runtime-tokio-rustls", - "chrono", - "postgres", -] } -async-trait = "0.1.58" +chrono = { workspace = true } +eyre = { workspace = true } +uuid = { workspace = true } +whoami = { workspace = true } +config = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sodiumoxide = { workspace = true } +base64 = { workspace = true } +rand = { workspace = true } +tokio = { workspace = true } +sqlx = { workspace = true } +async-trait = { workspace = true } axum = "0.6.4" http = "0.2" -fs-err = "2.9" +fs-err = { workspace = true } chronoutil = "0.2.3" tower = "0.4" tower-http = { version = "0.3", features = ["trace"] } -reqwest = { version = "0.11", features = [ - "json", - "rustls-tls-native-roots", -], default-features = false } +reqwest = { workspace = true } diff --git a/atuin/Cargo.toml b/atuin/Cargo.toml new file mode 100644 index 00000000..d9cbfe67 --- /dev/null +++ b/atuin/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "atuin" +edition = "2021" +rust-version = "1.59" +description = "atuin - magical shell history" +readme = "../README.md" + +version = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }.tar.gz" +bin-dir = "{ name }-v{ version }-{ target }/{ bin }{ binary-ext }" +pkg-fmt = "tgz" + +[package.metadata.deb] +maintainer = "Ellie Huxtable " +copyright = "2021, Ellie Huxtable " +license-file = ["LICENSE"] +depends = "$auto" +section = "utility" + +[package.metadata.rpm] +package = "atuin" + +[package.metadata.rpm.cargo] +buildflags = ["--release"] + +[package.metadata.rpm.targets] +atuin = { path = "/usr/bin/atuin" } + +[features] +# TODO(conradludgate) +# Currently, this keeps the same default built behaviour for v0.8 +# We should rethink this by the time we hit a new breaking change +default = ["client", "sync", "server"] +client = ["atuin-client"] +sync = ["atuin-client/sync"] +server = ["atuin-server", "tracing-subscriber"] + +[dependencies] +atuin-server = { path = "../atuin-server", version = "14.0.0", optional = true } +atuin-client = { path = "../atuin-client", version = "14.0.0", optional = true, default-features = false } +atuin-common = { path = "../atuin-common", version = "14.0.0" } + +log = { workspace = true } +env_logger = "0.10.0" +chrono = { version = "0.4", features = ["serde"] } +eyre = { workspace = true } +directories = { workspace = true } +indicatif = "0.17.1" +serde = { workspace = true } +serde_json = { workspace = true } +crossterm = { version = "0.26", features = ["use-dev-tty"] } +unicode-width = "0.1" +itertools = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +interim = { workspace = true } +base64 = { workspace = true } +crossbeam-channel = "0.5.1" +clap = { workspace = true } +clap_complete = "4.0.3" +fs-err = { workspace = true } +whoami = { workspace = true } +rpassword = "7.0" +semver = { workspace = true } +runtime-format = "0.1.2" +tiny-bip39 = "1" +futures-util = "0.3" +fuzzy-matcher = "0.3.7" +colored = "2.0.0" + +# ratatui +bitflags = "1.3" +cassowary = "0.3" +unicode-segmentation = "1.2" + +[dependencies.tracing-subscriber] +version = "0.3" +default-features = false +features = ["ansi", "fmt", "registry", "env-filter"] +optional = true diff --git a/atuin/src/command/client.rs b/atuin/src/command/client.rs new file mode 100644 index 00000000..2a825638 --- /dev/null +++ b/atuin/src/command/client.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use clap::Subcommand; +use eyre::{Result, WrapErr}; + +use atuin_client::{database::Sqlite, settings::Settings}; +use env_logger::Builder; + +#[cfg(feature = "sync")] +mod sync; + +mod history; +mod import; +mod search; +mod stats; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub enum Cmd { + /// Manipulate shell history + #[command(subcommand)] + History(history::Cmd), + + /// Import shell history from file + #[command(subcommand)] + Import(import::Cmd), + + /// Calculate statistics for your history + Stats(stats::Cmd), + + /// Interactive history search + Search(search::Cmd), + + #[cfg(feature = "sync")] + #[command(flatten)] + Sync(sync::Cmd), +} + +impl Cmd { + #[tokio::main(flavor = "current_thread")] + pub async fn run(self) -> Result<()> { + Builder::new() + .filter_level(log::LevelFilter::Off) + .parse_env("ATUIN_LOG") + .init(); + + let mut settings = Settings::new().wrap_err("could not load client settings")?; + + let db_path = PathBuf::from(settings.db_path.as_str()); + let mut db = Sqlite::new(db_path).await?; + + match self { + Self::History(history) => history.run(&settings, &mut db).await, + Self::Import(import) => import.run(&mut db).await, + Self::Stats(stats) => stats.run(&mut db, &settings).await, + Self::Search(search) => search.run(db, &mut settings).await, + #[cfg(feature = "sync")] + Self::Sync(sync) => sync.run(settings, &mut db).await, + } + } +} diff --git a/atuin/src/command/client/history.rs b/atuin/src/command/client/history.rs new file mode 100644 index 00000000..76c796ef --- /dev/null +++ b/atuin/src/command/client/history.rs @@ -0,0 +1,298 @@ +use std::{ + env, + fmt::{self, Display}, + io::{StdoutLock, Write}, + time::Duration, +}; + +use atuin_common::utils; +use clap::Subcommand; +use eyre::Result; +use runtime_format::{FormatKey, FormatKeyError, ParsedFmt}; + +use atuin_client::{ + database::{current_context, Database}, + history::History, + settings::Settings, +}; + +#[cfg(feature = "sync")] +use atuin_client::sync; +use log::debug; + +use super::search::format_duration; +use super::search::format_duration_into; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub enum Cmd { + /// Begins a new command in the history + Start { command: Vec }, + + /// Finishes a new command in the history (adds time, exit code) + End { + id: String, + #[arg(long, short)] + exit: i64, + }, + + /// List all items in history + List { + #[arg(long, short)] + cwd: bool, + + #[arg(long, short)] + session: bool, + + #[arg(long)] + human: bool, + + /// Show only the text of the command + #[arg(long)] + cmd_only: bool, + + /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. + /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" + #[arg(long, short)] + format: Option, + }, + + /// Get the last command ran + Last { + #[arg(long)] + human: bool, + + /// Show only the text of the command + #[arg(long)] + cmd_only: bool, + + /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. + /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" + #[arg(long, short)] + format: Option, + }, +} + +#[derive(Clone, Copy, Debug)] +pub enum ListMode { + Human, + CmdOnly, + Regular, +} + +impl ListMode { + pub const fn from_flags(human: bool, cmd_only: bool) -> Self { + if human { + ListMode::Human + } else if cmd_only { + ListMode::CmdOnly + } else { + ListMode::Regular + } + } +} + +#[allow(clippy::cast_sign_loss)] +pub fn print_list(h: &[History], list_mode: ListMode, format: Option<&str>) { + let w = std::io::stdout(); + let mut w = w.lock(); + + match list_mode { + ListMode::Human => print_human_list(&mut w, h, format), + ListMode::CmdOnly => print_cmd_only(&mut w, h), + ListMode::Regular => print_regular(&mut w, h, format), + } + + w.flush().expect("failed to flush history"); +} + +/// type wrapper around `History` so we can implement traits +struct FmtHistory<'a>(&'a History); + +/// defines how to format the history +impl FormatKey for FmtHistory<'_> { + #[allow(clippy::cast_sign_loss)] + fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { + match key { + "command" => f.write_str(self.0.command.trim())?, + "directory" => f.write_str(self.0.cwd.trim())?, + "exit" => f.write_str(&self.0.exit.to_string())?, + "duration" => { + let dur = Duration::from_nanos(std::cmp::max(self.0.duration, 0) as u64); + format_duration_into(dur, f)?; + } + "time" => self.0.timestamp.format("%Y-%m-%d %H:%M:%S").fmt(f)?, + "relativetime" => { + let since = chrono::Utc::now() - self.0.timestamp; + let time = format_duration(since.to_std().unwrap_or_default()); + f.write_str(&time)?; + } + "host" => f.write_str( + self.0 + .hostname + .split_once(':') + .map_or(&self.0.hostname, |(host, _)| host), + )?, + "user" => f.write_str(self.0.hostname.split_once(':').map_or("", |(_, user)| user))?, + _ => return Err(FormatKeyError::UnknownKey), + } + Ok(()) + } +} + +fn print_list_with(w: &mut StdoutLock, h: &[History], format: &str) { + let fmt = match ParsedFmt::new(format) { + Ok(fmt) => fmt, + Err(err) => { + eprintln!("ERROR: History formatting failed with the following error: {err}"); + println!("If your formatting string contains curly braces (eg: {{var}}) you need to escape them this way: {{{{var}}."); + std::process::exit(1) + } + }; + + for h in h.iter().rev() { + writeln!(w, "{}", fmt.with_args(&FmtHistory(h))).expect("failed to write history"); + } +} + +pub fn print_human_list(w: &mut StdoutLock, h: &[History], format: Option<&str>) { + let format = format + .unwrap_or("{time} · {duration}\t{command}") + .replace("\\t", "\t"); + print_list_with(w, h, &format); +} + +pub fn print_regular(w: &mut StdoutLock, h: &[History], format: Option<&str>) { + let format = format + .unwrap_or("{time}\t{command}\t{duration}") + .replace("\\t", "\t"); + print_list_with(w, h, &format); +} + +pub fn print_cmd_only(w: &mut StdoutLock, h: &[History]) { + for h in h.iter().rev() { + writeln!(w, "{}", h.command.trim()).expect("failed to write history"); + } +} + +impl Cmd { + pub async fn run(&self, settings: &Settings, db: &mut impl Database) -> Result<()> { + let context = current_context(); + + match self { + Self::Start { command: words } => { + let command = words.join(" "); + + if command.starts_with(' ') || settings.history_filter.is_match(&command) { + return Ok(()); + } + + // It's better for atuin to silently fail here and attempt to + // store whatever is ran, than to throw an error to the terminal + let cwd = utils::get_current_dir(); + + let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None, None); + + // print the ID + // we use this as the key for calling end + println!("{}", h.id); + db.save(&h).await?; + Ok(()) + } + + Self::End { id, exit } => { + if id.trim() == "" { + return Ok(()); + } + + let mut h = db.load(id).await?; + + if h.duration > 0 { + debug!("cannot end history - already has duration"); + + // returning OK as this can occur if someone Ctrl-c a prompt + return Ok(()); + } + + h.exit = *exit; + h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); + + db.update(&h).await?; + + if settings.should_sync()? { + #[cfg(feature = "sync")] + { + debug!("running periodic background sync"); + sync::sync(settings, false, db).await?; + } + #[cfg(not(feature = "sync"))] + debug!("not compiled with sync support"); + } else { + debug!("sync disabled! not syncing"); + } + + Ok(()) + } + + Self::List { + session, + cwd, + human, + cmd_only, + format, + } => { + let session = if *session { + Some(env::var("ATUIN_SESSION")?) + } else { + None + }; + let cwd = if *cwd { + Some(utils::get_current_dir()) + } else { + None + }; + + let history = match (session, cwd) { + (None, None) => db.list(settings.filter_mode, &context, None, false).await?, + (None, Some(cwd)) => { + let query = format!("select * from history where cwd = '{cwd}';"); + db.query_history(&query).await? + } + (Some(session), None) => { + let query = format!("select * from history where session = '{session}';"); + db.query_history(&query).await? + } + (Some(session), Some(cwd)) => { + let query = format!( + "select * from history where cwd = '{cwd}' and session = '{session}';", + ); + db.query_history(&query).await? + } + }; + + print_list( + &history, + ListMode::from_flags(*human, *cmd_only), + format.as_deref(), + ); + + Ok(()) + } + + Self::Last { + human, + cmd_only, + format, + } => { + let last = db.last().await?; + print_list( + &[last], + ListMode::from_flags(*human, *cmd_only), + format.as_deref(), + ); + + Ok(()) + } + } + } +} diff --git a/atuin/src/command/client/import.rs b/atuin/src/command/client/import.rs new file mode 100644 index 00000000..7abc3d44 --- /dev/null +++ b/atuin/src/command/client/import.rs @@ -0,0 +1,152 @@ +use std::env; + +use async_trait::async_trait; +use clap::Parser; +use eyre::Result; +use indicatif::ProgressBar; + +use atuin_client::{ + database::Database, + history::History, + import::{ + bash::Bash, fish::Fish, nu::Nu, nu_histdb::NuHistDb, resh::Resh, zsh::Zsh, + zsh_histdb::ZshHistDb, Importer, Loader, + }, +}; + +#[derive(Parser)] +#[command(infer_subcommands = true)] +pub enum Cmd { + /// Import history for the current shell + Auto, + + /// Import history from the zsh history file + Zsh, + /// Import history from the zsh history file + ZshHistDb, + /// Import history from the bash history file + Bash, + /// Import history from the resh history file + Resh, + /// Import history from the fish history file + Fish, + /// Import history from the nu history file + Nu, + /// Import history from the nu history file + NuHistDb, +} + +const BATCH_SIZE: usize = 100; + +impl Cmd { + pub async fn run(&self, db: &mut DB) -> Result<()> { + println!(" Atuin "); + println!("======================"); + println!(" \u{1f30d} "); + println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); + println!(" \u{1f422} "); + println!("======================"); + println!("Importing history..."); + + match self { + Self::Auto => { + if cfg!(windows) { + println!("This feature does not work on windows. Please run atuin import . To view a list of shells, run atuin import."); + return Ok(()); + } + + let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); + if shell.ends_with("/zsh") { + if ZshHistDb::histpath().is_ok() { + println!( + "Detected Zsh-HistDb, using :{}", + ZshHistDb::histpath().unwrap().to_str().unwrap() + ); + import::(db).await + } else { + println!("Detected ZSH"); + import::(db).await + } + } else if shell.ends_with("/fish") { + println!("Detected Fish"); + import::(db).await + } else if shell.ends_with("/bash") { + println!("Detected Bash"); + import::(db).await + } else if shell.ends_with("/nu") { + if NuHistDb::histpath().is_ok() { + println!( + "Detected Nu-HistDb, using :{}", + NuHistDb::histpath().unwrap().to_str().unwrap() + ); + import::(db).await + } else { + println!("Detected Nushell"); + import::(db).await + } + } else { + println!("cannot import {shell} history"); + Ok(()) + } + } + + Self::Zsh => import::(db).await, + Self::ZshHistDb => import::(db).await, + Self::Bash => import::(db).await, + Self::Resh => import::(db).await, + Self::Fish => import::(db).await, + Self::Nu => import::(db).await, + Self::NuHistDb => import::(db).await, + } + } +} + +pub struct HistoryImporter<'db, DB: Database> { + pb: ProgressBar, + buf: Vec, + db: &'db mut DB, +} + +impl<'db, DB: Database> HistoryImporter<'db, DB> { + fn new(db: &'db mut DB, len: usize) -> Self { + Self { + pb: ProgressBar::new(len as u64), + buf: Vec::with_capacity(BATCH_SIZE), + db, + } + } + + async fn flush(self) -> Result<()> { + if !self.buf.is_empty() { + self.db.save_bulk(&self.buf).await?; + } + self.pb.finish(); + Ok(()) + } +} + +#[async_trait] +impl<'db, DB: Database> Loader for HistoryImporter<'db, DB> { + async fn push(&mut self, hist: History) -> Result<()> { + self.pb.inc(1); + self.buf.push(hist); + if self.buf.len() == self.buf.capacity() { + self.db.save_bulk(&self.buf).await?; + self.buf.clear(); + } + Ok(()) + } +} + +async fn import(db: &mut DB) -> Result<()> { + println!("Importing history from {}", I::NAME); + + let mut importer = I::new().await?; + let len = importer.entries().await.unwrap(); + let mut loader = HistoryImporter::new(db, len); + importer.load(&mut loader).await?; + loader.flush().await?; + + println!("Import complete!"); + Ok(()) +} diff --git a/atuin/src/command/client/search.rs b/atuin/src/command/client/search.rs new file mode 100644 index 00000000..356ae251 --- /dev/null +++ b/atuin/src/command/client/search.rs @@ -0,0 +1,189 @@ +use atuin_common::utils; +use clap::Parser; +use eyre::Result; + +use atuin_client::{ + database::Database, + database::{current_context, OptFilters}, + history::History, + settings::{FilterMode, SearchMode, Settings}, +}; + +use super::history::ListMode; + +mod cursor; +mod duration; +mod engines; +mod history_list; +mod interactive; +pub use duration::{format_duration, format_duration_into}; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Parser)] +pub struct Cmd { + /// Filter search result by directory + #[arg(long, short)] + cwd: Option, + + /// Exclude directory from results + #[arg(long = "exclude-cwd")] + exclude_cwd: Option, + + /// Filter search result by exit code + #[arg(long, short)] + exit: Option, + + /// Exclude results with this exit code + #[arg(long = "exclude-exit")] + exclude_exit: Option, + + /// Only include results added before this date + #[arg(long, short)] + before: Option, + + /// Only include results after this date + #[arg(long)] + after: Option, + + /// How many entries to return at most + #[arg(long)] + limit: Option, + + /// Offset from the start of the results + #[arg(long)] + offset: Option, + + /// Open interactive search UI + #[arg(long, short)] + interactive: bool, + + /// Allow overriding filter mode over config + #[arg(long = "filter-mode")] + filter_mode: Option, + + /// Allow overriding search mode over config + #[arg(long = "search-mode")] + search_mode: Option, + + /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion) + #[arg(long = "shell-up-key-binding", hide = true)] + shell_up_key_binding: bool, + + /// Use human-readable formatting for time + #[arg(long)] + human: bool, + + query: Vec, + + /// Show only the text of the command + #[arg(long)] + cmd_only: bool, + + /// Delete anything matching this query. Will not print out the match + #[arg(long)] + delete: bool, + + /// Reverse the order of results, oldest first + #[arg(long, short)] + reverse: bool, + + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and + /// {relativetime}. + /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" + #[arg(long, short)] + format: Option, +} + +impl Cmd { + pub async fn run(self, mut db: impl Database, settings: &mut Settings) -> Result<()> { + if self.search_mode.is_some() { + settings.search_mode = self.search_mode.unwrap(); + } + if self.filter_mode.is_some() { + settings.filter_mode = self.filter_mode.unwrap(); + } + + settings.shell_up_key_binding = self.shell_up_key_binding; + + if self.interactive { + let item = interactive::history(&self.query, settings, db).await?; + eprintln!("{item}"); + } else { + let list_mode = ListMode::from_flags(self.human, self.cmd_only); + + let opt_filter = OptFilters { + exit: self.exit, + exclude_exit: self.exclude_exit, + cwd: self.cwd, + exclude_cwd: self.exclude_cwd, + before: self.before, + after: self.after, + limit: self.limit, + offset: self.offset, + reverse: self.reverse, + }; + + let mut entries = + run_non_interactive(settings, opt_filter.clone(), &self.query, &mut db).await?; + + if entries.is_empty() { + std::process::exit(1) + } + + // if we aren't deleting, print it all + if self.delete { + // delete it + // it only took me _years_ to add this + // sorry + while !entries.is_empty() { + for entry in &entries { + eprintln!("deleting {}", entry.id); + db.delete(entry.clone()).await?; + } + + entries = + run_non_interactive(settings, opt_filter.clone(), &self.query, &mut db) + .await?; + } + } else { + super::history::print_list(&entries, list_mode, self.format.as_deref()); + } + }; + Ok(()) + } +} + +// This is supposed to more-or-less mirror the command line version, so ofc +// it is going to have a lot of args +#[allow(clippy::too_many_arguments)] +async fn run_non_interactive( + settings: &Settings, + filter_options: OptFilters, + query: &[String], + db: &mut impl Database, +) -> Result> { + let dir = if filter_options.cwd.as_deref() == Some(".") { + Some(utils::get_current_dir()) + } else { + filter_options.cwd + }; + + let context = current_context(); + + let opt_filter = OptFilters { + cwd: dir, + ..filter_options + }; + + let results = db + .search( + settings.search_mode, + settings.filter_mode, + &context, + query.join(" ").as_str(), + opt_filter, + ) + .await?; + + Ok(results) +} diff --git a/atuin/src/command/client/search/cursor.rs b/atuin/src/command/client/search/cursor.rs new file mode 100644 index 00000000..2bce4f37 --- /dev/null +++ b/atuin/src/command/client/search/cursor.rs @@ -0,0 +1,333 @@ +use atuin_client::settings::WordJumpMode; + +pub struct Cursor { + source: String, + index: usize, +} + +impl From for Cursor { + fn from(source: String) -> Self { + Self { source, index: 0 } + } +} + +pub struct WordJumper<'a> { + word_chars: &'a str, + word_jump_mode: WordJumpMode, +} + +impl WordJumper<'_> { + fn is_word_boundary(&self, c: char, next_c: char) -> bool { + (c.is_whitespace() && !next_c.is_whitespace()) + || (!c.is_whitespace() && next_c.is_whitespace()) + || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) + || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) + } + + fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { + let index = (index + 1..source.len().saturating_sub(1)) + .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(source.len()); + (index + 1..source.len().saturating_sub(1)) + .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(source.len()) + } + + fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { + let index = (1..index) + .rev() + .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(0); + (1..index) + .rev() + .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) + .map_or(0, |i| i + 1) + } + + fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { + let index = (index..source.len().saturating_sub(1)).find(|&i| { + self.is_word_boundary( + source.chars().nth(i).unwrap(), + source.chars().nth(i + 1).unwrap(), + ) + }); + if index.is_none() { + return source.len(); + } + (index.unwrap() + 1..source.len()) + .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) + .unwrap_or(source.len()) + } + + fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { + let index = (1..index) + .rev() + .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); + if index.is_none() { + return 0; + } + (1..index.unwrap()) + .rev() + .find(|&i| { + self.is_word_boundary( + source.chars().nth(i - 1).unwrap(), + source.chars().nth(i).unwrap(), + ) + }) + .unwrap_or(0) + } + + fn get_next_word_pos(&self, source: &str, index: usize) -> usize { + match self.word_jump_mode { + WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), + WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), + } + } + + fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { + match self.word_jump_mode { + WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), + WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), + } + } +} + +impl Cursor { + pub fn as_str(&self) -> &str { + self.source.as_str() + } + + pub fn into_inner(self) -> String { + self.source + } + + /// Returns the string before the cursor + pub fn substring(&self) -> &str { + &self.source[..self.index] + } + + /// Returns the currently selected [`char`] + pub fn char(&self) -> Option { + self.source[self.index..].chars().next() + } + + pub fn right(&mut self) { + if self.index < self.source.len() { + loop { + self.index += 1; + if self.source.is_char_boundary(self.index) { + break; + } + } + } + } + + pub fn left(&mut self) -> bool { + if self.index > 0 { + loop { + self.index -= 1; + if self.source.is_char_boundary(self.index) { + break true; + } + } + } else { + false + } + } + + pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + self.index = word_jumper.get_next_word_pos(&self.source, self.index); + } + + pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + self.index = word_jumper.get_prev_word_pos(&self.source, self.index); + } + + pub fn insert(&mut self, c: char) { + self.source.insert(self.index, c); + self.index += c.len_utf8(); + } + + pub fn remove(&mut self) -> Option { + if self.index < self.source.len() { + Some(self.source.remove(self.index)) + } else { + None + } + } + + pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + let next_index = word_jumper.get_next_word_pos(&self.source, self.index); + self.source.replace_range(self.index..next_index, ""); + } + + pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); + self.source.replace_range(next_index..self.index, ""); + self.index = next_index; + } + + pub fn back(&mut self) -> Option { + if self.left() { + self.remove() + } else { + None + } + } + + pub fn clear(&mut self) { + self.source.clear(); + self.index = 0; + } + + pub fn end(&mut self) { + self.index = self.source.len(); + } + + pub fn start(&mut self) { + self.index = 0; + } +} + +#[cfg(test)] +mod cursor_tests { + use super::Cursor; + use super::*; + + static EMACS_WORD_JUMPER: WordJumper = WordJumper { + word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + word_jump_mode: WordJumpMode::Emacs, + }; + + static SUBL_WORD_JUMPER: WordJumper = WordJumper { + word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", + word_jump_mode: WordJumpMode::Subl, + }; + + #[test] + fn right() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; + for i in indices { + assert_eq!(c.index, i); + c.right(); + } + } + + #[test] + fn left() { + // ö is 2 bytes + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + c.end(); + let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; + for i in indices { + assert_eq!(c.index, i); + c.left(); + } + } + + #[test] + fn test_emacs_get_next_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; + for (i_src, i_dest) in indices { + assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); + } + assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); + } + + #[test] + fn test_emacs_get_prev_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; + for (i_src, i_dest) in indices { + assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); + } + assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); + } + + #[test] + fn test_subl_get_next_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; + for (i_src, i_dest) in indices { + assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); + } + assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); + } + + #[test] + fn test_subl_get_prev_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; + for (i_src, i_dest) in indices { + assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); + } + assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); + } + + #[test] + fn pop() { + let mut s = String::from("öaöböcödöeöfö"); + let mut c = Cursor::from(s.clone()); + c.end(); + while !s.is_empty() { + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + assert_eq!(s.as_str(), c.substring()); + } + let c1 = s.pop(); + let c2 = c.back(); + assert_eq!(c1, c2); + } + + #[test] + fn back() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { + c.right(); + } + assert_eq!(c.substring(), "öaöb"); + assert_eq!(c.back(), Some('b')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), Some('a')); + assert_eq!(c.back(), Some('ö')); + assert_eq!(c.back(), None); + assert_eq!(c.as_str(), "öcödöeöfö"); + } + + #[test] + fn insert() { + let mut c = Cursor::from(String::from("öaöböcödöeöfö")); + // move to ^ + for _ in 0..4 { + c.right(); + } + assert_eq!(c.substring(), "öaöb"); + c.insert('ö'); + c.insert('g'); + c.insert('ö'); + c.insert('h'); + assert_eq!(c.substring(), "öaöbögöh"); + assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); + } +} diff --git a/atuin/src/command/client/search/duration.rs b/atuin/src/command/client/search/duration.rs new file mode 100644 index 00000000..08dadb95 --- /dev/null +++ b/atuin/src/command/client/search/duration.rs @@ -0,0 +1,62 @@ +use core::fmt; +use std::{ops::ControlFlow, time::Duration}; + +#[allow(clippy::module_name_repetitions)] +pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { + if value > 0 { + ControlFlow::Break((unit, value)) + } else { + ControlFlow::Continue(()) + } + } + + // impl taken and modified from + // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 + // Copyright (c) 2016 The humantime Developers + fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { + let secs = f.as_secs(); + let nanos = f.subsec_nanos(); + + let years = secs / 31_557_600; // 365.25d + let year_days = secs % 31_557_600; + let months = year_days / 2_630_016; // 30.44d + let month_days = year_days % 2_630_016; + let days = month_days / 86400; + let day_secs = month_days % 86400; + let hours = day_secs / 3600; + let minutes = day_secs % 3600 / 60; + let seconds = day_secs % 60; + + let millis = nanos / 1_000_000; + + // a difference from our impl than the original is that + // we only care about the most-significant segment of the duration. + // If the item call returns `Break`, then the `?` will early-return. + // This allows for a very consise impl + item("y", years)?; + item("mo", months)?; + item("d", days)?; + item("h", hours)?; + item("m", minutes)?; + item("s", seconds)?; + item("ms", u64::from(millis))?; + ControlFlow::Continue(()) + } + + match fmt(dur) { + ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), + ControlFlow::Continue(()) => write!(f, "0s"), + } +} + +#[allow(clippy::module_name_repetitions)] +pub fn format_duration(f: Duration) -> String { + struct F(Duration); + impl fmt::Display for F { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + format_duration_into(self.0, f) + } + } + F(f).to_string() +} diff --git a/atuin/src/command/client/search/engines.rs b/atuin/src/command/client/search/engines.rs new file mode 100644 index 00000000..878b1431 --- /dev/null +++ b/atuin/src/command/client/search/engines.rs @@ -0,0 +1,46 @@ +use async_trait::async_trait; +use atuin_client::{ + database::{Context, Database}, + history::History, + settings::{FilterMode, SearchMode}, +}; +use eyre::Result; + +use super::cursor::Cursor; + +pub mod db; +pub mod skim; + +pub fn engine(search_mode: SearchMode) -> Box { + match search_mode { + SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, + mode => Box::new(db::Search(mode)) as Box<_>, + } +} + +pub struct SearchState { + pub input: Cursor, + pub filter_mode: FilterMode, + pub context: Context, +} + +#[async_trait] +pub trait SearchEngine: Send + Sync + 'static { + async fn full_query( + &mut self, + state: &SearchState, + db: &mut dyn Database, + ) -> Result>; + + async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result> { + if state.input.as_str().is_empty() { + Ok(db + .list(state.filter_mode, &state.context, Some(200), true) + .await? + .into_iter() + .collect::>()) + } else { + self.full_query(state, db).await + } + } +} diff --git a/atuin/src/command/client/search/engines/db.rs b/atuin/src/command/client/search/engines/db.rs new file mode 100644 index 00000000..b4f24561 --- /dev/null +++ b/atuin/src/command/client/search/engines/db.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; +use atuin_client::{ + database::Database, database::OptFilters, history::History, settings::SearchMode, +}; +use eyre::Result; + +use super::{SearchEngine, SearchState}; + +pub struct Search(pub SearchMode); + +#[async_trait] +impl SearchEngine for Search { + async fn full_query( + &mut self, + state: &SearchState, + db: &mut dyn Database, + ) -> Result> { + Ok(db + .search( + self.0, + state.filter_mode, + &state.context, + state.input.as_str(), + OptFilters { + limit: Some(200), + ..Default::default() + }, + ) + .await? + .into_iter() + .collect::>()) + } +} diff --git a/atuin/src/command/client/search/engines/skim.rs b/atuin/src/command/client/search/engines/skim.rs new file mode 100644 index 00000000..76049312 --- /dev/null +++ b/atuin/src/command/client/search/engines/skim.rs @@ -0,0 +1,145 @@ +use std::path::Path; + +use async_trait::async_trait; +use atuin_client::{database::Database, history::History, settings::FilterMode}; +use chrono::Utc; +use eyre::Result; +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +use tokio::task::yield_now; + +use super::{SearchEngine, SearchState}; + +pub struct Search { + all_history: Vec<(History, i32)>, + engine: SkimMatcherV2, +} + +impl Search { + pub fn new() -> Self { + Search { + all_history: vec![], + engine: SkimMatcherV2::default(), + } + } +} + +#[async_trait] +impl SearchEngine for Search { + async fn full_query( + &mut self, + state: &SearchState, + db: &mut dyn Database, + ) -> Result> { + if self.all_history.is_empty() { + self.all_history = db.all_with_count().await.unwrap(); + } + + Ok(fuzzy_search(&self.engine, state, &self.all_history).await) + } +} + +async fn fuzzy_search( + engine: &SkimMatcherV2, + state: &SearchState, + all_history: &[(History, i32)], +) -> Vec { + let mut set = Vec::with_capacity(200); + let mut ranks = Vec::with_capacity(200); + let query = state.input.as_str(); + let now = Utc::now(); + + for (i, (history, count)) in all_history.iter().enumerate() { + if i % 256 == 0 { + yield_now().await; + } + match state.filter_mode { + FilterMode::Global => {} + FilterMode::Host if history.hostname == state.context.hostname => {} + FilterMode::Session if history.session == state.context.session => {} + FilterMode::Directory if history.cwd == state.context.cwd => {} + _ => continue, + } + #[allow(clippy::cast_lossless, clippy::cast_precision_loss)] + if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) { + let begin = indices.first().copied().unwrap_or_default(); + + let mut duration = ((now - history.timestamp).num_seconds() as f64).log2(); + if !duration.is_finite() || duration <= 1.0 { + duration = 1.0; + } + // these + X.0 just make the log result a bit smoother. + // log is very spiky towards 1-4, but I want a gradual decay. + // eg: + // log2(4) = 2, log2(5) = 2.3 (16% increase) + // log2(8) = 3, log2(9) = 3.16 (5% increase) + // log2(16) = 4, log2(17) = 4.08 (2% increase) + let count = (*count as f64 + 8.0).log2(); + let begin = (begin as f64 + 16.0).log2(); + let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref()); + let path = (path as f64 + 8.0).log2(); + + // reduce longer durations, raise higher counts, raise matches close to the start + let score = (-score as f64) * count / path / duration / begin; + + 'insert: { + // algorithm: + // 1. find either the position that this command ranks + // 2. find the same command positioned better than our rank. + for i in 0..set.len() { + // do we out score the corrent position? + if ranks[i] > score { + ranks.insert(i, score); + set.insert(i, history.clone()); + let mut j = i + 1; + while j < set.len() { + // remove duplicates that have a worse score + if set[j].command == history.command { + ranks.remove(j); + set.remove(j); + + // break this while loop because there won't be any other + // duplicates. + break; + } + j += 1; + } + + // keep it limited + if ranks.len() > 200 { + ranks.pop(); + set.pop(); + } + + break 'insert; + } + // don't continue if this command has a better score already + if set[i].command == history.command { + break 'insert; + } + } + + if set.len() < 200 { + ranks.push(score); + set.push(history.clone()); + } + } + } + } + + set +} + +fn path_dist(a: &Path, b: &Path) -> usize { + let mut a: Vec<_> = a.components().collect(); + let b: Vec<_> = b.components().collect(); + + let mut dist = 0; + + // pop a until there's a common anscestor + while !b.starts_with(&a) { + dist += 1; + a.pop(); + } + + b.len() - a.len() + dist +} diff --git a/atuin/src/command/client/search/history_list.rs b/atuin/src/command/client/search/history_list.rs new file mode 100644 index 00000000..eedab1a5 --- /dev/null +++ b/atuin/src/command/client/search/history_list.rs @@ -0,0 +1,183 @@ +use std::time::Duration; + +use crate::ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, StatefulWidget, Widget}, +}; +use atuin_client::history::History; + +use super::format_duration; + +pub struct HistoryList<'a> { + history: &'a [History], + block: Option>, +} + +#[derive(Default)] +pub struct ListState { + offset: usize, + selected: usize, + max_entries: usize, +} + +impl ListState { + pub fn selected(&self) -> usize { + self.selected + } + + pub fn max_entries(&self) -> usize { + self.max_entries + } + + pub fn select(&mut self, index: usize) { + self.selected = index; + } +} + +impl<'a> StatefulWidget for HistoryList<'a> { + type State = ListState; + + fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let list_area = self.block.take().map_or(area, |b| { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + }); + + if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() { + return; + } + let list_height = list_area.height as usize; + + let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); + state.offset = start; + state.max_entries = end - start; + + let mut s = DrawState { + buf, + list_area, + x: 0, + y: 0, + state, + }; + + for item in self.history.iter().skip(state.offset).take(end - start) { + s.index(); + s.duration(item); + s.time(item); + s.command(item); + + // reset line + s.y += 1; + s.x = 0; + } + } +} + +impl<'a> HistoryList<'a> { + pub fn new(history: &'a [History]) -> Self { + Self { + history, + block: None, + } + } + + pub fn block(mut self, block: Block<'a>) -> Self { + self.block = Some(block); + self + } + + fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) { + let offset = offset.min(self.history.len().saturating_sub(1)); + + let max_scroll_space = height.min(10); + if offset + height < selected + max_scroll_space { + let end = selected + max_scroll_space; + (end - height, end) + } else if selected < offset { + (selected, selected + height) + } else { + (offset, offset + height) + } + } +} + +struct DrawState<'a> { + buf: &'a mut Buffer, + list_area: Rect, + x: u16, + y: u16, + state: &'a ListState, +} + +// longest line prefix I could come up with +#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length +pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16; + +impl DrawState<'_> { + fn index(&mut self) { + // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. + // Yes, this is a hack, but it makes me feel happy + static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 "; + + let i = self.y as usize + self.state.offset; + let i = i.checked_sub(self.state.selected); + let i = i.unwrap_or(10).min(10) * 2; + self.draw(&SLICES[i..i + 3], Style::default()); + } + + fn duration(&mut self, h: &History) { + let status = Style::default().fg(if h.success() { + Color::Green + } else { + Color::Red + }); + let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); + self.draw(&format_duration(duration), status); + } + + #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6 + fn time(&mut self, h: &History) { + let style = Style::default().fg(Color::Blue); + + // Account for the chance that h.timestamp is "in the future" + // This would mean that "since" is negative, and the unwrap here + // would fail. + // If the timestamp would otherwise be in the future, display + // the time since as 0. + let since = chrono::Utc::now() - h.timestamp; + let time = format_duration(since.to_std().unwrap_or_default()); + + // pad the time a little bit before we write. this aligns things nicely + self.x = PREFIX_LENGTH - 4 - time.len() as u16; + + self.draw(&time, style); + self.draw(" ago", style); + } + + fn command(&mut self, h: &History) { + let mut style = Style::default(); + if self.y as usize + self.state.offset == self.state.selected { + style = style.fg(Color::Red).add_modifier(Modifier::BOLD); + } + + for section in h.command.split_ascii_whitespace() { + self.x += 1; + if self.x > self.list_area.width { + // Avoid attempting to draw a command section beyond the width + // of the list + return; + } + self.draw(section, style); + } + } + + fn draw(&mut self, s: &str, style: Style) { + let cx = self.list_area.left() + self.x; + let cy = self.list_area.bottom() - self.y - 1; + let w = (self.list_area.width - self.x) as usize; + self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx; + } +} diff --git a/atuin/src/command/client/search/interactive.rs b/atuin/src/command/client/search/interactive.rs new file mode 100644 index 00000000..300bc791 --- /dev/null +++ b/atuin/src/command/client/search/interactive.rs @@ -0,0 +1,588 @@ +use std::{ + io::{stdout, Write}, + time::Duration, +}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}, + execute, terminal, +}; +use eyre::Result; +use futures_util::FutureExt; +use semver::Version; +use unicode_width::UnicodeWidthStr; + +use atuin_client::{ + database::{current_context, Database}, + history::History, + settings::{ExitMode, FilterMode, SearchMode, Settings}, +}; + +use super::{ + cursor::Cursor, + engines::{SearchEngine, SearchState}, + history_list::{HistoryList, ListState, PREFIX_LENGTH}, +}; +use crate::ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, BorderType, Borders, Paragraph}, + Frame, Terminal, TerminalOptions, Viewport, +}; +use crate::{command::client::search::engines, VERSION}; + +const RETURN_ORIGINAL: usize = usize::MAX; +const RETURN_QUERY: usize = usize::MAX - 1; + +struct State { + history_count: i64, + update_needed: Option, + results_state: ListState, + switched_search_mode: bool, + search_mode: SearchMode, + + search: SearchState, + engine: Box, +} + +impl State { + async fn query_results(&mut self, db: &mut dyn Database) -> Result> { + let results = self.engine.query(&self.search, db).await?; + self.results_state.select(0); + Ok(results) + } + + fn handle_input(&mut self, settings: &Settings, input: &Event, len: usize) -> Option { + match input { + Event::Key(k) => self.handle_key_input(settings, k, len), + Event::Mouse(m) => self.handle_mouse_input(*m, len), + Event::Paste(d) => self.handle_paste_input(d), + _ => None, + } + } + + fn handle_mouse_input(&mut self, input: MouseEvent, len: usize) -> Option { + match input.kind { + event::MouseEventKind::ScrollDown => { + let i = self.results_state.selected().saturating_sub(1); + self.results_state.select(i); + } + event::MouseEventKind::ScrollUp => { + let i = self.results_state.selected() + 1; + self.results_state.select(i.min(len - 1)); + } + _ => {} + } + None + } + + fn handle_paste_input(&mut self, input: &str) -> Option { + for i in input.chars() { + self.search.input.insert(i); + } + None + } + + #[allow(clippy::too_many_lines)] + #[allow(clippy::cognitive_complexity)] + fn handle_key_input( + &mut self, + settings: &Settings, + input: &KeyEvent, + len: usize, + ) -> Option { + if input.kind == event::KeyEventKind::Release { + return None; + } + + let ctrl = input.modifiers.contains(KeyModifiers::CONTROL); + let alt = input.modifiers.contains(KeyModifiers::ALT); + // reset the state, will be set to true later if user really did change it + self.switched_search_mode = false; + match input.code { + KeyCode::Char('c' | 'd' | 'g') if ctrl => return Some(RETURN_ORIGINAL), + KeyCode::Esc => { + return Some(match settings.exit_mode { + ExitMode::ReturnOriginal => RETURN_ORIGINAL, + ExitMode::ReturnQuery => RETURN_QUERY, + }) + } + KeyCode::Enter => { + return Some(self.results_state.selected()); + } + KeyCode::Char(c @ '1'..='9') if alt => { + let c = c.to_digit(10)? as usize; + return Some(self.results_state.selected() + c); + } + KeyCode::Left if ctrl => self + .search + .input + .prev_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Char('b') if alt => self + .search + .input + .prev_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Left => { + self.search.input.left(); + } + KeyCode::Char('h') if ctrl => { + self.search.input.left(); + } + KeyCode::Char('b') if ctrl => { + self.search.input.left(); + } + KeyCode::Right if ctrl => self + .search + .input + .next_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Char('f') if alt => self + .search + .input + .next_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Right => self.search.input.right(), + KeyCode::Char('l') if ctrl => self.search.input.right(), + KeyCode::Char('f') if ctrl => self.search.input.right(), + KeyCode::Char('a') if ctrl => self.search.input.start(), + KeyCode::Home => self.search.input.start(), + KeyCode::Char('e') if ctrl => self.search.input.end(), + KeyCode::End => self.search.input.end(), + KeyCode::Backspace if ctrl => self + .search + .input + .remove_prev_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Backspace => { + self.search.input.back(); + } + KeyCode::Delete if ctrl => self + .search + .input + .remove_next_word(&settings.word_chars, settings.word_jump_mode), + KeyCode::Delete => { + self.search.input.remove(); + } + KeyCode::Char('w') if ctrl => { + // remove the first batch of whitespace + while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} + while self.search.input.left() { + if self.search.input.char().unwrap().is_whitespace() { + self.search.input.right(); // found whitespace, go back right + break; + } + self.search.input.remove(); + } + } + KeyCode::Char('u') if ctrl => self.search.input.clear(), + KeyCode::Char('r') if ctrl => { + pub static FILTER_MODES: [FilterMode; 4] = [ + FilterMode::Global, + FilterMode::Host, + FilterMode::Session, + FilterMode::Directory, + ]; + let i = self.search.filter_mode as usize; + let i = (i + 1) % FILTER_MODES.len(); + self.search.filter_mode = FILTER_MODES[i]; + } + KeyCode::Char('s') if ctrl => { + self.switched_search_mode = true; + self.search_mode = self.search_mode.next(settings); + self.engine = engines::engine(self.search_mode); + } + KeyCode::Down if self.results_state.selected() == 0 => { + return Some(match settings.exit_mode { + ExitMode::ReturnOriginal => RETURN_ORIGINAL, + ExitMode::ReturnQuery => RETURN_QUERY, + }) + } + KeyCode::Down => { + let i = self.results_state.selected().saturating_sub(1); + self.results_state.select(i); + } + KeyCode::Char('n' | 'j') if ctrl => { + let i = self.results_state.selected().saturating_sub(1); + self.results_state.select(i); + } + KeyCode::Up => { + let i = self.results_state.selected() + 1; + self.results_state.select(i.min(len - 1)); + } + KeyCode::Char('p' | 'k') if ctrl => { + let i = self.results_state.selected() + 1; + self.results_state.select(i.min(len - 1)); + } + KeyCode::Char(c) => self.search.input.insert(c), + KeyCode::PageDown => { + let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; + let i = self.results_state.selected().saturating_sub(scroll_len); + self.results_state.select(i); + } + KeyCode::PageUp => { + let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; + let i = self.results_state.selected() + scroll_len; + self.results_state.select(i.min(len - 1)); + } + _ => {} + }; + + None + } + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::bool_to_int_with_if)] + fn draw( + &mut self, + f: &mut Frame<'_, T>, + results: &[History], + compact: bool, + show_preview: bool, + ) { + let border_size = if compact { 0 } else { 1 }; + let preview_width = f.size().width - 2; + let preview_height = if show_preview { + let longest_command = results + .iter() + .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); + longest_command.map_or(0, |v| { + std::cmp::min( + 4, + (v.command.len() as u16 + preview_width - 1 - border_size) + / (preview_width - border_size), + ) + }) + border_size * 2 + } else if compact { + 0 + } else { + 1 + }; + let show_help = !compact || f.size().height > 1; + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .horizontal_margin(1) + .constraints( + [ + Constraint::Length(if show_help { 1 } else { 0 }), + Constraint::Min(1), + Constraint::Length(1 + border_size), + Constraint::Length(preview_height), + ] + .as_ref(), + ) + .split(f.size()); + + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ] + .as_ref(), + ) + .split(chunks[0]); + + let title = self.build_title(); + f.render_widget(title, header_chunks[0]); + + let help = self.build_help(); + f.render_widget(help, header_chunks[1]); + + let stats = self.build_stats(); + f.render_widget(stats, header_chunks[2]); + + let results_list = Self::build_results_list(compact, results); + f.render_stateful_widget(results_list, chunks[1], &mut self.results_state); + + let input = self.build_input(compact, chunks[2].width.into()); + f.render_widget(input, chunks[2]); + + let preview = self.build_preview(results, compact, preview_width, chunks[3].width.into()); + f.render_widget(preview, chunks[3]); + + let extra_width = UnicodeWidthStr::width(self.search.input.substring()); + + let cursor_offset = if compact { 0 } else { 1 }; + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset, + chunks[2].y + cursor_offset, + ); + } + + fn build_title(&mut self) -> Paragraph { + let title = if self.update_needed.is_some() { + let version = self.update_needed.clone().unwrap(); + + Paragraph::new(Text::from(Span::styled( + format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"), + Style::default().add_modifier(Modifier::BOLD).fg(Color::Red), + ))) + } else { + Paragraph::new(Text::from(Span::styled( + format!(" Atuin v{VERSION}"), + Style::default().add_modifier(Modifier::BOLD), + ))) + }; + title + } + + #[allow(clippy::unused_self)] + fn build_help(&mut self) -> Paragraph { + let help = Paragraph::new(Text::from(Spans::from(vec![ + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit"), + ]))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + help + } + + fn build_stats(&mut self) -> Paragraph { + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + self.history_count, + )))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + stats + } + + fn build_results_list(compact: bool, results: &[History]) -> HistoryList { + let results_list = if compact { + HistoryList::new(results) + } else { + HistoryList::new(results).block( + Block::default() + .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) + .border_type(BorderType::Rounded), + ) + }; + results_list + } + + fn build_input(&mut self, compact: bool, chunk_width: usize) -> Paragraph { + /// Max width of the UI box showing current mode + const MAX_WIDTH: usize = 14; + let (pref, mode) = if self.switched_search_mode { + (" SRCH:", self.search_mode.as_str()) + } else { + ("", self.search.filter_mode.as_str()) + }; + let mode_width = MAX_WIDTH - pref.len(); + // sanity check to ensure we don't exceed the layout limits + debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!"); + let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),); + let input = if compact { + Paragraph::new(input) + } else { + Paragraph::new(input).block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(BorderType::Rounded) + .title(format!("{:─>width$}", "", width = chunk_width - 2)), + ) + }; + input + } + + fn build_preview( + &mut self, + results: &[History], + compact: bool, + preview_width: u16, + chunk_width: usize, + ) -> Paragraph { + let selected = self.results_state.selected(); + let command = if results.is_empty() { + String::new() + } else { + use itertools::Itertools as _; + let s = &results[selected].command; + s.char_indices() + .step_by(preview_width.into()) + .map(|(i, _)| i) + .chain(Some(s.len())) + .tuple_windows() + .map(|(a, b)| &s[a..b]) + .join("\n") + }; + let preview = if compact { + Paragraph::new(command).style(Style::default().fg(Color::DarkGray)) + } else { + Paragraph::new(command).block( + Block::default() + .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) + .border_type(BorderType::Rounded) + .title(format!("{:─>width$}", "", width = chunk_width - 2)), + ) + }; + preview + } +} + +struct Stdout { + stdout: std::io::Stdout, + inline_mode: bool, +} + +impl Stdout { + pub fn new(inline_mode: bool) -> std::io::Result { + terminal::enable_raw_mode()?; + let mut stdout = stdout(); + if !inline_mode { + execute!(stdout, terminal::EnterAlternateScreen)?; + } + execute!( + stdout, + event::EnableMouseCapture, + event::EnableBracketedPaste, + )?; + Ok(Self { + stdout, + inline_mode, + }) + } +} + +impl Drop for Stdout { + fn drop(&mut self) { + if !self.inline_mode { + execute!(self.stdout, terminal::LeaveAlternateScreen).unwrap(); + } + execute!( + self.stdout, + event::DisableMouseCapture, + event::DisableBracketedPaste, + ) + .unwrap(); + terminal::disable_raw_mode().unwrap(); + } +} + +impl Write for Stdout { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.stdout.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.stdout.flush() + } +} + +// this is a big blob of horrible! clean it up! +// for now, it works. But it'd be great if it were more easily readable, and +// modular. I'd like to add some more stats and stuff at some point +#[allow(clippy::cast_possible_truncation)] +pub async fn history( + query: &[String], + settings: &Settings, + mut db: impl Database, +) -> Result { + let stdout = Stdout::new(settings.inline_height > 0)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: if settings.inline_height > 0 { + Viewport::Inline(settings.inline_height) + } else { + Viewport::Fullscreen + }, + }, + )?; + + let mut input = Cursor::from(query.join(" ")); + // Put the cursor at the end of the query by default + input.end(); + + let settings2 = settings.clone(); + let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse(); + tokio::pin!(update_needed); + + let context = current_context(); + + let history_count = db.history_count().await?; + + let mut app = State { + history_count, + results_state: ListState::default(), + update_needed: None, + switched_search_mode: false, + search_mode: settings.search_mode, + search: SearchState { + input, + context, + filter_mode: if settings.shell_up_key_binding { + settings + .filter_mode_shell_up_key_binding + .unwrap_or(settings.filter_mode) + } else { + settings.filter_mode + }, + }, + engine: engines::engine(settings.search_mode), + }; + + let mut results = app.query_results(&mut db).await?; + + let index = 'render: loop { + let compact = match settings.style { + atuin_client::settings::Style::Auto => { + terminal.size().map(|size| size.height < 14).unwrap_or(true) + } + atuin_client::settings::Style::Compact => true, + atuin_client::settings::Style::Full => false, + }; + terminal.draw(|f| app.draw(f, &results, compact, settings.show_preview))?; + + let initial_input = app.search.input.as_str().to_owned(); + let initial_filter_mode = app.search.filter_mode; + let initial_search_mode = app.search_mode; + + let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); + + tokio::select! { + event_ready = event_ready => { + if event_ready?? { + loop { + if let Some(i) = app.handle_input(settings, &event::read()?, results.len()) { + break 'render i; + } + if !event::poll(Duration::ZERO)? { + break; + } + } + } + } + update_needed = &mut update_needed => { + app.update_needed = update_needed?; + } + } + + if initial_input != app.search.input.as_str() + || initial_filter_mode != app.search.filter_mode + || initial_search_mode != app.search_mode + { + results = app.query_results(&mut db).await?; + } + }; + + if settings.inline_height > 0 { + terminal.clear()?; + } + + if index < results.len() { + // index is in bounds so we return that entry + Ok(results.swap_remove(index).command) + } else if index == RETURN_ORIGINAL { + Ok(String::new()) + } else { + // Either: + // * index == RETURN_QUERY, in which case we should return the input + // * out of bounds -> usually implies no selected entry so we return the input + Ok(app.search.input.into_inner()) + } +} diff --git a/atuin/src/command/client/stats.rs b/atuin/src/command/client/stats.rs new file mode 100644 index 00000000..5134f22f --- /dev/null +++ b/atuin/src/command/client/stats.rs @@ -0,0 +1,181 @@ +use std::collections::{HashMap, HashSet}; + +use chrono::{prelude::*, Duration}; +use clap::Parser; +use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; +use eyre::{bail, Result}; +use interim::parse_date_string; + +use atuin_client::{ + database::{current_context, Database}, + history::History, + settings::{FilterMode, Settings}, +}; + +#[derive(Parser)] +#[command(infer_subcommands = true)] +pub struct Cmd { + /// compute statistics for the specified period, leave blank for statistics since the beginning + period: Vec, + + /// How many top commands to list + #[arg(long, short, default_value = "10")] + count: usize, +} + +fn compute_stats(history: &[History], count: usize) -> Result<()> { + let mut commands = HashSet::<&str>::with_capacity(history.len()); + let mut prefixes = HashMap::<&str, usize>::with_capacity(history.len()); + for i in history { + // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes) + let command = i.command.trim(); + commands.insert(command); + *prefixes.entry(interesting_command(command)).or_default() += 1; + } + + let unique = commands.len(); + let mut top = prefixes.into_iter().collect::>(); + top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1)); + top.truncate(count); + if top.is_empty() { + bail!("No commands found"); + } + + let max = top.iter().map(|x| x.1).max().unwrap(); + let num_pad = max.ilog10() as usize + 1; + + for (command, count) in top { + let gray = SetForegroundColor(Color::Grey); + let bold = SetAttribute(crossterm::style::Attribute::Bold); + + let in_ten = 10 * count / max; + print!("["); + print!("{}", SetForegroundColor(Color::Red)); + for i in 0..in_ten { + if i == 2 { + print!("{}", SetForegroundColor(Color::Yellow)); + } + if i == 5 { + print!("{}", SetForegroundColor(Color::Green)); + } + print!("▮"); + } + for _ in in_ten..10 { + print!(" "); + } + + println!("{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{command}{ResetColor}"); + } + println!("Total commands: {}", history.len()); + println!("Unique commands: {unique}"); + + Ok(()) +} + +impl Cmd { + pub async fn run(&self, db: &mut impl Database, settings: &Settings) -> Result<()> { + let context = current_context(); + let words = if self.period.is_empty() { + String::from("all") + } else { + self.period.join(" ") + }; + let history = if words.as_str() == "all" { + db.list(FilterMode::Global, &context, None, false).await? + } else if words.trim() == "today" { + let start = Local::now().date().and_hms(0, 0, 0); + let end = start + Duration::days(1); + db.range(start.into(), end.into()).await? + } else if words.trim() == "month" { + let end = Local::now().date().and_hms(0, 0, 0); + let start = end - Duration::days(31); + db.range(start.into(), end.into()).await? + } else if words.trim() == "week" { + let end = Local::now().date().and_hms(0, 0, 0); + let start = end - Duration::days(7); + db.range(start.into(), end.into()).await? + } else if words.trim() == "year" { + let end = Local::now().date().and_hms(0, 0, 0); + let start = end - Duration::days(365); + db.range(start.into(), end.into()).await? + } else { + let start = parse_date_string(&words, Local::now(), settings.dialect.into())?; + let end = start + Duration::days(1); + db.range(start.into(), end.into()).await? + }; + compute_stats(&history, self.count)?; + Ok(()) + } +} + +// TODO: make this configurable? +static COMMON_COMMAND_PREFIX: &[&str] = &["sudo"]; +static COMMON_SUBCOMMAND_PREFIX: &[&str] = &["cargo", "go", "git", "npm", "yarn", "pnpm"]; + +fn first_non_whitespace(s: &str) -> Option { + s.char_indices() + // find the first non whitespace char + .find(|(_, c)| !c.is_ascii_whitespace()) + // return the index of that char + .map(|(i, _)| i) +} + +fn first_whitespace(s: &str) -> usize { + s.char_indices() + // find the first whitespace char + .find(|(_, c)| c.is_ascii_whitespace()) + // return the index of that char, (or the max length of the string) + .map_or(s.len(), |(i, _)| i) +} + +fn interesting_command(mut command: &str) -> &str { + // compute command prefix + // we loop here because we might be working with a common command prefix (eg sudo) that we want to trim off + let (i, prefix) = loop { + let i = first_whitespace(command); + let prefix = &command[..i]; + + // is it a common prefix + if COMMON_COMMAND_PREFIX.contains(&prefix) { + command = command[i..].trim_start(); + if command.is_empty() { + // no commands following, just use the prefix + return prefix; + } + } else { + break (i, prefix); + } + }; + + // compute subcommand + let subcommand_indices = command + // after the end of the command prefix + .get(i..) + // find the first non whitespace character (start of subcommand) + .and_then(first_non_whitespace) + // then find the end of that subcommand + .map(|j| i + j + first_whitespace(&command[i + j..])); + + match subcommand_indices { + // if there is a subcommand and it's a common one, then count the full prefix + subcommand + Some(end) if COMMON_SUBCOMMAND_PREFIX.contains(&prefix) => &command[..end], + // otherwise just count the main command + _ => prefix, + } +} + +#[cfg(test)] +mod tests { + use super::interesting_command; + + #[test] + fn interesting_commands() { + assert_eq!(interesting_command("cargo"), "cargo"); + assert_eq!(interesting_command("cargo build foo bar"), "cargo build"); + assert_eq!( + interesting_command("sudo cargo build foo bar"), + "cargo build" + ); + assert_eq!(interesting_command("sudo"), "sudo"); + } +} diff --git a/atuin/src/command/client/sync.rs b/atuin/src/command/client/sync.rs new file mode 100644 index 00000000..419177a5 --- /dev/null +++ b/atuin/src/command/client/sync.rs @@ -0,0 +1,74 @@ +use clap::Subcommand; +use eyre::{Result, WrapErr}; + +use atuin_client::{database::Database, settings::Settings}; + +mod login; +mod logout; +mod register; +mod status; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub enum Cmd { + /// Sync with the configured server + Sync { + /// Force re-download everything + #[arg(long, short)] + force: bool, + }, + + /// Login to the configured server + Login(login::Cmd), + + /// Log out + Logout, + + /// Register with the configured server + Register(register::Cmd), + + /// Print the encryption key for transfer to another machine + Key { + /// Switch to base64 output of the key + #[arg(long)] + base64: bool, + }, + + Status, +} + +impl Cmd { + pub async fn run(self, settings: Settings, db: &mut impl Database) -> Result<()> { + match self { + Self::Sync { force } => run(&settings, force, db).await, + Self::Login(l) => l.run(&settings).await, + Self::Logout => logout::run(&settings), + Self::Register(r) => r.run(&settings).await, + Self::Status => status::run(&settings, db).await, + Self::Key { base64 } => { + use atuin_client::encryption::{encode_key, load_key}; + let key = load_key(&settings).wrap_err("could not load encryption key")?; + + if base64 { + let encode = encode_key(key).wrap_err("could not encode encryption key")?; + println!("{encode}"); + } else { + let mnemonic = bip39::Mnemonic::from_entropy(&key.0, bip39::Language::English) + .map_err(|_| eyre::eyre!("invalid key"))?; + println!("{mnemonic}"); + } + Ok(()) + } + } + } +} + +async fn run(settings: &Settings, force: bool, db: &mut impl Database) -> Result<()> { + atuin_client::sync::sync(settings, force, db).await?; + println!( + "Sync complete! {} items in database, force: {}", + db.history_count().await?, + force + ); + Ok(()) +} diff --git a/atuin/src/command/client/sync/login.rs b/atuin/src/command/client/sync/login.rs new file mode 100644 index 00000000..6aa2d847 --- /dev/null +++ b/atuin/src/command/client/sync/login.rs @@ -0,0 +1,147 @@ +use std::{io, path::PathBuf}; + +use clap::Parser; +use eyre::{bail, Context, ContextCompat, Result}; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{ + api_client, + encryption::{decode_key, encode_key, new_key, Key}, + settings::Settings, +}; +use atuin_common::api::LoginRequest; +use rpassword::prompt_password; + +#[derive(Parser)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + /// The encryption key for your account + #[clap(long, short)] + pub key: Option, +} + +fn get_input() -> Result { + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) +} + +impl Cmd { + pub async fn run(&self, settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + println!( + "You are already logged in! Please run 'atuin logout' if you wish to login again" + ); + + return Ok(()); + } + + let username = or_user_input(&self.username, "username"); + let key = or_user_input(&self.key, "encryption key [blank to use existing key file]"); + let password = self.password.clone().unwrap_or_else(read_user_password); + + let key_path = settings.key_path.as_str(); + if key.is_empty() { + if PathBuf::from(key_path).exists() { + let bytes = fs_err::read_to_string(key_path) + .context("existing key file couldn't be read")?; + if decode_key(bytes).is_err() { + bail!("the key in existing key file was invalid"); + } + } else { + println!("No key file exists, creating a new"); + let _key = new_key(settings)?; + } + } else { + // try parse the key as a mnemonic... + let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) { + Ok(mnemonic) => encode_key( + Key::from_slice(mnemonic.entropy()) + .context("key was not the correct length")?, + )?, + Err(err) => { + if let Some(err) = err.downcast_ref::() { + match err { + // assume they copied in the base64 key + bip39::ErrorKind::InvalidWord => key, + bip39::ErrorKind::InvalidChecksum => { + bail!("key mnemonic was not valid") + } + bip39::ErrorKind::InvalidKeysize(_) + | bip39::ErrorKind::InvalidWordLength(_) + | bip39::ErrorKind::InvalidEntropyLength(_, _) => { + bail!("key was not the correct length") + } + } + } else { + // unknown error. assume they copied the base64 key + key + } + } + }; + + if decode_key(key.clone()).is_err() { + bail!("the specified key was invalid"); + } + + let mut file = File::create(key_path).await?; + file.write_all(key.as_bytes()).await?; + } + + let session = api_client::login( + settings.sync_address.as_str(), + LoginRequest { username, password }, + ) + .await?; + + let session_path = settings.session_path.as_str(); + let mut file = File::create(session_path).await?; + file.write_all(session.session.as_bytes()).await?; + + println!("Logged in!"); + + Ok(()) + } +} + +pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { + value.clone().unwrap_or_else(|| read_user_input(name)) +} + +pub(super) fn read_user_password() -> String { + let password = prompt_password("Please enter password: "); + password.expect("Failed to read from input") +} + +fn read_user_input(name: &'static str) -> String { + eprint!("Please enter {name}: "); + get_input().expect("Failed to read from input") +} + +#[cfg(test)] +mod tests { + use atuin_client::encryption::Key; + + #[test] + fn mnemonic_round_trip() { + let key = Key { + 0: [ + 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, 6, 2, 6, 4, 3, 3, 8, 3, + 2, 7, 9, 5, + ], + }; + let phrase = bip39::Mnemonic::from_entropy(&key.0, bip39::Language::English) + .unwrap() + .into_phrase(); + let mnemonic = bip39::Mnemonic::from_phrase(&phrase, bip39::Language::English).unwrap(); + assert_eq!(mnemonic.entropy(), &key.0); + assert_eq!(phrase, "adapt amused able anxiety mother adapt beef gaze amount else seat alcohol cage lottery avoid scare alcohol cactus school avoid coral adjust catch pink"); + } +} diff --git a/atuin/src/command/client/sync/logout.rs b/atuin/src/command/client/sync/logout.rs new file mode 100644 index 00000000..90b49d6d --- /dev/null +++ b/atuin/src/command/client/sync/logout.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +use eyre::{Context, Result}; +use fs_err::remove_file; + +use atuin_client::settings::Settings; + +pub fn run(settings: &Settings) -> Result<()> { + let session_path = settings.session_path.as_str(); + + if PathBuf::from(session_path).exists() { + remove_file(session_path).context("Failed to remove session file")?; + println!("You have logged out!"); + } else { + println!("You are not logged in"); + } + + Ok(()) +} diff --git a/atuin/src/command/client/sync/register.rs b/atuin/src/command/client/sync/register.rs new file mode 100644 index 00000000..6b51fac8 --- /dev/null +++ b/atuin/src/command/client/sync/register.rs @@ -0,0 +1,49 @@ +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::{api_client, settings::Settings}; + +#[derive(Parser)] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + #[clap(long, short)] + pub email: Option, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.username, &self.email, &self.password).await + } +} + +pub async fn run( + settings: &Settings, + username: &Option, + email: &Option, + password: &Option, +) -> Result<()> { + use super::login::or_user_input; + let username = or_user_input(username, "username"); + let email = or_user_input(email, "email"); + let password = password + .clone() + .unwrap_or_else(super::login::read_user_password); + + let session = + api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; + + let path = settings.session_path.as_str(); + let mut file = File::create(path).await?; + file.write_all(session.session.as_bytes()).await?; + + // Create a new key, and save it to disk + let _key = atuin_client::encryption::new_key(settings)?; + + Ok(()) +} diff --git a/atuin/src/command/client/sync/status.rs b/atuin/src/command/client/sync/status.rs new file mode 100644 index 00000000..b3e73e8e --- /dev/null +++ b/atuin/src/command/client/sync/status.rs @@ -0,0 +1,35 @@ +use atuin_client::{ + api_client, database::Database, encryption::load_encoded_key, settings::Settings, +}; +use colored::Colorize; +use eyre::Result; + +pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> { + let client = api_client::Client::new( + &settings.sync_address, + &settings.session_token, + load_encoded_key(settings)?, + )?; + + let status = client.status().await?; + let last_sync = Settings::last_sync()?; + let local_count = db.history_count().await?; + + println!("{}", "[Local]".green()); + + if settings.auto_sync { + println!("Sync frequency: {}", settings.sync_frequency); + println!("Last sync: {last_sync}"); + } + + println!("History count: {local_count}\n"); + + if settings.auto_sync { + println!("{}", "[Remote]".green()); + println!("Address: {}", settings.sync_address); + println!("Username: {}", status.username); + println!("History count: {}", status.count); + } + + Ok(()) +} diff --git a/atuin/src/command/contributors.rs b/atuin/src/command/contributors.rs new file mode 100644 index 00000000..6f21b5fe --- /dev/null +++ b/atuin/src/command/contributors.rs @@ -0,0 +1,75 @@ +const CONTRIBUTORS: &str = r#" +Baptiste +Benjamin Vergnaud +Brad Robel-Forrest +Bruce Huang +Conrad Ludgate +CosmicHorror +Daniel +Ellie Huxtable +Eric Crosson +Eric Ripa +Erwin Kroon +Evan Purkhiser +Frank Hamand +Herby Gillot +Ian Smith +Ilkin Bayramli +Violet Shreve +Jakob Schrettenbrunner +Jakob-Niklas See +Jakub Jirutka +Jakub Panek +Jamie Quigley +Jannik +Jerome Ducret +Johannes Baiter +Klas Mellbourn +Laurent le Beau-Martin +Lucas Burns +Lucy +Luke Baker +Manel Vilar +Mark Wotton +Martin Indra +Martin Junghanns +Mat Jones +Michael Bianco +Michael Mior +Omer Katz +Orhun Parmaksız +Patrick +Patrick Decat +Patrick Jackson +Plamen Dimitrov +Sam Edwards +Sam Lanning +Sandro +Satyarth Sampath +Simon Elsbrock +Tobias Hunger +Trygve Aaberge +TymanWasTaken +Ubiquitous Photon +Webmaster At Cosmic DNA +Will Fancher +Yolo +Yuvi Panda +ZhiHong Li +avinassh +b3nj5m1n +c-14 +frukto +jean-santos +lchausmann +mb6ockatf +morguldir +mundry +noyez +wpbrz +xfzv +"#; + +pub fn run() { + println!("{CONTRIBUTORS}"); +} diff --git a/atuin/src/command/init.rs b/atuin/src/command/init.rs new file mode 100644 index 00000000..a9c24b09 --- /dev/null +++ b/atuin/src/command/init.rs @@ -0,0 +1,144 @@ +use clap::{Parser, ValueEnum}; + +#[derive(Parser)] +pub struct Cmd { + shell: Shell, + + /// Disable the binding of CTRL-R to atuin + #[clap(long)] + disable_ctrl_r: bool, + + /// Disable the binding of the Up Arrow key to atuin + #[clap(long)] + disable_up_arrow: bool, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum Shell { + /// Zsh setup + Zsh, + /// Bash setup + Bash, + /// Fish setup + Fish, + /// Nu setup + Nu, +} + +impl Cmd { + fn init_zsh(&self) { + let base = include_str!("../shell/atuin.zsh"); + + println!("{base}"); + + if std::env::var("ATUIN_NOBIND").is_err() { + const BIND_CTRL_R: &str = "bindkey '^r' _atuin_search_widget"; + const BIND_UP_ARROW: &str = "bindkey '^[[A' _atuin_up_search_widget +bindkey '^[OA' _atuin_up_search_widget"; + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + } + } + + fn init_bash(&self) { + let base = include_str!("../shell/atuin.bash"); + println!("{base}"); + + if std::env::var("ATUIN_NOBIND").is_err() { + const BIND_CTRL_R: &str = r#"bind -x '"\C-r": __atuin_history'"#; + const BIND_UP_ARROW: &str = r#"bind -x '"\e[A": __atuin_history --shell-up-key-binding' +bind -x '"\eOA": __atuin_history --shell-up-key-binding'"#; + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + } + } + + fn init_fish(&self) { + let full = include_str!("../shell/atuin.fish"); + println!("{full}"); + + if std::env::var("ATUIN_NOBIND").is_err() { + const BIND_CTRL_R: &str = r"bind \cr _atuin_search"; + const BIND_UP_ARROW: &str = r"bind -k up _atuin_bind_up +bind \eOA _atuin_bind_up +bind \e\[A _atuin_bind_up"; + const BIND_CTRL_R_INS: &str = r"bind -M insert \cr _atuin_search"; + const BIND_UP_ARROW_INS: &str = r"bind -M insert -k up _atuin_bind_up +bind -M insert \eOA _atuin_bind_up +bind -M insert \e\[A _atuin_bind_up"; + + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + + println!("if bind -M insert > /dev/null 2>&1"); + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R_INS}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW_INS}"); + } + println!("end"); + } + } + + fn init_nu(&self) { + let full = include_str!("../shell/atuin.nu"); + println!("{full}"); + + if std::env::var("ATUIN_NOBIND").is_err() { + const BIND_CTRL_R: &str = r#"let-env config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: control + keycode: char_r + mode: [emacs, vi_normal, vi_insert] + event: { send: executehostcommand cmd: (_atuin_search_cmd) } + } + ) +) +"#; + const BIND_UP_ARROW: &str = r#"let-env config = ( + $env.config | upsert keybindings ( + $env.config.keybindings + | append { + name: atuin + modifier: none + keycode: up + mode: [emacs, vi_normal, vi_insert] + event: { send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } + } + ) +) +"#; + if !self.disable_ctrl_r { + println!("{BIND_CTRL_R}"); + } + if !self.disable_up_arrow { + println!("{BIND_UP_ARROW}"); + } + } + } + + pub fn run(self) { + match self.shell { + Shell::Zsh => self.init_zsh(), + Shell::Bash => self.init_bash(), + Shell::Fish => self.init_fish(), + Shell::Nu => self.init_nu(), + } + } +} diff --git a/atuin/src/command/mod.rs b/atuin/src/command/mod.rs new file mode 100644 index 00000000..4ed1691a --- /dev/null +++ b/atuin/src/command/mod.rs @@ -0,0 +1,87 @@ +use clap::{CommandFactory, Subcommand}; +use clap_complete::{generate, generate_to, Shell}; +use eyre::Result; + +#[cfg(feature = "client")] +mod client; + +#[cfg(feature = "server")] +mod server; + +mod init; + +mod contributors; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub enum AtuinCmd { + #[cfg(feature = "client")] + #[command(flatten)] + Client(client::Cmd), + + /// Start an atuin server + #[cfg(feature = "server")] + #[command(subcommand)] + Server(server::Cmd), + + /// Output shell setup + Init(init::Cmd), + + /// Generate a UUID + Uuid, + + Contributors, + + /// Generate shell completions + GenCompletions { + /// Set the shell for generating completions + #[arg(long, short)] + shell: Shell, + + /// Set the output directory + #[arg(long, short)] + out_dir: Option, + }, +} + +impl AtuinCmd { + pub fn run(self) -> Result<()> { + match self { + #[cfg(feature = "client")] + Self::Client(client) => client.run(), + #[cfg(feature = "server")] + Self::Server(server) => server.run(), + Self::Contributors => { + contributors::run(); + Ok(()) + } + Self::Init(init) => { + init.run(); + Ok(()) + } + Self::Uuid => { + println!("{}", atuin_common::utils::uuid_v7().as_simple()); + Ok(()) + } + Self::GenCompletions { shell, out_dir } => { + let mut cli = crate::Atuin::command(); + + match out_dir { + Some(out_dir) => { + generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; + } + None => { + generate( + shell, + &mut cli, + env!("CARGO_PKG_NAME"), + &mut std::io::stdout(), + ); + } + } + + Ok(()) + } + } + } +} diff --git a/atuin/src/command/server.rs b/atuin/src/command/server.rs new file mode 100644 index 00000000..495f85d0 --- /dev/null +++ b/atuin/src/command/server.rs @@ -0,0 +1,44 @@ +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +use clap::Parser; +use eyre::{Context, Result}; + +use atuin_server::{launch, settings::Settings}; + +#[derive(Parser)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Start the server + Start { + /// The host address to bind + #[clap(long)] + host: Option, + + /// The port to bind + #[clap(long, short)] + port: Option, + }, +} + +impl Cmd { + #[tokio::main] + pub async fn run(self) -> Result<()> { + tracing_subscriber::registry() + .with(fmt::layer()) + .with(EnvFilter::from_default_env()) + .init(); + + let settings = Settings::new().wrap_err("could not load server settings")?; + + match self { + Self::Start { host, port } => { + let host = host + .as_ref() + .map_or(settings.host.clone(), std::string::ToString::to_string); + let port = port.map_or(settings.port, |p| p); + + launch(settings, host, port).await + } + } + } +} diff --git a/atuin/src/main.rs b/atuin/src/main.rs new file mode 100644 index 00000000..9e570337 --- /dev/null +++ b/atuin/src/main.rs @@ -0,0 +1,45 @@ +#![warn(clippy::pedantic, clippy::nursery)] +#![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable + +use clap::Parser; +use eyre::Result; + +use command::AtuinCmd; +mod command; + +#[allow(clippy::all)] +mod ratatui; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +static HELP_TEMPLATE: &str = "\ +{before-help}{name} {version} +{author} +{about} + +{usage-heading} + {usage} + +{all-args}{after-help}"; + +/// Magical shell history +#[derive(Parser)] +#[command( + author = "Ellie Huxtable ", + version = VERSION, + help_template(HELP_TEMPLATE), +)] +struct Atuin { + #[command(subcommand)] + atuin: AtuinCmd, +} + +impl Atuin { + fn run(self) -> Result<()> { + self.atuin.run() + } +} + +fn main() -> Result<()> { + Atuin::parse().run() +} diff --git a/atuin/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..0a0f4bc6 --- /dev/null +++ b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +--- +name: Bug report +about: Create an issue about a bug you encountered +title: '' +labels: bug +assignees: '' +--- + + + +## Description + + + +## To Reproduce + + + +## Expected behavior + + + +## Screenshots + + + +## Environment + + +- OS: +- Terminal Emulator: +- Font: +- Crate version: +- Backend: + +## Additional context + diff --git a/atuin/src/ratatui/.github/ISSUE_TEMPLATE/config.yml b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/atuin/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..ae095edb --- /dev/null +++ b/atuin/src/ratatui/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +## Problem + + +## Solution + + +## Alternatives + + +## Additional context + diff --git a/atuin/src/ratatui/.github/workflows/cd.yml b/atuin/src/ratatui/.github/workflows/cd.yml new file mode 100644 index 00000000..f61e3603 --- /dev/null +++ b/atuin/src/ratatui/.github/workflows/cd.yml @@ -0,0 +1,19 @@ +name: Continuous Deployment + +on: + push: + tags: + - "v*.*.*" + +jobs: + publish: + name: Publish on crates.io + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v3 + - name: Publish + uses: actions-rs/cargo@v1 + with: + command: publish + args: --token ${{ secrets.CARGO_TOKEN }} diff --git a/atuin/src/ratatui/.github/workflows/ci.yml b/atuin/src/ratatui/.github/workflows/ci.yml new file mode 100644 index 00000000..bfa363e9 --- /dev/null +++ b/atuin/src/ratatui/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +on: + push: + branches: + - main + pull_request: + branches: + - main + +name: CI + +env: + CI_CARGO_MAKE_VERSION: 0.35.16 + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + rust: ["1.59.0", "stable"] + include: + - os: ubuntu-latest + triple: x86_64-unknown-linux-musl + - os: windows-latest + triple: x86_64-pc-windows-msvc + - os: macos-latest + triple: x86_64-apple-darwin + runs-on: ${{ matrix.os }} + steps: + - uses: hecrj/setup-rust-action@50a120e4d34903c2c1383dec0e9b1d349a9cc2b1 + with: + rust-version: ${{ matrix.rust }} + components: rustfmt,clippy + - uses: actions/checkout@v3 + - name: Install cargo-make on Linux or macOS + if: ${{ runner.os != 'windows' }} + shell: bash + run: | + curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' + unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' + cp 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}/cargo-make' ~/.cargo/bin/ + cargo make --version + - name: Install cargo-make on Windows + if: ${{ runner.os == 'windows' }} + shell: bash + run: | + # `cargo-make-v0.35.16-{target}/` directory is created on Linux and macOS, but it is not creatd on Windows. + mkdir cargo-make-temporary + cd cargo-make-temporary + curl -LO 'https://github.com/sagiegurari/cargo-make/releases/download/${{ env.CI_CARGO_MAKE_VERSION }}/cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' + unzip 'cargo-make-v${{ env.CI_CARGO_MAKE_VERSION }}-${{ matrix.triple }}.zip' + cp cargo-make.exe ~/.cargo/bin/ + cd .. + cargo make --version + - name: "Format / Build / Test" + run: cargo make ci + env: + RUST_BACKTRACE: full + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + if: github.event_name != 'pull_request' + uses: actions/checkout@v3 + - name: Checkout + if: github.event_name == 'pull_request' + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: "Check conventional commits" + uses: crate-ci/committed@master + with: + args: "-vv" + commits: "HEAD" + - name: "Check typos" + uses: crate-ci/typos@master diff --git a/atuin/src/ratatui/.gitignore b/atuin/src/ratatui/.gitignore new file mode 100644 index 00000000..dcb33fbb --- /dev/null +++ b/atuin/src/ratatui/.gitignore @@ -0,0 +1,6 @@ +target +Cargo.lock +*.log +*.rs.rustfmt +.gdb_history +.idea/ diff --git a/atuin/src/ratatui/LICENSE b/atuin/src/ratatui/LICENSE new file mode 100644 index 00000000..7a0657cb --- /dev/null +++ b/atuin/src/ratatui/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Florian Dehau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/atuin/src/ratatui/README.md b/atuin/src/ratatui/README.md new file mode 100644 index 00000000..05d4adb6 --- /dev/null +++ b/atuin/src/ratatui/README.md @@ -0,0 +1,136 @@ +# ratatui + +An actively maintained `tui`-rs fork. + +[![Build Status](https://github.com/tui-rs-revival/ratatui/workflows/CI/badge.svg)](https://github.com/tui-rs-revival/ratatui/actions?query=workflow%3ACI+) +[![Crate Status](https://img.shields.io/crates/v/ratatui.svg)](https://crates.io/crates/ratatui) +[![Docs Status](https://docs.rs/ratatui/badge.svg)](https://docs.rs/crate/ratatui/) + +Demo cast under Linux Termite with Inconsolata font 12pt + +# Install + +```toml +[dependencies] +tui = { package = "ratatui" } +``` + +# What is this fork? + +This fork was created to continue maintenance on the original TUI project. The original maintainer had created an [issue](https://github.com/fdehau/tui-rs/issues/654) explaining how he couldn't find time to continue development, which led to us creating this fork. + +With that in mind, **we the community** look forward to continuing the work started by [**Florian Dehau.**](https://github.com/fdehau) :rocket: + +In order to organize ourselves, we currently use a [discord server](https://discord.gg/pMCEU9hNEj), feel free to join and come chat ! There are also plans to implement a [matrix](https://matrix.org/) bridge in the near future. +**Discord is not a MUST to contribute,** we follow a pretty standard github centered open source workflow keeping the most important conversations on github, open an issue or PR and it will be addressed. :smile: + +Please make sure you read the updated contributing guidelines, especially if you are interested in working on a PR or issue opened in the previous repository. + +# Introduction + +`ratatui`-rs is a [Rust](https://www.rust-lang.org) library to build rich terminal +user interfaces and dashboards. It is heavily inspired by the `Javascript` +library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the +`Go` library [termui](https://github.com/gizak/termui). + +The library supports multiple backends: + +- [crossterm](https://github.com/crossterm-rs/crossterm) [default] +- [termion](https://github.com/ticki/termion) + +The library is based on the principle of immediate rendering with intermediate +buffers. This means that at each new frame you should build all widgets that are +supposed to be part of the UI. While providing a great flexibility for rich and +interactive UI, this may introduce overhead for highly dynamic content. So, the +implementation try to minimize the number of ansi escapes sequences generated to +draw the updated UI. In practice, given the speed of `Rust` the overhead rather +comes from the terminal emulator than the library itself. + +Moreover, the library does not provide any input handling nor any event system and +you may rely on the previously cited libraries to achieve such features. + +## Rust version requirements + +Since version 0.17.0, `ratatui` requires **rustc version 1.59.0 or greater**. + +# Documentation + +The documentation can be found on [docs.rs.](https://docs.rs/ratatui) + +# Demo + +The demo shown in the gif can be run with all available backends. + +``` +# crossterm +cargo run --example demo --release -- --tick-rate 200 +# termion +cargo run --example demo --no-default-features --features=termion --release -- --tick-rate 200 +``` + +where `tick-rate` is the UI refresh rate in ms. + +The UI code is in [examples/demo/ui.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/ui.rs) while the +application state is in [examples/demo/app.rs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/demo/app.rs). + +If the user interface contains glyphs that are not displayed correctly by your terminal, you may want to run +the demo without those symbols: + +``` +cargo run --example demo --release -- --tick-rate 200 --enhanced-graphics false +``` + +# Widgets + +## Built in + +The library comes with the following list of widgets: + +- [Block](https://github.com/tui-rs-revival/ratatui/blob/main/examples/block.rs) +- [Gauge](https://github.com/tui-rs-revival/ratatui/blob/main/examples/gauge.rs) +- [Sparkline](https://github.com/tui-rs-revival/ratatui/blob/main/examples/sparkline.rs) +- [Chart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/chart.rs) +- [BarChart](https://github.com/tui-rs-revival/ratatui/blob/main/examples/barchart.rs) +- [List](https://github.com/tui-rs-revival/ratatui/blob/main/examples/list.rs) +- [Table](https://github.com/tui-rs-revival/ratatui/blob/main/examples/table.rs) +- [Paragraph](https://github.com/tui-rs-revival/ratatui/blob/main/examples/paragraph.rs) +- [Canvas (with line, point cloud, map)](https://github.com/tui-rs-revival/ratatui/blob/main/examples/canvas.rs) +- [Tabs](https://github.com/tui-rs-revival/ratatui/blob/main/examples/tabs.rs) + +Click on each item to see the source of the example. Run the examples with with +cargo (e.g. to run the gauge example `cargo run --example gauge`), and quit by pressing `q`. + +You can run all examples by running `cargo make run-examples` (require +`cargo-make` that can be installed with `cargo install cargo-make`). + +### Third-party libraries, bootstrapping templates and widgets + +- [ansi-to-tui](https://github.com/uttarayan21/ansi-to-tui) — Convert ansi colored text to `tui::text::Text` +- [color-to-tui](https://github.com/uttarayan21/color-to-tui) — Parse hex colors to `tui::style::Color` +- [rust-tui-template](https://github.com/orhun/rust-tui-template) — A template for bootstrapping a Rust TUI application with Tui-rs & crossterm +- [simple-tui-rs](https://github.com/pmsanford/simple-tui-rs) — A simple example tui-rs app +- [tui-builder](https://github.com/jkelleyrtp/tui-builder) — Batteries-included MVC framework for Tui-rs + Crossterm apps +- [tui-clap](https://github.com/kegesch/tui-clap-rs) — Use clap-rs together with Tui-rs +- [tui-log](https://github.com/kegesch/tui-log-rs) — Example of how to use logging with Tui-rs +- [tui-logger](https://github.com/gin66/tui-logger) — Logger and Widget for Tui-rs +- [tui-realm](https://github.com/veeso/tui-realm) — Tui-rs framework to build stateful applications with a React/Elm inspired approach +- [tui-realm-treeview](https://github.com/veeso/tui-realm-treeview) — Treeview component for Tui-realm +- [tui tree widget](https://github.com/EdJoPaTo/tui-rs-tree-widget) — Tree Widget for Tui-rs +- [tui-windows](https://github.com/markatk/tui-windows-rs) — Tui-rs abstraction to handle multiple windows and their rendering +- [tui-textarea](https://github.com/rhysd/tui-textarea): Simple yet powerful multi-line text editor widget supporting several key shortcuts, undo/redo, text search, etc. +- [tui-rs-tree-widgets](https://github.com/EdJoPaTo/tui-rs-tree-widget): Widget for tree data structures. +- [tui-input](https://github.com/sayanarijit/tui-input): TUI input library supporting multiple backends and tui-rs. + +# Apps + +Check out the list of [close to 40 apps](./APPS.md) using `ratatui`! + +# Alternatives + +You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an +alternative solution to build text user interfaces in Rust. + +# License + +[MIT](LICENSE) + diff --git a/atuin/src/ratatui/backend/crossterm.rs b/atuin/src/ratatui/backend/crossterm.rs new file mode 100644 index 00000000..3dceb6ad --- /dev/null +++ b/atuin/src/ratatui/backend/crossterm.rs @@ -0,0 +1,241 @@ +use crate::ratatui::{ + backend::{Backend, ClearType}, + buffer::Cell, + layout::Rect, + style::{Color, Modifier}, +}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + execute, queue, + style::{ + Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor, + SetForegroundColor, + }, + terminal::{self, Clear}, +}; +use std::io::{self, Write}; + +pub struct CrosstermBackend { + buffer: W, +} + +impl CrosstermBackend +where + W: Write, +{ + pub fn new(buffer: W) -> CrosstermBackend { + CrosstermBackend { buffer } + } +} + +impl Write for CrosstermBackend +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +impl Backend for CrosstermBackend +where + W: Write, +{ + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option<(u16, u16)> = None; + for (x, y, cell) in content { + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) { + map_error(queue!(self.buffer, MoveTo(x, y)))?; + } + last_pos = Some((x, y)); + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(&mut self.buffer)?; + modifier = cell.modifier; + } + if cell.fg != fg { + let color = CColor::from(cell.fg); + map_error(queue!(self.buffer, SetForegroundColor(color)))?; + fg = cell.fg; + } + if cell.bg != bg { + let color = CColor::from(cell.bg); + map_error(queue!(self.buffer, SetBackgroundColor(color)))?; + bg = cell.bg; + } + + map_error(queue!(self.buffer, Print(&cell.symbol)))?; + } + + map_error(queue!( + self.buffer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(CAttribute::Reset) + )) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Hide)) + } + + fn show_cursor(&mut self) -> io::Result<()> { + map_error(execute!(self.buffer, Show)) + } + + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + crossterm::cursor::position() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) + } + + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + map_error(execute!(self.buffer, MoveTo(x, y))) + } + + fn clear(&mut self) -> io::Result<()> { + self.clear_region(ClearType::All) + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + map_error(execute!( + self.buffer, + Clear(match clear_type { + ClearType::All => crossterm::terminal::ClearType::All, + ClearType::AfterCursor => crossterm::terminal::ClearType::FromCursorDown, + ClearType::BeforeCursor => crossterm::terminal::ClearType::FromCursorUp, + ClearType::CurrentLine => crossterm::terminal::ClearType::CurrentLine, + ClearType::UntilNewLine => crossterm::terminal::ClearType::UntilNewLine, + }) + )) + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + for _ in 0..n { + map_error(queue!(self.buffer, Print("\n")))?; + } + self.buffer.flush() + } + + fn size(&self) -> io::Result { + let (width, height) = + terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + Ok(Rect::new(0, 0, width, height)) + } + + fn flush(&mut self) -> io::Result<()> { + self.buffer.flush() + } +} + +fn map_error(error: crossterm::Result<()>) -> io::Result<()> { + error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) +} + +impl From for CColor { + fn from(color: Color) -> Self { + match color { + Color::Reset => CColor::Reset, + Color::Black => CColor::Black, + Color::Red => CColor::DarkRed, + Color::Green => CColor::DarkGreen, + Color::Yellow => CColor::DarkYellow, + Color::Blue => CColor::DarkBlue, + Color::Magenta => CColor::DarkMagenta, + Color::Cyan => CColor::DarkCyan, + Color::Gray => CColor::Grey, + Color::DarkGray => CColor::DarkGrey, + Color::LightRed => CColor::Red, + Color::LightGreen => CColor::Green, + Color::LightBlue => CColor::Blue, + Color::LightYellow => CColor::Yellow, + Color::LightMagenta => CColor::Magenta, + Color::LightCyan => CColor::Cyan, + Color::White => CColor::White, + Color::Indexed(i) => CColor::AnsiValue(i), + Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, + } + } +} + +#[derive(Debug)] +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue(&self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + //use crossterm::Attribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?; + } + if removed.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + if self.to.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + } + if removed.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; + } + if removed.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; + } + if removed.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?; + } + if added.contains(Modifier::BOLD) { + map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; + } + if added.contains(Modifier::ITALIC) { + map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; + } + if added.contains(Modifier::UNDERLINED) { + map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; + } + if added.contains(Modifier::DIM) { + map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; + } + if added.contains(Modifier::CROSSED_OUT) { + map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?; + } + if added.contains(Modifier::SLOW_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?; + } + if added.contains(Modifier::RAPID_BLINK) { + map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?; + } + + Ok(()) + } +} diff --git a/atuin/src/ratatui/backend/mod.rs b/atuin/src/ratatui/backend/mod.rs new file mode 100644 index 00000000..a360db18 --- /dev/null +++ b/atuin/src/ratatui/backend/mod.rs @@ -0,0 +1,58 @@ +use std::io; + +use crate::ratatui::buffer::Cell; +use crate::ratatui::layout::Rect; + +#[cfg(feature = "termion")] +mod termion; +#[cfg(feature = "termion")] +pub use self::termion::TermionBackend; + +mod crossterm; +pub use self::crossterm::CrosstermBackend; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ClearType { + All, + AfterCursor, + BeforeCursor, + CurrentLine, + UntilNewLine, +} + +pub trait Backend { + fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> + where + I: Iterator; + + /// Insert `n` line breaks to the terminal screen + fn append_lines(&mut self, n: u16) -> io::Result<()> { + // to get around the unused warning + let _n = n; + Ok(()) + } + + fn hide_cursor(&mut self) -> Result<(), io::Error>; + fn show_cursor(&mut self) -> Result<(), io::Error>; + fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>; + fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; + + /// Clears the whole terminal screen + fn clear(&mut self) -> Result<(), io::Error>; + + /// Clears a specific region of the terminal specified by the [`ClearType`] parameter + fn clear_region(&mut self, clear_type: ClearType) -> Result<(), io::Error> { + match clear_type { + ClearType::All => self.clear(), + ClearType::AfterCursor + | ClearType::BeforeCursor + | ClearType::CurrentLine + | ClearType::UntilNewLine => Err(io::Error::new( + io::ErrorKind::Other, + format!("clear_type [{clear_type:?}] not supported with this backend"), + )), + } + } + fn size(&self) -> Result; + fn flush(&mut self) -> Result<(), io::Error>; +} diff --git a/atuin/src/ratatui/backend/termion.rs b/atuin/src/ratatui/backend/termion.rs new file mode 100644 index 00000000..76def792 --- /dev/null +++ b/atuin/src/ratatui/backend/termion.rs @@ -0,0 +1,275 @@ +use crate::{ + backend::{Backend, ClearType}, + buffer::Cell, + layout::Rect, + style::{Color, Modifier}, +}; +use std::{ + fmt, + io::{self, Write}, +}; + +pub struct TermionBackend +where + W: Write, +{ + stdout: W, +} + +impl TermionBackend +where + W: Write, +{ + pub fn new(stdout: W) -> TermionBackend { + TermionBackend { stdout } + } +} + +impl Write for TermionBackend +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stdout.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.stdout.flush() + } +} + +impl Backend for TermionBackend +where + W: Write, +{ + fn clear(&mut self) -> io::Result<()> { + self.clear_region(ClearType::All) + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + match clear_type { + ClearType::All => write!(self.stdout, "{}", termion::clear::All)?, + ClearType::AfterCursor => write!(self.stdout, "{}", termion::clear::AfterCursor)?, + ClearType::BeforeCursor => write!(self.stdout, "{}", termion::clear::BeforeCursor)?, + ClearType::CurrentLine => write!(self.stdout, "{}", termion::clear::CurrentLine)?, + ClearType::UntilNewLine => write!(self.stdout, "{}", termion::clear::UntilNewline)?, + }; + self.stdout.flush() + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + for _ in 0..n { + writeln!(self.stdout)?; + } + self.stdout.flush() + } + + /// Hides cursor + fn hide_cursor(&mut self) -> io::Result<()> { + write!(self.stdout, "{}", termion::cursor::Hide)?; + self.stdout.flush() + } + + /// Shows cursor + fn show_cursor(&mut self) -> io::Result<()> { + write!(self.stdout, "{}", termion::cursor::Show)?; + self.stdout.flush() + } + + /// Gets cursor position (0-based index) + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + termion::cursor::DetectCursorPos::cursor_pos(&mut self.stdout).map(|(x, y)| (x - 1, y - 1)) + } + + /// Sets cursor position (0-based index) + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + write!(self.stdout, "{}", termion::cursor::Goto(x + 1, y + 1))?; + self.stdout.flush() + } + + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + use std::fmt::Write; + + let mut string = String::with_capacity(content.size_hint().0 * 3); + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option<(u16, u16)> = None; + for (x, y, cell) in content { + // Move the cursor if the previous location was not (x - 1, y) + if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) { + write!(string, "{}", termion::cursor::Goto(x + 1, y + 1)).unwrap(); + } + last_pos = Some((x, y)); + if cell.modifier != modifier { + write!( + string, + "{}", + ModifierDiff { + from: modifier, + to: cell.modifier + } + ) + .unwrap(); + modifier = cell.modifier; + } + if cell.fg != fg { + write!(string, "{}", Fg(cell.fg)).unwrap(); + fg = cell.fg; + } + if cell.bg != bg { + write!(string, "{}", Bg(cell.bg)).unwrap(); + bg = cell.bg; + } + string.push_str(&cell.symbol); + } + write!( + self.stdout, + "{}{}{}{}", + string, + Fg(Color::Reset), + Bg(Color::Reset), + termion::style::Reset, + ) + } + + /// Return the size of the terminal + fn size(&self) -> io::Result { + let terminal = termion::terminal_size()?; + Ok(Rect::new(0, 0, terminal.0, terminal.1)) + } + + fn flush(&mut self) -> io::Result<()> { + self.stdout.flush() + } +} + +struct Fg(Color); + +struct Bg(Color); + +struct ModifierDiff { + from: Modifier, + to: Modifier, +} + +impl fmt::Display for Fg { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use termion::color::Color as TermionColor; + match self.0 { + Color::Reset => termion::color::Reset.write_fg(f), + Color::Black => termion::color::Black.write_fg(f), + Color::Red => termion::color::Red.write_fg(f), + Color::Green => termion::color::Green.write_fg(f), + Color::Yellow => termion::color::Yellow.write_fg(f), + Color::Blue => termion::color::Blue.write_fg(f), + Color::Magenta => termion::color::Magenta.write_fg(f), + Color::Cyan => termion::color::Cyan.write_fg(f), + Color::Gray => termion::color::White.write_fg(f), + Color::DarkGray => termion::color::LightBlack.write_fg(f), + Color::LightRed => termion::color::LightRed.write_fg(f), + Color::LightGreen => termion::color::LightGreen.write_fg(f), + Color::LightBlue => termion::color::LightBlue.write_fg(f), + Color::LightYellow => termion::color::LightYellow.write_fg(f), + Color::LightMagenta => termion::color::LightMagenta.write_fg(f), + Color::LightCyan => termion::color::LightCyan.write_fg(f), + Color::White => termion::color::LightWhite.write_fg(f), + Color::Indexed(i) => termion::color::AnsiValue(i).write_fg(f), + Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_fg(f), + } + } +} +impl fmt::Display for Bg { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use termion::color::Color as TermionColor; + match self.0 { + Color::Reset => termion::color::Reset.write_bg(f), + Color::Black => termion::color::Black.write_bg(f), + Color::Red => termion::color::Red.write_bg(f), + Color::Green => termion::color::Green.write_bg(f), + Color::Yellow => termion::color::Yellow.write_bg(f), + Color::Blue => termion::color::Blue.write_bg(f), + Color::Magenta => termion::color::Magenta.write_bg(f), + Color::Cyan => termion::color::Cyan.write_bg(f), + Color::Gray => termion::color::White.write_bg(f), + Color::DarkGray => termion::color::LightBlack.write_bg(f), + Color::LightRed => termion::color::LightRed.write_bg(f), + Color::LightGreen => termion::color::LightGreen.write_bg(f), + Color::LightBlue => termion::color::LightBlue.write_bg(f), + Color::LightYellow => termion::color::LightYellow.write_bg(f), + Color::LightMagenta => termion::color::LightMagenta.write_bg(f), + Color::LightCyan => termion::color::LightCyan.write_bg(f), + Color::White => termion::color::LightWhite.write_bg(f), + Color::Indexed(i) => termion::color::AnsiValue(i).write_bg(f), + Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_bg(f), + } + } +} + +impl fmt::Display for ModifierDiff { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let remove = self.from - self.to; + if remove.contains(Modifier::REVERSED) { + write!(f, "{}", termion::style::NoInvert)?; + } + if remove.contains(Modifier::BOLD) { + // XXX: the termion NoBold flag actually enables double-underline on ECMA-48 compliant + // terminals, and NoFaint additionally disables bold... so we use this trick to get + // the right semantics. + write!(f, "{}", termion::style::NoFaint)?; + + if self.to.contains(Modifier::DIM) { + write!(f, "{}", termion::style::Faint)?; + } + } + if remove.contains(Modifier::ITALIC) { + write!(f, "{}", termion::style::NoItalic)?; + } + if remove.contains(Modifier::UNDERLINED) { + write!(f, "{}", termion::style::NoUnderline)?; + } + if remove.contains(Modifier::DIM) { + write!(f, "{}", termion::style::NoFaint)?; + + // XXX: the NoFaint flag additionally disables bold as well, so we need to re-enable it + // here if we want it. + if self.to.contains(Modifier::BOLD) { + write!(f, "{}", termion::style::Bold)?; + } + } + if remove.contains(Modifier::CROSSED_OUT) { + write!(f, "{}", termion::style::NoCrossedOut)?; + } + if remove.contains(Modifier::SLOW_BLINK) || remove.contains(Modifier::RAPID_BLINK) { + write!(f, "{}", termion::style::NoBlink)?; + } + + let add = self.to - self.from; + if add.contains(Modifier::REVERSED) { + write!(f, "{}", termion::style::Invert)?; + } + if add.contains(Modifier::BOLD) { + write!(f, "{}", termion::style::Bold)?; + } + if add.contains(Modifier::ITALIC) { + write!(f, "{}", termion::style::Italic)?; + } + if add.contains(Modifier::UNDERLINED) { + write!(f, "{}", termion::style::Underline)?; + } + if add.contains(Modifier::DIM) { + write!(f, "{}", termion::style::Faint)?; + } + if add.contains(Modifier::CROSSED_OUT) { + write!(f, "{}", termion::style::CrossedOut)?; + } + if add.contains(Modifier::SLOW_BLINK) || add.contains(Modifier::RAPID_BLINK) { + write!(f, "{}", termion::style::Blink)?; + } + + Ok(()) + } +} diff --git a/atuin/src/ratatui/buffer.rs b/atuin/src/ratatui/buffer.rs new file mode 100644 index 00000000..b2a988b7 --- /dev/null +++ b/atuin/src/ratatui/buffer.rs @@ -0,0 +1,736 @@ +use crate::ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Span, Spans}, +}; +use std::cmp::min; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +/// A buffer cell +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cell { + pub symbol: String, + pub fg: Color, + pub bg: Color, + pub modifier: Modifier, +} + +impl Cell { + pub fn set_symbol(&mut self, symbol: &str) -> &mut Cell { + self.symbol.clear(); + self.symbol.push_str(symbol); + self + } + + pub fn set_char(&mut self, ch: char) -> &mut Cell { + self.symbol.clear(); + self.symbol.push(ch); + self + } + + pub fn set_fg(&mut self, color: Color) -> &mut Cell { + self.fg = color; + self + } + + pub fn set_bg(&mut self, color: Color) -> &mut Cell { + self.bg = color; + self + } + + pub fn set_style(&mut self, style: Style) -> &mut Cell { + if let Some(c) = style.fg { + self.fg = c; + } + if let Some(c) = style.bg { + self.bg = c; + } + self.modifier.insert(style.add_modifier); + self.modifier.remove(style.sub_modifier); + self + } + + pub fn style(&self) -> Style { + Style::default() + .fg(self.fg) + .bg(self.bg) + .add_modifier(self.modifier) + } + + pub fn reset(&mut self) { + self.symbol.clear(); + self.symbol.push(' '); + self.fg = Color::Reset; + self.bg = Color::Reset; + self.modifier = Modifier::empty(); + } +} + +impl Default for Cell { + fn default() -> Cell { + Cell { + symbol: " ".into(), + fg: Color::Reset, + bg: Color::Reset, + modifier: Modifier::empty(), + } + } +} + +/// A buffer that maps to the desired content of the terminal after the draw call +/// +/// No widget in the library interacts directly with the terminal. Instead each of them is required +/// to draw their state to an intermediate buffer. It is basically a grid where each cell contains +/// a grapheme, a foreground color and a background color. This grid will then be used to output +/// the appropriate escape sequences and characters to draw the UI as the user has defined it. +/// +/// # Examples: +/// +/// ``` +/// use ratatui::buffer::{Buffer, Cell}; +/// use ratatui::layout::Rect; +/// use ratatui::style::{Color, Style, Modifier}; +/// +/// let mut buf = Buffer::empty(Rect{x: 0, y: 0, width: 10, height: 5}); +/// buf.get_mut(0, 2).set_symbol("x"); +/// assert_eq!(buf.get(0, 2).symbol, "x"); +/// buf.set_string(3, 0, "string", Style::default().fg(Color::Red).bg(Color::White)); +/// assert_eq!(buf.get(5, 0), &Cell{ +/// symbol: String::from("r"), +/// fg: Color::Red, +/// bg: Color::White, +/// modifier: Modifier::empty() +/// }); +/// buf.get_mut(5, 0).set_char('x'); +/// assert_eq!(buf.get(5, 0).symbol, "x"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Buffer { + /// The area represented by this buffer + pub area: Rect, + /// The content of the buffer. The length of this Vec should always be equal to area.width * + /// area.height + pub content: Vec, +} + +impl Buffer { + /// Returns a Buffer with all cells set to the default one + pub fn empty(area: Rect) -> Buffer { + let cell: Cell = Default::default(); + Buffer::filled(area, &cell) + } + + /// Returns a Buffer with all cells initialized with the attributes of the given Cell + pub fn filled(area: Rect, cell: &Cell) -> Buffer { + let size = area.area() as usize; + let mut content = Vec::with_capacity(size); + for _ in 0..size { + content.push(cell.clone()); + } + Buffer { area, content } + } + + /// Returns a Buffer containing the given lines + pub fn with_lines(lines: Vec) -> Buffer + where + S: AsRef, + { + let height = lines.len() as u16; + let width = lines + .iter() + .map(|i| i.as_ref().width() as u16) + .max() + .unwrap_or_default(); + let mut buffer = Buffer::empty(Rect { + x: 0, + y: 0, + width, + height, + }); + for (y, line) in lines.iter().enumerate() { + buffer.set_string(0, y as u16, line, Style::default()); + } + buffer + } + + /// Returns the content of the buffer as a slice + pub fn content(&self) -> &[Cell] { + &self.content + } + + /// Returns the area covered by this buffer + pub fn area(&self) -> &Rect { + &self.area + } + + /// Returns a reference to Cell at the given coordinates + pub fn get(&self, x: u16, y: u16) -> &Cell { + let i = self.index_of(x, y); + &self.content[i] + } + + /// Returns a mutable reference to Cell at the given coordinates + pub fn get_mut(&mut self, x: u16, y: u16) -> &mut Cell { + let i = self.index_of(x, y); + &mut self.content[i] + } + + /// Returns the index in the `Vec` for the given global (x, y) coordinates. + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use ratatui::buffer::Buffer; + /// # use ratatui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Global coordinates to the top corner of this buffer's area + /// assert_eq!(buffer.index_of(200, 100), 0); + /// ``` + /// + /// # Panics + /// + /// Panics when given an coordinate that is outside of this Buffer's area. + /// + /// ```should_panic + /// # use ratatui::buffer::Buffer; + /// # use ratatui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// // Top coordinate is outside of the buffer in global coordinate space, as the Buffer's area + /// // starts at (200, 100). + /// buffer.index_of(0, 0); // Panics + /// ``` + pub fn index_of(&self, x: u16, y: u16) -> usize { + debug_assert!( + x >= self.area.left() + && x < self.area.right() + && y >= self.area.top() + && y < self.area.bottom(), + "Trying to access position outside the buffer: x={}, y={}, area={:?}", + x, + y, + self.area + ); + ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize + } + + /// Returns the (global) coordinates of a cell given its index + /// + /// Global coordinates are offset by the Buffer's area offset (`x`/`y`). + /// + /// # Examples + /// + /// ``` + /// # use ratatui::buffer::Buffer; + /// # use ratatui::layout::Rect; + /// let rect = Rect::new(200, 100, 10, 10); + /// let buffer = Buffer::empty(rect); + /// assert_eq!(buffer.pos_of(0), (200, 100)); + /// assert_eq!(buffer.pos_of(14), (204, 101)); + /// ``` + /// + /// # Panics + /// + /// Panics when given an index that is outside the Buffer's content. + /// + /// ```should_panic + /// # use ratatui::buffer::Buffer; + /// # use ratatui::layout::Rect; + /// let rect = Rect::new(0, 0, 10, 10); // 100 cells in total + /// let buffer = Buffer::empty(rect); + /// // Index 100 is the 101th cell, which lies outside of the area of this Buffer. + /// buffer.pos_of(100); // Panics + /// ``` + pub fn pos_of(&self, i: usize) -> (u16, u16) { + debug_assert!( + i < self.content.len(), + "Trying to get the coords of a cell outside the buffer: i={} len={}", + i, + self.content.len() + ); + ( + self.area.x + i as u16 % self.area.width, + self.area.y + i as u16 / self.area.width, + ) + } + + /// Print a string, starting at the position (x, y) + pub fn set_string(&mut self, x: u16, y: u16, string: S, style: Style) + where + S: AsRef, + { + self.set_stringn(x, y, string, usize::MAX, style); + } + + /// Print at most the first n characters of a string if enough space is available + /// until the end of the line + pub fn set_stringn( + &mut self, + x: u16, + y: u16, + string: S, + width: usize, + style: Style, + ) -> (u16, u16) + where + S: AsRef, + { + let mut index = self.index_of(x, y); + let mut x_offset = x as usize; + let graphemes = UnicodeSegmentation::graphemes(string.as_ref(), true); + let max_offset = min(self.area.right() as usize, width.saturating_add(x as usize)); + for s in graphemes { + let width = s.width(); + if width == 0 { + continue; + } + // `x_offset + width > max_offset` could be integer overflow on 32-bit machines if we + // change dimensions to usize or u32 and someone resizes the terminal to 1x2^32. + if width > max_offset.saturating_sub(x_offset) { + break; + } + + self.content[index].set_symbol(s); + self.content[index].set_style(style); + // Reset following cells if multi-width (they would be hidden by the grapheme), + for i in index + 1..index + width { + self.content[i].reset(); + } + index += width; + x_offset += width; + } + (x_offset as u16, y) + } + + pub fn set_spans(&mut self, x: u16, y: u16, spans: &Spans<'_>, width: u16) -> (u16, u16) { + let mut remaining_width = width; + let mut x = x; + for span in &spans.0 { + if remaining_width == 0 { + break; + } + let pos = self.set_stringn( + x, + y, + span.content.as_ref(), + remaining_width as usize, + span.style, + ); + let w = pos.0.saturating_sub(x); + x = pos.0; + remaining_width = remaining_width.saturating_sub(w); + } + (x, y) + } + + pub fn set_span(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) -> (u16, u16) { + self.set_stringn(x, y, span.content.as_ref(), width as usize, span.style) + } + + #[deprecated( + since = "0.10.0", + note = "You should use styling capabilities of `Buffer::set_style`" + )] + pub fn set_background(&mut self, area: Rect, color: Color) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_bg(color); + } + } + } + + pub fn set_style(&mut self, area: Rect, style: Style) { + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + self.get_mut(x, y).set_style(style); + } + } + } + + /// Resize the buffer so that the mapped area matches the given area and that the buffer + /// length is equal to area.width * area.height + pub fn resize(&mut self, area: Rect) { + let length = area.area() as usize; + if self.content.len() > length { + self.content.truncate(length); + } else { + self.content.resize(length, Default::default()); + } + self.area = area; + } + + /// Reset all cells in the buffer + pub fn reset(&mut self) { + for c in &mut self.content { + c.reset(); + } + } + + /// Merge an other buffer into this one + pub fn merge(&mut self, other: &Buffer) { + let area = self.area.union(other.area); + let cell: Cell = Default::default(); + self.content.resize(area.area() as usize, cell.clone()); + + // Move original content to the appropriate space + let size = self.area.area() as usize; + for i in (0..size).rev() { + let (x, y) = self.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + if i != k { + self.content[k] = self.content[i].clone(); + self.content[i] = cell.clone(); + } + } + + // Push content of the other buffer into this one (may erase previous + // data) + let size = other.area.area() as usize; + for i in 0..size { + let (x, y) = other.pos_of(i); + // New index in content + let k = ((y - area.y) * area.width + x - area.x) as usize; + self.content[k] = other.content[i].clone(); + } + self.area = area; + } + + /// Builds a minimal sequence of coordinates and Cells necessary to update the UI from + /// self to other. + /// + /// We're assuming that buffers are well-formed, that is no double-width cell is followed by + /// a non-blank cell. + /// + /// # Multi-width characters handling: + /// + /// ```text + /// (Index:) `01` + /// Prev: `コ` + /// Next: `aa` + /// Updates: `0: a, 1: a' + /// ``` + /// + /// ```text + /// (Index:) `01` + /// Prev: `a ` + /// Next: `コ` + /// Updates: `0: コ` (double width symbol at index 0 - skip index 1) + /// ``` + /// + /// ```text + /// (Index:) `012` + /// Prev: `aaa` + /// Next: `aコ` + /// Updates: `0: a, 1: コ` (double width symbol at index 1 - skip index 2) + /// ``` + pub fn diff<'a>(&self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> { + let previous_buffer = &self.content; + let next_buffer = &other.content; + + let mut updates: Vec<(u16, u16, &Cell)> = vec![]; + // Cells invalidated by drawing/replacing preceding multi-width characters: + let mut invalidated: usize = 0; + // Cells from the current buffer to skip due to preceding multi-width characters taking their + // place (the skipped cells should be blank anyway): + let mut to_skip: usize = 0; + for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if (current != previous || invalidated > 0) && to_skip == 0 { + let (x, y) = self.pos_of(i); + updates.push((x, y, &next_buffer[i])); + } + + to_skip = current.symbol.width().saturating_sub(1); + + let affected_width = std::cmp::max(current.symbol.width(), previous.symbol.width()); + invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); + } + updates + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(s: &str) -> Cell { + let mut cell = Cell::default(); + cell.set_symbol(s); + cell + } + + #[test] + fn it_translates_to_and_from_coordinates() { + let rect = Rect::new(200, 100, 50, 80); + let buf = Buffer::empty(rect); + + // First cell is at the upper left corner. + assert_eq!(buf.pos_of(0), (200, 100)); + assert_eq!(buf.index_of(200, 100), 0); + + // Last cell is in the lower right. + assert_eq!(buf.pos_of(buf.content.len() - 1), (249, 179)); + assert_eq!(buf.index_of(249, 179), buf.content.len() - 1); + } + + #[test] + #[ignore] + #[should_panic(expected = "outside the buffer")] + fn pos_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // There are a total of 100 cells; zero-indexed means that 100 would be the 101st cell. + buf.pos_of(100); + } + + #[test] + #[ignore] + #[should_panic(expected = "outside the buffer")] + fn index_of_panics_on_out_of_bounds() { + let rect = Rect::new(0, 0, 10, 10); + let buf = Buffer::empty(rect); + + // width is 10; zero-indexed means that 10 would be the 11th cell. + buf.index_of(10, 0); + } + + #[test] + fn buffer_set_string() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + + // Zero-width + buffer.set_stringn(0, 0, "aaa", 0, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec![" "])); + + buffer.set_string(0, 0, "aaa", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["aaa "])); + + // Width limit: + buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "])); + + buffer.set_string(0, 0, "12345", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + + // Width truncation: + buffer.set_string(0, 0, "123456", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["12345"])); + } + + #[test] + fn buffer_set_string_zero_width() { + let area = Rect::new(0, 0, 1, 1); + let mut buffer = Buffer::empty(area); + + // Leading grapheme with zero width + let s = "\u{1}a"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["a"])); + + // Trailing grapheme with zero with + let s = "a\u{1}"; + buffer.set_stringn(0, 0, s, 1, Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["a"])); + } + + #[test] + fn buffer_set_string_double_width() { + let area = Rect::new(0, 0, 5, 1); + let mut buffer = Buffer::empty(area); + buffer.set_string(0, 0, "コン", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + + // Only 1 space left. + buffer.set_string(0, 0, "コンピ", Style::default()); + assert_eq!(buffer, Buffer::with_lines(vec!["コン "])); + } + + #[test] + fn buffer_with_lines() { + let buffer = + Buffer::with_lines(vec!["┌────────┐", "│コンピュ│", "│ーa 上で│", "└────────┘"]); + assert_eq!(buffer.area.x, 0); + assert_eq!(buffer.area.y, 0); + assert_eq!(buffer.area.width, 10); + assert_eq!(buffer.area.height, 4); + } + + #[test] + fn buffer_diffing_empty_empty() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::empty(area); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_empty_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::empty(area); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff.len(), 40 * 40); + } + + #[test] + fn buffer_diffing_filled_filled() { + let area = Rect::new(0, 0, 40, 40); + let prev = Buffer::filled(area, Cell::default().set_symbol("a")); + let next = Buffer::filled(area, Cell::default().set_symbol("a")); + let diff = prev.diff(&next); + assert_eq!(diff, vec![]); + } + + #[test] + fn buffer_diffing_single_width() { + let prev = Buffer::with_lines(vec![ + " ", + "┌Title─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + " ", + "┌TITLE─┐ ", + "│ │ ", + "│ │ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (2, 1, &cell("I")), + (3, 1, &cell("T")), + (4, 1, &cell("L")), + (5, 1, &cell("E")), + ] + ); + } + + #[test] + #[rustfmt::skip] + fn buffer_diffing_multi_width() { + let prev = Buffer::with_lines(vec![ + "┌Title─┐ ", + "└──────┘ ", + ]); + let next = Buffer::with_lines(vec![ + "┌称号──┐ ", + "└──────┘ ", + ]); + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![ + (1, 0, &cell("称")), + // Skipped "i" + (3, 0, &cell("号")), + // Skipped "l" + (5, 0, &cell("─")), + ] + ); + } + + #[test] + fn buffer_diffing_multi_width_offset() { + let prev = Buffer::with_lines(vec!["┌称号──┐"]); + let next = Buffer::with_lines(vec!["┌─称号─┐"]); + + let diff = prev.diff(&next); + assert_eq!( + diff, + vec![(1, 0, &cell("─")), (2, 0, &cell("称")), (4, 0, &cell("号")),] + ); + } + + #[test] + fn buffer_merge() { + let mut one = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"])); + } + + #[test] + fn buffer_merge2() { + let mut one = Buffer::filled( + Rect { + x: 2, + y: 2, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 0, + y: 0, + width: 2, + height: 2, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + assert_eq!( + one, + Buffer::with_lines(vec!["22 ", "22 ", " 11", " 11"]) + ); + } + + #[test] + fn buffer_merge3() { + let mut one = Buffer::filled( + Rect { + x: 3, + y: 3, + width: 2, + height: 2, + }, + Cell::default().set_symbol("1"), + ); + let two = Buffer::filled( + Rect { + x: 1, + y: 1, + width: 3, + height: 4, + }, + Cell::default().set_symbol("2"), + ); + one.merge(&two); + let mut merged = Buffer::with_lines(vec!["222 ", "222 ", "2221", "2221"]); + merged.area = Rect { + x: 1, + y: 1, + width: 4, + height: 4, + }; + assert_eq!(one, merged); + } +} diff --git a/atuin/src/ratatui/layout.rs b/atuin/src/ratatui/layout.rs new file mode 100644 index 00000000..f5b14e35 --- /dev/null +++ b/atuin/src/ratatui/layout.rs @@ -0,0 +1,560 @@ +use std::cell::RefCell; +use std::cmp::{max, min}; +use std::collections::HashMap; +use std::rc::Rc; + +use cassowary::strength::{MEDIUM, REQUIRED, WEAK}; +use cassowary::WeightedRelation::*; +use cassowary::{Constraint as CassowaryConstraint, Expression, Solver, Variable}; + +#[derive(Debug, Hash, Clone, Copy, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +#[derive(Debug, Hash, Clone, PartialEq, Eq)] +pub enum Direction { + Horizontal, + Vertical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Constraint { + // TODO: enforce range 0 - 100 + Percentage(u16), + Ratio(u32, u32), + Length(u16), + Max(u16), + Min(u16), +} + +impl Constraint { + pub fn apply(&self, length: u16) -> u16 { + match *self { + Constraint::Percentage(p) => length * p / 100, + Constraint::Ratio(num, den) => { + let r = num * u32::from(length) / den; + r as u16 + } + Constraint::Length(l) => length.min(l), + Constraint::Max(m) => length.min(m), + Constraint::Min(m) => length.max(m), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Margin { + pub vertical: u16, + pub horizontal: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Alignment { + Left, + Center, + Right, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Layout { + direction: Direction, + margin: Margin, + constraints: Vec, + /// Whether the last chunk of the computed layout should be expanded to fill the available + /// space. + expand_to_fill: bool, +} + +type Cache = HashMap<(Rect, Layout), Rc<[Rect]>>; +thread_local! { + static LAYOUT_CACHE: RefCell = RefCell::new(HashMap::new()); +} + +impl Default for Layout { + fn default() -> Layout { + Layout { + direction: Direction::Vertical, + margin: Margin { + horizontal: 0, + vertical: 0, + }, + constraints: Vec::new(), + expand_to_fill: true, + } + } +} + +impl Layout { + pub fn constraints(mut self, constraints: C) -> Layout + where + C: Into>, + { + self.constraints = constraints.into(); + self + } + + pub fn margin(mut self, margin: u16) -> Layout { + self.margin = Margin { + horizontal: margin, + vertical: margin, + }; + self + } + + pub fn horizontal_margin(mut self, horizontal: u16) -> Layout { + self.margin.horizontal = horizontal; + self + } + + pub fn vertical_margin(mut self, vertical: u16) -> Layout { + self.margin.vertical = vertical; + self + } + + pub fn direction(mut self, direction: Direction) -> Layout { + self.direction = direction; + self + } + + pub(crate) fn expand_to_fill(mut self, expand_to_fill: bool) -> Layout { + self.expand_to_fill = expand_to_fill; + self + } + + /// Wrapper function around the cassowary-rs solver to be able to split a given + /// area into smaller ones based on the preferred widths or heights and the direction. + /// + /// # Examples + /// ``` + /// # use ratatui::layout::{Rect, Constraint, Direction, Layout}; + /// let chunks = Layout::default() + /// .direction(Direction::Vertical) + /// .constraints([Constraint::Length(5), Constraint::Min(0)].as_ref()) + /// .split(Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 10, + /// }); + /// assert_eq!( + /// chunks[..], + /// [ + /// Rect { + /// x: 2, + /// y: 2, + /// width: 10, + /// height: 5 + /// }, + /// Rect { + /// x: 2, + /// y: 7, + /// width: 10, + /// height: 5 + /// } + /// ] + /// ); + /// + /// let chunks = Layout::default() + /// .direction(Direction::Horizontal) + /// .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)].as_ref()) + /// .split(Rect { + /// x: 0, + /// y: 0, + /// width: 9, + /// height: 2, + /// }); + /// assert_eq!( + /// chunks[..], + /// [ + /// Rect { + /// x: 0, + /// y: 0, + /// width: 3, + /// height: 2 + /// }, + /// Rect { + /// x: 3, + /// y: 0, + /// width: 6, + /// height: 2 + /// } + /// ] + /// ); + /// ``` + pub fn split(&self, area: Rect) -> Rc<[Rect]> { + // TODO: Maybe use a fixed size cache ? + LAYOUT_CACHE.with(|c| { + c.borrow_mut() + .entry((area, self.clone())) + .or_insert_with(|| split(area, self)) + .clone() + }) + } +} + +fn split(area: Rect, layout: &Layout) -> Rc<[Rect]> { + let mut solver = Solver::new(); + let mut vars: HashMap = HashMap::new(); + let elements = layout + .constraints + .iter() + .map(|_| Element::new()) + .collect::>(); + let mut res = layout + .constraints + .iter() + .map(|_| Rect::default()) + .collect::>(); + + let mut results = Rc::get_mut(&mut res).expect("newly created Rc should have no shared refs"); + + let dest_area = area.inner(&layout.margin); + for (i, e) in elements.iter().enumerate() { + vars.insert(e.x, (i, 0)); + vars.insert(e.y, (i, 1)); + vars.insert(e.width, (i, 2)); + vars.insert(e.height, (i, 3)); + } + let mut ccs: Vec = + Vec::with_capacity(elements.len() * 4 + layout.constraints.len() * 6); + for elt in &elements { + ccs.push(elt.width | GE(REQUIRED) | 0f64); + ccs.push(elt.height | GE(REQUIRED) | 0f64); + ccs.push(elt.left() | GE(REQUIRED) | f64::from(dest_area.left())); + ccs.push(elt.top() | GE(REQUIRED) | f64::from(dest_area.top())); + ccs.push(elt.right() | LE(REQUIRED) | f64::from(dest_area.right())); + ccs.push(elt.bottom() | LE(REQUIRED) | f64::from(dest_area.bottom())); + } + if let Some(first) = elements.first() { + ccs.push(match layout.direction { + Direction::Horizontal => first.left() | EQ(REQUIRED) | f64::from(dest_area.left()), + Direction::Vertical => first.top() | EQ(REQUIRED) | f64::from(dest_area.top()), + }); + } + if layout.expand_to_fill { + if let Some(last) = elements.last() { + ccs.push(match layout.direction { + Direction::Horizontal => last.right() | EQ(REQUIRED) | f64::from(dest_area.right()), + Direction::Vertical => last.bottom() | EQ(REQUIRED) | f64::from(dest_area.bottom()), + }); + } + } + match layout.direction { + Direction::Horizontal => { + for pair in elements.windows(2) { + ccs.push((pair[0].x + pair[0].width) | EQ(REQUIRED) | pair[1].x); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].y | EQ(REQUIRED) | f64::from(dest_area.y)); + ccs.push(elements[i].height | EQ(REQUIRED) | f64::from(dest_area.height)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].width | EQ(MEDIUM) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].width | EQ(MEDIUM) | (f64::from(v * dest_area.width) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].width + | EQ(MEDIUM) + | (f64::from(dest_area.width) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].width | GE(MEDIUM) | f64::from(v), + Constraint::Max(v) => elements[i].width | LE(MEDIUM) | f64::from(v), + }); + + match *size { + Constraint::Min(v) => { + ccs.push(elements[i].width | EQ(WEAK) | f64::from(v)); + } + Constraint::Max(v) => { + ccs.push(elements[i].width | EQ(WEAK) | f64::from(v)); + } + _ => {} + } + } + } + Direction::Vertical => { + for pair in elements.windows(2) { + ccs.push((pair[0].y + pair[0].height) | EQ(REQUIRED) | pair[1].y); + } + for (i, size) in layout.constraints.iter().enumerate() { + ccs.push(elements[i].x | EQ(REQUIRED) | f64::from(dest_area.x)); + ccs.push(elements[i].width | EQ(REQUIRED) | f64::from(dest_area.width)); + ccs.push(match *size { + Constraint::Length(v) => elements[i].height | EQ(MEDIUM) | f64::from(v), + Constraint::Percentage(v) => { + elements[i].height | EQ(MEDIUM) | (f64::from(v * dest_area.height) / 100.0) + } + Constraint::Ratio(n, d) => { + elements[i].height + | EQ(MEDIUM) + | (f64::from(dest_area.height) * f64::from(n) / f64::from(d)) + } + Constraint::Min(v) => elements[i].height | GE(MEDIUM) | f64::from(v), + Constraint::Max(v) => elements[i].height | LE(MEDIUM) | f64::from(v), + }); + + match *size { + Constraint::Min(v) => { + ccs.push(elements[i].height | EQ(WEAK) | f64::from(v)); + } + Constraint::Max(v) => { + ccs.push(elements[i].height | EQ(WEAK) | f64::from(v)); + } + _ => {} + } + } + } + } + solver.add_constraints(&ccs).unwrap(); + for &(var, value) in solver.fetch_changes() { + let (index, attr) = vars[&var]; + let value = if value.is_sign_negative() { + 0 + } else { + value as u16 + }; + match attr { + 0 => { + results[index].x = value; + } + 1 => { + results[index].y = value; + } + 2 => { + results[index].width = value; + } + 3 => { + results[index].height = value; + } + _ => {} + } + } + + if layout.expand_to_fill { + // Fix imprecision by extending the last item a bit if necessary + if let Some(last) = results.last_mut() { + match layout.direction { + Direction::Vertical => { + last.height = dest_area.bottom() - last.y; + } + Direction::Horizontal => { + last.width = dest_area.right() - last.x; + } + } + } + } + res +} + +/// A container used by the solver inside split +struct Element { + x: Variable, + y: Variable, + width: Variable, + height: Variable, +} + +impl Element { + fn new() -> Element { + Element { + x: Variable::new(), + y: Variable::new(), + width: Variable::new(), + height: Variable::new(), + } + } + + fn left(&self) -> Variable { + self.x + } + + fn top(&self) -> Variable { + self.y + } + + fn right(&self) -> Expression { + self.x + self.width + } + + fn bottom(&self) -> Expression { + self.y + self.height + } +} + +/// A simple rectangle used in the computation of the layout and to give widgets a hint about the +/// area they are supposed to render to. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)] +pub struct Rect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl Rect { + /// Creates a new rect, with width and height limited to keep the area under max u16. + /// If clipped, aspect ratio will be preserved. + pub fn new(x: u16, y: u16, width: u16, height: u16) -> Rect { + let max_area = u16::max_value(); + let (clipped_width, clipped_height) = + if u32::from(width) * u32::from(height) > u32::from(max_area) { + let aspect_ratio = f64::from(width) / f64::from(height); + let max_area_f = f64::from(max_area); + let height_f = (max_area_f / aspect_ratio).sqrt(); + let width_f = height_f * aspect_ratio; + (width_f as u16, height_f as u16) + } else { + (width, height) + }; + Rect { + x, + y, + width: clipped_width, + height: clipped_height, + } + } + + pub fn area(self) -> u16 { + self.width * self.height + } + + pub fn left(self) -> u16 { + self.x + } + + pub fn right(self) -> u16 { + self.x.saturating_add(self.width) + } + + pub fn top(self) -> u16 { + self.y + } + + pub fn bottom(self) -> u16 { + self.y.saturating_add(self.height) + } + + pub fn inner(self, margin: &Margin) -> Rect { + if self.width < 2 * margin.horizontal || self.height < 2 * margin.vertical { + Rect::default() + } else { + Rect { + x: self.x + margin.horizontal, + y: self.y + margin.vertical, + width: self.width - 2 * margin.horizontal, + height: self.height - 2 * margin.vertical, + } + } + } + + pub fn union(self, other: Rect) -> Rect { + let x1 = min(self.x, other.x); + let y1 = min(self.y, other.y); + let x2 = max(self.x + self.width, other.x + other.width); + let y2 = max(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersection(self, other: Rect) -> Rect { + let x1 = max(self.x, other.x); + let y1 = max(self.y, other.y); + let x2 = min(self.x + self.width, other.x + other.width); + let y2 = min(self.y + self.height, other.y + other.height); + Rect { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + pub fn intersects(self, other: Rect) -> bool { + self.x < other.x + other.width + && self.x + self.width > other.x + && self.y < other.y + other.height + && self.y + self.height > other.y + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vertical_split_by_height() { + let target = Rect { + x: 2, + y: 2, + width: 10, + height: 10, + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(10), + Constraint::Max(5), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(target); + + assert_eq!(target.height, chunks.iter().map(|r| r.height).sum::()); + chunks.windows(2).for_each(|w| assert!(w[0].y <= w[1].y)); + } + + #[test] + fn test_rect_size_truncation() { + for width in 256u16..300u16 { + for height in 256u16..300u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert!(rect.width < width || rect.height < height); + // The target dimensions are rounded down so the math will not be too precise + // but let's make sure the ratios don't diverge crazily. + assert!( + (f64::from(rect.width) / f64::from(rect.height) + - f64::from(width) / f64::from(height)) + .abs() + < 1.0 + ) + } + } + + // One dimension below 255, one above. Area above max u16. + let width = 900; + let height = 100; + let rect = Rect::new(0, 0, width, height); + assert_ne!(rect.width, 900); + assert_ne!(rect.height, 100); + assert!(rect.width < width || rect.height < height); + } + + #[test] + fn test_rect_size_preservation() { + for width in 0..256u16 { + for height in 0..256u16 { + let rect = Rect::new(0, 0, width, height); + rect.area(); // Should not panic. + assert_eq!(rect.width, width); + assert_eq!(rect.height, height); + } + } + + // One dimension below 255, one above. Area below max u16. + let rect = Rect::new(0, 0, 300, 100); + assert_eq!(rect.width, 300); + assert_eq!(rect.height, 100); + } +} diff --git a/atuin/src/ratatui/mod.rs b/atuin/src/ratatui/mod.rs new file mode 100644 index 00000000..d7926b96 --- /dev/null +++ b/atuin/src/ratatui/mod.rs @@ -0,0 +1,177 @@ +#![allow(clippy::all)] +#![allow(warnings)] + +//! [ratatui](https://github.com/tui-rs-revival/ratatui) is a library used to build rich +//! terminal users interfaces and dashboards. +//! +//! ![](https://raw.githubusercontent.com/tui-rs-revival/ratatui/master/assets/demo.gif) +//! +//! # Get started +//! +//! ## Adding `ratatui` as a dependency +//! +//! Add the following to your `Cargo.toml`: +//! ```toml +//! [dependencies] +//! crossterm = "0.26" +//! ratatui = "0.20" +//! ``` +//! +//! The crate is using the `crossterm` backend by default that works on most platforms. But if for +//! example you want to use the `termion` backend instead. This can be done by changing your +//! dependencies specification to the following: +//! +//! ```toml +//! [dependencies] +//! termion = "1.5" +//! ratatui = { version = "0.20", default-features = false, features = ['termion'] } +//! +//! ``` +//! +//! The same logic applies for all other available backends. +//! +//! ## Creating a `Terminal` +//! +//! Every application using `ratatui` should start by instantiating a `Terminal`. It is a light +//! abstraction over available backends that provides basic functionalities such as clearing the +//! screen, hiding the cursor, etc. +//! +//! ```rust,no_run +//! use std::io; +//! use ratatui::{backend::CrosstermBackend, Terminal}; +//! +//! fn main() -> Result<(), io::Error> { +//! let stdout = io::stdout(); +//! let backend = CrosstermBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! Ok(()) +//! } +//! ``` +//! +//! If you had previously chosen `termion` as a backend, the terminal can be created in a similar +//! way: +//! +//! ```rust,ignore +//! use std::io; +//! use ratatui::{backend::TermionBackend, Terminal}; +//! use termion::raw::IntoRawMode; +//! +//! fn main() -> Result<(), io::Error> { +//! let stdout = io::stdout().into_raw_mode()?; +//! let backend = TermionBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! Ok(()) +//! } +//! ``` +//! +//! You may also refer to the examples to find out how to create a `Terminal` for each available +//! backend. +//! +//! ## Building a User Interface (UI) +//! +//! Every component of your interface will be implementing the `Widget` trait. The library comes +//! with a predefined set of widgets that should meet most of your use cases. You are also free to +//! implement your own. +//! +//! Each widget follows a builder pattern API providing a default configuration along with methods +//! to customize them. The widget is then rendered using [`Frame::render_widget`] which takes +//! your widget instance and an area to draw to. +//! +//! The following example renders a block of the size of the terminal: +//! +//! ```rust,no_run +//! use std::{io, thread, time::Duration}; +//! use ratatui::{ +//! backend::CrosstermBackend, +//! widgets::{Widget, Block, Borders}, +//! layout::{Layout, Constraint, Direction}, +//! Terminal +//! }; +//! use crossterm::{ +//! event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, +//! execute, +//! terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +//! }; +//! +//! fn main() -> Result<(), io::Error> { +//! // setup terminal +//! enable_raw_mode()?; +//! let mut stdout = io::stdout(); +//! execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; +//! let backend = CrosstermBackend::new(stdout); +//! let mut terminal = Terminal::new(backend)?; +//! +//! terminal.draw(|f| { +//! let size = f.size(); +//! let block = Block::default() +//! .title("Block") +//! .borders(Borders::ALL); +//! f.render_widget(block, size); +//! })?; +//! +//! thread::sleep(Duration::from_millis(5000)); +//! +//! // restore terminal +//! disable_raw_mode()?; +//! execute!( +//! terminal.backend_mut(), +//! LeaveAlternateScreen, +//! DisableMouseCapture +//! )?; +//! terminal.show_cursor()?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Layout +//! +//! The library comes with a basic yet useful layout management object called `Layout`. As you may +//! see below and in the examples, the library makes heavy use of the builder pattern to provide +//! full customization. And `Layout` is no exception: +//! +//! ```rust,no_run +//! use ratatui::{ +//! backend::Backend, +//! layout::{Constraint, Direction, Layout}, +//! widgets::{Block, Borders}, +//! Frame, +//! }; +//! fn ui(f: &mut Frame) { +//! let chunks = Layout::default() +//! .direction(Direction::Vertical) +//! .margin(1) +//! .constraints( +//! [ +//! Constraint::Percentage(10), +//! Constraint::Percentage(80), +//! Constraint::Percentage(10) +//! ].as_ref() +//! ) +//! .split(f.size()); +//! let block = Block::default() +//! .title("Block") +//! .borders(Borders::ALL); +//! f.render_widget(block, chunks[0]); +//! let block = Block::default() +//! .title("Block 2") +//! .borders(Borders::ALL); +//! f.render_widget(block, chunks[1]); +//! } +//! ``` +//! +//! This let you describe responsive terminal UI by nesting layouts. You should note that by +//! default the computed layout tries to fill the available space completely. So if for any reason +//! you might need a blank space somewhere, try to pass an additional constraint and don't use the +//! corresponding area. + +pub mod backend; +pub mod buffer; +pub mod layout; +pub mod style; +pub mod symbols; +pub mod terminal; +pub mod text; +pub mod widgets; + +pub use self::terminal::{Frame, Terminal, TerminalOptions, Viewport}; diff --git a/atuin/src/ratatui/style.rs b/atuin/src/ratatui/style.rs new file mode 100644 index 00000000..4d74f6fc --- /dev/null +++ b/atuin/src/ratatui/style.rs @@ -0,0 +1,310 @@ +//! `style` contains the primitives used to control how your user interface will look. + +use bitflags::bitflags; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Color { + Reset, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, + Rgb(u8, u8, u8), + Indexed(u8), +} + +bitflags! { + /// Modifier changes the way a piece of text is displayed. + /// + /// They are bitflags so they can easily be composed. + /// + /// ## Examples + /// + /// ```rust + /// # use ratatui::style::Modifier; + /// + /// let m = Modifier::BOLD | Modifier::ITALIC; + /// ``` + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct Modifier: u16 { + const BOLD = 0b0000_0000_0001; + const DIM = 0b0000_0000_0010; + const ITALIC = 0b0000_0000_0100; + const UNDERLINED = 0b0000_0000_1000; + const SLOW_BLINK = 0b0000_0001_0000; + const RAPID_BLINK = 0b0000_0010_0000; + const REVERSED = 0b0000_0100_0000; + const HIDDEN = 0b0000_1000_0000; + const CROSSED_OUT = 0b0001_0000_0000; + } +} + +/// Style let you control the main characteristics of the displayed elements. +/// +/// ```rust +/// # use ratatui::style::{Color, Modifier, Style}; +/// Style::default() +/// .fg(Color::Black) +/// .bg(Color::Green) +/// .add_modifier(Modifier::ITALIC | Modifier::BOLD); +/// ``` +/// +/// It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the +/// terminal buffer, the style of this cell will be the result of the merge of S1, S2 and S3, not +/// just S3. +/// +/// ```rust +/// # use ratatui::style::{Color, Modifier, Style}; +/// # use ratatui::buffer::Buffer; +/// # use ratatui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::default().bg(Color::Red), +/// Style::default().fg(Color::Yellow).remove_modifier(Modifier::ITALIC), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Red), +/// add_modifier: Modifier::BOLD, +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +/// +/// The default implementation returns a `Style` that does not modify anything. If you wish to +/// reset all properties until that point use [`Style::reset`]. +/// +/// ``` +/// # use ratatui::style::{Color, Modifier, Style}; +/// # use ratatui::buffer::Buffer; +/// # use ratatui::layout::Rect; +/// let styles = [ +/// Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD | Modifier::ITALIC), +/// Style::reset().fg(Color::Yellow), +/// ]; +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1)); +/// for style in &styles { +/// buffer.get_mut(0, 0).set_style(*style); +/// } +/// assert_eq!( +/// Style { +/// fg: Some(Color::Yellow), +/// bg: Some(Color::Reset), +/// add_modifier: Modifier::empty(), +/// sub_modifier: Modifier::empty(), +/// }, +/// buffer.get(0, 0).style(), +/// ); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub add_modifier: Modifier, + pub sub_modifier: Modifier, +} + +impl Default for Style { + fn default() -> Style { + Style { + fg: None, + bg: None, + add_modifier: Modifier::empty(), + sub_modifier: Modifier::empty(), + } + } +} + +impl Style { + /// Returns a `Style` resetting all properties. + pub fn reset() -> Style { + Style { + fg: Some(Color::Reset), + bg: Some(Color::Reset), + add_modifier: Modifier::empty(), + sub_modifier: Modifier::all(), + } + } + + /// Changes the foreground color. + /// + /// ## Examples + /// + /// ```rust + /// # use ratatui::style::{Color, Style}; + /// let style = Style::default().fg(Color::Blue); + /// let diff = Style::default().fg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().fg(Color::Red)); + /// ``` + pub fn fg(mut self, color: Color) -> Style { + self.fg = Some(color); + self + } + + /// Changes the background color. + /// + /// ## Examples + /// + /// ```rust + /// # use ratatui::style::{Color, Style}; + /// let style = Style::default().bg(Color::Blue); + /// let diff = Style::default().bg(Color::Red); + /// assert_eq!(style.patch(diff), Style::default().bg(Color::Red)); + /// ``` + pub fn bg(mut self, color: Color) -> Style { + self.bg = Some(color); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it adds the given modifier to the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use ratatui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD); + /// let diff = Style::default().add_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD | Modifier::ITALIC); + /// assert_eq!(patched.sub_modifier, Modifier::empty()); + /// ``` + pub fn add_modifier(mut self, modifier: Modifier) -> Style { + self.sub_modifier.remove(modifier); + self.add_modifier.insert(modifier); + self + } + + /// Changes the text emphasis. + /// + /// When applied, it removes the given modifier from the `Style` modifiers. + /// + /// ## Examples + /// + /// ```rust + /// # use ratatui::style::{Color, Modifier, Style}; + /// let style = Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC); + /// let diff = Style::default().remove_modifier(Modifier::ITALIC); + /// let patched = style.patch(diff); + /// assert_eq!(patched.add_modifier, Modifier::BOLD); + /// assert_eq!(patched.sub_modifier, Modifier::ITALIC); + /// ``` + pub fn remove_modifier(mut self, modifier: Modifier) -> Style { + self.add_modifier.remove(modifier); + self.sub_modifier.insert(modifier); + self + } + + /// Results in a combined style that is equivalent to applying the two individual styles to + /// a style one after the other. + /// + /// ## Examples + /// ``` + /// # use ratatui::style::{Color, Modifier, Style}; + /// let style_1 = Style::default().fg(Color::Yellow); + /// let style_2 = Style::default().bg(Color::Red); + /// let combined = style_1.patch(style_2); + /// assert_eq!( + /// Style::default().patch(style_1).patch(style_2), + /// Style::default().patch(combined)); + /// ``` + pub fn patch(mut self, other: Style) -> Style { + self.fg = other.fg.or(self.fg); + self.bg = other.bg.or(self.bg); + + self.add_modifier.remove(other.sub_modifier); + self.add_modifier.insert(other.add_modifier); + self.sub_modifier.remove(other.add_modifier); + self.sub_modifier.insert(other.sub_modifier); + + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn styles() -> Vec