diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4660d0f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,75 @@ +# What software is installed by default: +# https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources + +name: build + +on: + push: + pull_request: + +defaults: + run: + shell: bash + +jobs: + build: + strategy: + matrix: + os: + - ubuntu-22.04 + - windows-latest + - macos-latest + - macos-13 + runs-on: ${{ matrix.os }} + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Set up rust + run: rustup update + + - name: Build + run: cargo build --release + + - name: Test + run: cargo test --release + + - name: Record target triple + run: rustc -vV | awk '/^host/ { print $2 }' > target/release/host + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: cove-${{ matrix.os }} + path: | + target/release/cove + target/release/cove.exe + target/release/host + + release: + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + needs: + - build + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Zip artifacts + run: | + chmod +x cove-ubuntu-22.04/cove + chmod +x cove-windows-latest/cove.exe + chmod +x cove-macos-latest/cove + chmod +x cove-macos-13/cove + zip -jr "cove-$(cat cove-ubuntu-22.04/host).zip" cove-ubuntu-22.04/cove + zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe + zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove + zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove + + - name: Create new release + uses: softprops/action-gh-release@v2 + with: + body: Automated release, see [CHANGELOG.md](CHANGELOG.md) for more details. + files: "*.zip" diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a89179..4e428aa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "files.insertFinalNewline": true, "rust-analyzer.cargo.features": "all", "rust-analyzer.imports.granularity.enforce": true, - "rust-analyzer.imports.granularity.group": "module", + "rust-analyzer.imports.granularity.group": "crate", "rust-analyzer.imports.group.enable": true, "evenBetterToml.formatter.columnWidth": 100, } diff --git a/CHANGELOG.md b/CHANGELOG.md index 826d211..3f9ce8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,168 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Procedure when bumping the version number: + 1. Update dependencies in a separate commit 2. Set version number in `Cargo.toml` 3. Add new section in this changelog -4. Commit with message `Bump version to X.Y.Z` -5. Create tag named `vX.Y.Z` -6. Fast-forward branch `latest` -7. Push `master`, `latest` and the new tag +4. Run `cargo run help-config > CONFIG.md` +5. Commit with message `Bump version to X.Y.Z` +6. Create tag named `vX.Y.Z` +7. Push `master` and the new tag ## Unreleased +### Changed + +- Display emoji user id hashes in the nick list +- Compile linux binary with older glibc version + +## v0.9.3 - 2025-05-31 + +### Added + +- Key bindings for emoji-based user id hashing + +### Fixed + +- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms + +## v0.9.2 - 2025-03-14 + +### Added + +- `bell_on_mention` config option + +## v0.9.1 - 2025-03-01 + +### Fixed + +- Rendering glitches with unicode-based width estimation + +## v0.9.0 - 2025-02-23 + +### Added + +- Unicode-based grapheme width estimation method + - `width_estimation_method` config option + - `--width-estimation-method` option +- Room links are now included in the `I` message links list + +### Changed + +- Updated documentation for `time_zone` config option +- When connecting to a room using `n` in the room list, the cursor now moves to that room +- Updated list of emoji names + +### Removed + +- Special handling of &rl2dev + +### Fixed + +- Nick color in rare edge cases +- Message link list rendering bug + +## v0.8.3 - 2024-05-20 + +### Changed + +- Updated list of emoji names + +## v0.8.2 - 2024-04-25 + +### Changed + +- Renamed `json-stream` export format to `json-lines` (see ) +- Changed `json-lines` file extension from `.json` to `.jsonl` + +### Fixed + +- Crash when window is too small while empty message editor is visible +- Mistakes in output and docs +- Cove not cleaning up terminal state properly + +## v0.8.1 - 2024-01-11 + +### Added + +- Support for setting window title +- More information to room list heading +- Key bindings for live caesar cipher de- and encoding + +### Removed + +- Key binding to open present page + +## v0.8.0 - 2024-01-04 + +### Added + +- Support for multiple euph server domains +- Support for `TZ` environment variable +- `time_zone` config option +- `--domain` option to `cove export` command +- `--domain` option to `cove clear-cookies` command +- Domain field to "connect to new room" popup +- Welcome info box next to room list + +### Changed + +- The default euph domain is now https://euphoria.leet.nu/ everywhere +- The config file format was changed to support multiple euph servers with different domains. + Options previously located at `euph.rooms.*` should be reviewed and moved to `euph.servers."euphoria.leet.nu".rooms.*`. +- Tweaked F1 popup +- Tweaked chat message editor when nick list is foused +- Reduced connection timeout from 30 seconds to 10 seconds + +### Fixed + +- Room deletion popup accepting any room name +- Duplicated key presses on Windows + +## v0.7.1 - 2023-08-31 + +### Changed + +- Updated dependencies + +## v0.7.0 - 2023-05-14 + +### Added + +- Auto-generated config documentation + - in [CONFIG.md](CONFIG.md) + - via `help-config` CLI command +- `keys.*` config options +- `measure_widths` config option + +### Changed + +- Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss) +- Overhauled config system to support auto-generating documentation +- Overhauled key binding system to make key bindings configurable +- Redesigned F1 popup. It can now be toggled with F1 like the F12 log +- The F12 log can now be closed with escape +- Some more small UI fixes and adjustments to the new key binding system +- Reduced tearing when redrawing screen +- Split up project into sub-crates +- Simplified flake dependencies + +## v0.6.1 - 2023-04-10 + +### Changed + +- Improved JSON export performance +- Always show rooms from config file in room list + +### Fixed + +- Rooms reconnecting instead of showing error popups + ## v0.6.0 - 2023-04-04 ### Added + - Emoji support - `flake.nix`, making cove available as a nix flake - `json-stream` room export format @@ -24,31 +173,37 @@ Procedure when bumping the version number: - `--verbose` flag ### Changed + - Non-export info is now printed to stderr instead of stdout -- Recognizes links without scheme (e. g. `euphoria.io` instead of `https://euphoria.io`) +- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`) - Rooms waiting for reconnect are no longer sorted to bottom in default sort order ### Fixed + - Mentions not being stopped by `>` ## v0.5.2 - 2023-01-14 ### Added + - Key binding to open present page ### Changed + - Always connect to &rl2dev in ephemeral mode - Reduce amount of messages per &rl2dev log request ## v0.5.1 - 2022-11-27 ### Changed + - Increase reconnect delay to one minute - Print errors that occurred while cove was running more compactly ## v0.5.0 - 2022-09-26 ### Added + - Key bindings to navigate nick list - Room deletion confirmation popup - Message inspection popup @@ -57,10 +212,12 @@ Procedure when bumping the version number: - `rooms_sort_order` config option ### Changed + - Use nick changes to detect sessions for nick list - Support Unicode 15 ### Fixed + - Cursor being visible through popups - Cursor in lists when highlighted item moves off-screen - User disappearing from nick list when only one of their sessions disconnects @@ -68,6 +225,7 @@ Procedure when bumping the version number: ## v0.4.0 - 2022-09-01 ### Added + - Config file and `--config` cli option - `data_dir` config option - `ephemeral` config option @@ -83,14 +241,17 @@ Procedure when bumping the version number: - Key bindings to view and open links in a message ### Changed + - Some key bindings in the rooms list ### Fixed + - Rooms being stuck in "Connecting" state ## v0.3.0 - 2022-08-22 ### Added + - Account login and logout - Authentication dialog for password-protected rooms - Error popups in rooms when something goes wrong @@ -98,10 +259,12 @@ Procedure when bumping the version number: - Key binding to download more logs ### Changed + - Reduced amount of unnecessary redraws - Description of `export` CLI command ### Fixed + - Crash when connecting to nonexistent rooms - Crash when connecting to rooms that require authentication - Pasting multi-line strings into the editor @@ -109,15 +272,18 @@ Procedure when bumping the version number: ## v0.2.1 - 2022-08-11 ### Added + - Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) ### Fixed + - Joining new rooms no longer crashes cove - Scrolling when exiting message editor ## v0.2.0 - 2022-08-10 ### Added + - New messages are now marked as unseen - Sub-trees can now be folded - Support for pasting text into editors @@ -130,10 +296,12 @@ Procedure when bumping the version number: - Support for exporting multiple/all rooms at once ### Changed + - Reorganized export command - Slowed down room history download speed ### Fixed + - Chat rendering when deleting and re-joining a room - Spacing in some popups diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..82a7242 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,711 @@ +# Config file format + +Cove's config file uses the [TOML](https://toml.io/) format. + +Here is an example config that changes a few different options: + +```toml +measure_widths = true +rooms_sort_order = "importance" + +[euph.servers."euphoria.leet.nu".rooms] +welcome.autojoin = true +test.username = "badingle" +test.force_username = true +private.password = "foobar" + +[keys] +general.abort = ["esc", "ctrl+c"] +general.exit = "ctrl+q" +tree.action.fold_tree = "f" +``` + +## Key bindings + +Key bindings are specified as strings or lists of strings. Each string specifies +a main key and zero or more modifier keys. The modifier keys (if any) are listed +first, followed by the main key. They are separated by the `+` character and +**no** whitespace. + +Examples of key bindings: +- `"ctrl+c"` +- `"X"` (not `"shift+x"`) +- `"space"` or `" "` (both space bar) +- `["g", "home"]` +- `["K", "ctrl+up"]` +- `["f1", "?"]` +- `"ctrl+alt+f3"` +- `["enter", "any+enter"]` (matches `enter` regardless of modifiers) + +Available main keys: +- Any single character that can be typed +- `esc`, `enter`, `space`, `tab`, `backtab` +- `backspace`, `delete`, `insert` +- `left`, `right`, `up`, `down` +- `home`, `end`, `pageup`, `pagedown` +- `f1`, `f2`, ... + +Available modifiers: +- `shift` (must not be used with single characters) +- `ctrl` +- `alt` +- `any` (matches as long as at least one modifier is pressed) + +## Available options + +### `bell_on_mention` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +Ring the bell (character 0x07) when you are mentioned in a room. + +### `data_dir` + +**Required:** no +**Type:** path +**Default:** platform-dependent + +The directory that cove stores its data in when not running in ephemeral +mode. + +Relative paths are interpreted relative to the user's home directory. + +See also the `--data-dir` command line option. + +### `ephemeral` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +Whether to start in ephemeral mode. + +In ephemeral mode, cove doesn't store any data. It completely ignores +any options related to the data dir. + +See also the `--ephemeral` command line option. + +### `euph.servers..rooms..autojoin` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +Whether to automatically join this room on startup. + +### `euph.servers..rooms..force_username` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +If `euph.servers..rooms..username` is set, this will force +cove to set the username even if there is already a different username +associated with the current session. + +### `euph.servers..rooms..password` + +**Required:** no +**Type:** string + +If set, cove will try once to use this password to authenticate, should +the room be password-protected. + +### `euph.servers..rooms..username` + +**Required:** no +**Type:** string + +If set, cove will set this username upon joining if there is no username +associated with the current session. + +### `keys.cursor.down` + +**Required:** yes +**Type:** key binding +**Default:** `["j", "down"]` + +Move down. + +### `keys.cursor.to_bottom` + +**Required:** yes +**Type:** key binding +**Default:** `["G", "end"]` + +Move to bottom. + +### `keys.cursor.to_top` + +**Required:** yes +**Type:** key binding +**Default:** `["g", "home"]` + +Move to top. + +### `keys.cursor.up` + +**Required:** yes +**Type:** key binding +**Default:** `["k", "up"]` + +Move up. + +### `keys.editor.action.backspace` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+h", "backspace"]` + +Delete before cursor. + +### `keys.editor.action.clear` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+l"` + +Clear editor contents. + +### `keys.editor.action.delete` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+d", "delete"]` + +Delete after cursor. + +### `keys.editor.action.external` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+x", "alt+e"]` + +Edit in external editor. + +### `keys.editor.cursor.down` + +**Required:** yes +**Type:** key binding +**Default:** `"down"` + +Move down. + +### `keys.editor.cursor.end` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+e", "end"]` + +Move to end of line. + +### `keys.editor.cursor.left` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+b", "left"]` + +Move left. + +### `keys.editor.cursor.left_word` + +**Required:** yes +**Type:** key binding +**Default:** `["alt+b", "ctrl+left"]` + +Move left a word. + +### `keys.editor.cursor.right` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+f", "right"]` + +Move right. + +### `keys.editor.cursor.right_word` + +**Required:** yes +**Type:** key binding +**Default:** `["alt+f", "ctrl+right"]` + +Move right a word. + +### `keys.editor.cursor.start` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+a", "home"]` + +Move to start of line. + +### `keys.editor.cursor.up` + +**Required:** yes +**Type:** key binding +**Default:** `"up"` + +Move up. + +### `keys.general.abort` + +**Required:** yes +**Type:** key binding +**Default:** `"esc"` + +Abort/close. + +### `keys.general.confirm` + +**Required:** yes +**Type:** key binding +**Default:** `"enter"` + +Confirm. + +### `keys.general.exit` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+c"` + +Quit cove. + +### `keys.general.focus` + +**Required:** yes +**Type:** key binding +**Default:** `"tab"` + +Advance focus. + +### `keys.general.help` + +**Required:** yes +**Type:** key binding +**Default:** `"f1"` + +Show this help. + +### `keys.general.log` + +**Required:** yes +**Type:** key binding +**Default:** `"f12"` + +Show log. + +### `keys.room.action.account` + +**Required:** yes +**Type:** key binding +**Default:** `"A"` + +Manage account. + +### `keys.room.action.authenticate` + +**Required:** yes +**Type:** key binding +**Default:** `"a"` + +Authenticate. + +### `keys.room.action.more_messages` + +**Required:** yes +**Type:** key binding +**Default:** `"m"` + +Download more messages. + +### `keys.room.action.nick` + +**Required:** yes +**Type:** key binding +**Default:** `"n"` + +Change nick. + +### `keys.rooms.action.change_sort_order` + +**Required:** yes +**Type:** key binding +**Default:** `"s"` + +Change sort order. + +### `keys.rooms.action.connect` + +**Required:** yes +**Type:** key binding +**Default:** `"c"` + +Connect to selected room. + +### `keys.rooms.action.connect_all` + +**Required:** yes +**Type:** key binding +**Default:** `"C"` + +Connect to all rooms. + +### `keys.rooms.action.connect_autojoin` + +**Required:** yes +**Type:** key binding +**Default:** `"a"` + +Connect to all autojoin rooms. + +### `keys.rooms.action.delete` + +**Required:** yes +**Type:** key binding +**Default:** `"X"` + +Delete room. + +### `keys.rooms.action.disconnect` + +**Required:** yes +**Type:** key binding +**Default:** `"d"` + +Disconnect from selected room. + +### `keys.rooms.action.disconnect_all` + +**Required:** yes +**Type:** key binding +**Default:** `"D"` + +Disconnect from all rooms. + +### `keys.rooms.action.disconnect_non_autojoin` + +**Required:** yes +**Type:** key binding +**Default:** `"A"` + +Disconnect from all non-autojoin rooms. + +### `keys.rooms.action.new` + +**Required:** yes +**Type:** key binding +**Default:** `"n"` + +Connect to new room. + +### `keys.scroll.center_cursor` + +**Required:** yes +**Type:** key binding +**Default:** `"z"` + +Center cursor. + +### `keys.scroll.down_full` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+f", "pagedown"]` + +Scroll down a full screen. + +### `keys.scroll.down_half` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+d"` + +Scroll down half a screen. + +### `keys.scroll.down_line` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+e"` + +Scroll down one line. + +### `keys.scroll.up_full` + +**Required:** yes +**Type:** key binding +**Default:** `["ctrl+b", "pageup"]` + +Scroll up a full screen. + +### `keys.scroll.up_half` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+u"` + +Scroll up half a screen. + +### `keys.scroll.up_line` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+y"` + +Scroll up one line. + +### `keys.tree.action.decrease_caesar` + +**Required:** yes +**Type:** key binding +**Default:** `"C"` + +Decrease caesar cipher rotation. + +### `keys.tree.action.fold_tree` + +**Required:** yes +**Type:** key binding +**Default:** `"space"` + +Fold current message's subtree. + +### `keys.tree.action.increase_caesar` + +**Required:** yes +**Type:** key binding +**Default:** `"c"` + +Increase caesar cipher rotation. + +### `keys.tree.action.inspect` + +**Required:** yes +**Type:** key binding +**Default:** `"i"` + +Inspect selected element. + +### `keys.tree.action.links` + +**Required:** yes +**Type:** key binding +**Default:** `"I"` + +List links found in message. + +### `keys.tree.action.mark_older_seen` + +**Required:** yes +**Type:** key binding +**Default:** `"ctrl+s"` + +Mark all older messages as seen. + +### `keys.tree.action.mark_visible_seen` + +**Required:** yes +**Type:** key binding +**Default:** `"S"` + +Mark all visible messages as seen. + +### `keys.tree.action.new_thread` + +**Required:** yes +**Type:** key binding +**Default:** `"t"` + +Start a new thread. + +### `keys.tree.action.reply` + +**Required:** yes +**Type:** key binding +**Default:** `"r"` + +Reply to message, inline if possible. + +### `keys.tree.action.reply_alternate` + +**Required:** yes +**Type:** key binding +**Default:** `"R"` + +Reply opposite to normal reply. + +### `keys.tree.action.toggle_nick_emoji` + +**Required:** yes +**Type:** key binding +**Default:** `"e"` + +Toggle agent id based nick emoji. + +### `keys.tree.action.toggle_seen` + +**Required:** yes +**Type:** key binding +**Default:** `"s"` + +Toggle current message's seen status. + +### `keys.tree.cursor.to_above_sibling` + +**Required:** yes +**Type:** key binding +**Default:** `["K", "ctrl+up"]` + +Move to above sibling. + +### `keys.tree.cursor.to_below_sibling` + +**Required:** yes +**Type:** key binding +**Default:** `["J", "ctrl+down"]` + +Move to below sibling. + +### `keys.tree.cursor.to_newer_message` + +**Required:** yes +**Type:** key binding +**Default:** `["l", "right"]` + +Move to newer message. + +### `keys.tree.cursor.to_newer_unseen_message` + +**Required:** yes +**Type:** key binding +**Default:** `["L", "ctrl+right"]` + +Move to newer unseen message. + +### `keys.tree.cursor.to_older_message` + +**Required:** yes +**Type:** key binding +**Default:** `["h", "left"]` + +Move to older message. + +### `keys.tree.cursor.to_older_unseen_message` + +**Required:** yes +**Type:** key binding +**Default:** `["H", "ctrl+left"]` + +Move to older unseen message. + +### `keys.tree.cursor.to_parent` + +**Required:** yes +**Type:** key binding +**Default:** `"p"` + +Move to parent. + +### `keys.tree.cursor.to_root` + +**Required:** yes +**Type:** key binding +**Default:** `"P"` + +Move to root. + +### `measure_widths` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +Whether to measure the width of graphemes (i.e. characters) as displayed +by the terminal emulator instead of estimating the width. + +Enabling this makes rendering a bit slower but more accurate. The screen +might also flash when encountering new graphemes. + +See also the `--measure-widths` command line option. + +### `offline` + +**Required:** yes +**Type:** boolean +**Default:** `false` + +Whether to start in offline mode. + +In offline mode, cove won't automatically join rooms marked via the +`autojoin` option on startup. You can still join those rooms manually by +pressing `a` in the rooms list. + +See also the `--offline` command line option. + +### `rooms_sort_order` + +**Required:** yes +**Type:** string +**Values:** `"alphabet"`, `"importance"` +**Default:** `"alphabet"` + +Initial sort order of rooms list. + +`"alphabet"` sorts rooms in alphabetic order. + +`"importance"` sorts rooms by the following criteria (in descending +order of priority): + +1. connected rooms before unconnected rooms +2. rooms with unread messages before rooms without +3. alphabetic order + +### `time_zone` + +**Required:** no +**Type:** string +**Default:** `$TZ` or local system time zone + +Time zone that chat timestamps should be displayed in. + +This option can either be the string `"localtime"`, a [POSIX TZ string], +or a [tz identifier] from the [tz database]. + +When not set or when set to `"localtime"`, cove attempts to use your +system's configured time zone, falling back to UTC. + +When the string begins with a colon or doesn't match the a POSIX TZ +string format, it is interpreted as a tz identifier and looked up in +your system's tz database (or a bundled tz database on Windows). + +If the `TZ` environment variable exists, it overrides this option. + +[POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03 +[tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones +[tz database]: https://en.wikipedia.org/wiki/Tz_database + +### `width_estimation_method` + +**Required:** yes +**Type:** string +**Values:** `"legacy"`, `"unicode"` +**Default:** `"legacy"` + +How to estimate the width of graphemes (i.e. characters) as displayed by +the terminal emulator. + +`"legacy"`: Use a legacy method that should mostly work on most terminal +emulators. This method will never be correct in all cases since every +terminal emulator handles grapheme widths slightly differently. However, +those cases are usually rare (unless you view a lot of emoji). + +`"unicode"`: Use the unicode standard in a best-effort manner to +determine grapheme widths. Some terminals (e.g. ghostty) can make use of +this. + +This method is used when `measure_widths` is set to `false`. + +See also the `--width-estimation-method` command line option. diff --git a/Cargo.lock b/Cargo.lock index 70a95c3..2f45a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,107 +1,182 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "ahash" -version = "0.7.6" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ - "getrandom", + "cfg-if", "once_cell", "version_check", + "zerocopy 0.7.35", ] [[package]] name = "aho-corasick" -version = "0.7.20" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.2.6" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342258dd14006105c2b75ab1bd7543a03bdf0cfc94383303ac212a04939dff6f" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", + "anstyle-query", "anstyle-wincon", - "concolor-override", - "concolor-query", - "is-terminal", + "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "0.3.5" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ea9e81bd02e310c216d080f6223c179012256e5151c41db88d12c88a1684d2" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" -version = "0.1.1" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7d1bb534e9efed14f3e5f44e7dd1a4f709384023a4165199a4241e18dff0116" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] -name = "anstyle-wincon" -version = "0.2.0" +name = "anstyle-query" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3127af6145b149f3287bb9a0d10ad9c5692dba8c53ad48285e5bec4063834fa" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", - "windows-sys 0.45.0", + "once_cell", + "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.70" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "async-trait" -version = "0.1.68" +version = "0.1.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "base64" -version = "0.13.1" +name = "aws-lc-rs" +version = "1.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "dabb68eb3a7aa08b46fddfd59a3d55c978243557a90ab804769f7e20e67d2b01" +dependencies = [ + "aws-lc-sys", + "zeroize", +] [[package]] -name = "base64" -version = "0.21.0" +name = "aws-lc-sys" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" +checksum = "6bbe221bbf523b625a4dd8585c7f38166e31167ec2ca98051dbcb4c3b6e825d2" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", + "which", +] [[package]] name = "bitflags" -version = "1.3.2" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "block-buffer" @@ -112,39 +187,40 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bumpalo" -version = "3.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - [[package]] name = "bytes" -version = "1.4.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "caseless" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808dab3318747be122cb31d36de18d4d1c81277a76f8332a02b81a3d73463d7f" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" dependencies = [ - "regex", "unicode-normalization", ] [[package]] name = "cc" -version = "1.0.79" +version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] [[package]] name = "cfg-if" @@ -153,67 +229,76 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "clap" -version = "4.2.1" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046ae530c528f252094e4a77886ee1374437744b2bff1497aa898bbddbbb29b3" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" dependencies = [ "clap_builder", "clap_derive", - "once_cell", ] [[package]] name = "clap_builder" -version = "4.2.1" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223163f58c9a40c3b0a43e1c4b50a9ce09f007ea2cb1ec258a687945b4b7929f" +checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" dependencies = [ "anstream", "anstyle", - "bitflags", "clap_lex", "strsim", ] [[package]] name = "clap_derive" -version = "4.2.0" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9644cd56d6b87dbe899ef8b053e331c0637664e9e21a33dfcdc36093f5c5c4" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.13", + "syn", ] [[package]] name = "clap_lex" -version = "0.4.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] -name = "concolor-override" -version = "1.0.0" +name = "cmake" +version = "0.1.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a855d4a1978dc52fb0536a04d384c2c0c1aa273597f08b77c8c4d3b2eec6037f" - -[[package]] -name = "concolor-query" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" dependencies = [ - "windows-sys 0.45.0", + "cc", ] [[package]] -name = "cookie" -version = "0.17.0" +name = "colorchoice" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "time", "version_check", @@ -221,9 +306,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ "core-foundation-sys", "libc", @@ -231,61 +316,92 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cove" -version = "0.6.0" +version = "0.9.3" dependencies = [ "anyhow", "async-trait", "clap", "cookie", + "cove-config", + "cove-input", "crossterm", "directories", - "edit", "euphoxide", + "jiff", "linkify", "log", - "once_cell", "open", "parking_lot", "rusqlite", - "serde", + "rustls", "serde_json", "thiserror", - "time", "tokio", - "tokio-tungstenite", - "toml", "toss", - "unicode-segmentation", "unicode-width", "vault", ] +[[package]] +name = "cove-config" +version = "0.9.3" +dependencies = [ + "cove-input", + "cove-macro", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "cove-input" +version = "0.9.3" +dependencies = [ + "cove-macro", + "crossterm", + "edit", + "parking_lot", + "serde", + "serde_either", + "thiserror", + "toss", +] + +[[package]] +name = "cove-macro" +version = "0.9.3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cpufeatures" -version = "0.2.6" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crossterm" -version = "0.26.1" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -293,9 +409,9 @@ dependencies = [ [[package]] name = "crossterm_winapi" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] @@ -311,10 +427,25 @@ dependencies = [ ] [[package]] -name = "digest" -version = "0.10.6" +name = "data-encoding" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", @@ -322,29 +453,36 @@ dependencies = [ [[package]] name = "directories" -version = "5.0.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be3be809c18e089de43bdc504652bb2bc473fca8756131f8689db8cf079ba9" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04414300db88f70d74c5ff54e50f9e1d1737d9a5b90f53fcf2e95ca2a9ab554b" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", + "option-ext", "redox_users", - "windows-sys 0.45.0", + "windows-sys 0.59.0", ] [[package]] -name = "edit" -version = "0.1.4" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c562aa71f7bc691fde4c6bf5f93ae5a5298b617c2eb44c76c87832299a17fbb4" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "edit" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f364860e764787163c8c8f58231003839be31276e821e2ad2092ddf496b1aa09" dependencies = [ "tempfile", "which", @@ -352,45 +490,40 @@ dependencies = [ [[package]] name = "either" -version = "1.8.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.0" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d6a0976c999d473fe89ad888d5a284e55366d9dc9038b1ba2aa15128c4afa0" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ - "errno-dragonfly", - "libc", - "windows-sys 0.45.0", -] - -[[package]] -name = "errno-dragonfly" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", "libc", + "windows-sys 0.59.0", ] [[package]] name = "euphoxide" -version = "0.3.1" -source = "git+https://github.com/Garmelon/euphoxide.git?rev=768a259f02f5743f7812904657a99f1f58b8e835#768a259f02f5743f7812904657a99f1f58b8e835" +version = "0.6.1" +source = "git+https://github.com/Garmelon/euphoxide.git?tag=v0.6.1#7a292c429ad44aa6aa52fc381e3168841d6303b0" dependencies = [ "async-trait", "caseless", "clap", "cookie", "futures-util", + "jiff", "log", "serde", "serde_json", - "time", "tokio", "tokio-stream", "tokio-tungstenite", @@ -399,9 +532,9 @@ dependencies = [ [[package]] name = "fallible-iterator" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] name = "fallible-streaming-iterator" @@ -411,12 +544,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "1.9.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fnv" @@ -425,37 +555,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "form_urlencoded" -version = "1.1.0" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" -dependencies = [ - "percent-encoding", -] +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-core" -version = "0.3.28" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-sink" -version = "0.3.28" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.28" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.28" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-sink", @@ -477,59 +604,83 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.8" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "getrandom" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", ] [[package]] -name = "hashlink" -version = "0.8.1" +name = "hashbrown" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.2.6" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "libc", + "windows-sys 0.59.0", ] -[[package]] -name = "hermit-abi" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" - [[package]] name = "http" -version = "0.2.9" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -538,88 +689,153 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "indexmap" -version = "1.9.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" dependencies = [ - "autocfg", - "hashbrown", + "equivalent", + "hashbrown 0.15.2", ] [[package]] -name = "instant" -version = "0.1.12" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "cfg-if", + "once_cell", ] [[package]] -name = "io-lifetimes" -version = "1.0.9" +name = "is-wsl" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09270fd4fa1111bc614ed2246c7ef56239a3063d5be0d1ec3b589c505d400aeb" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" dependencies = [ - "hermit-abi 0.3.1", - "libc", - "windows-sys 0.45.0", + "is-docker", + "once_cell", ] [[package]] -name = "is-terminal" -version = "0.4.6" +name = "is_terminal_polyfill" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256017f749ab3117e93acb91063009e1f1bb56d03965b14c2c8df4eb02c524d8" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ - "hermit-abi 0.3.1", - "io-lifetimes", - "rustix", - "windows-sys 0.45.0", + "either", ] [[package]] name = "itoa" -version = "1.0.6" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] -name = "js-sys" -version = "0.3.61" +name = "jiff" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" dependencies = [ - "wasm-bindgen", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", ] [[package]] -name = "libc" -version = "0.2.141" +name = "jiff-static" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5" +checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962e1dfe9b2d75a84536cf5bf5eaaa4319aa7906c7160134a22883ac316d5f31" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a63c62e404e7b92979d2792352d885a7f8f83fd1d0d31eea582d77b2ceca697e" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] [[package]] name = "libsqlite3-sys" -version = "0.25.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "cc", "pkg-config", @@ -628,24 +844,30 @@ dependencies = [ [[package]] name = "linkify" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96dd5884008358112bc66093362197c7248ece00d46624e2cf71e50029f8cff5" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" dependencies = [ "memchr", ] [[package]] name = "linux-raw-sys" -version = "0.3.1" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" [[package]] name = "lock_api" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -653,67 +875,120 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] -name = "mio" -version = "0.8.6" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.45.0", + "adler2", ] [[package]] -name = "num_cpus" -version = "1.15.0" +name = "mio" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi 0.2.6", "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", ] [[package]] name = "once_cell" -version = "1.17.1" +version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" [[package]] name = "open" -version = "4.0.1" +version = "5.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075c5203b3a2b698bc72c6c10b1f6263182135751d5013ea66e8a4b3d0562a43" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" dependencies = [ + "is-wsl", + "libc", "pathdiff", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -721,34 +996,28 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.7" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.2.16", + "redox_syscall", "smallvec", - "windows-sys 0.45.0", + "windows-targets", ] [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" - -[[package]] -name = "percent-encoding" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -758,50 +1027,84 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.26" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.23", +] + +[[package]] +name = "prettyplease" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.26" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ - "libc", "rand_chacha", "rand_core", + "zerocopy 0.8.23", ] [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core", @@ -809,47 +1112,50 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.1", ] [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" dependencies = [ "bitflags", ] [[package]] name = "redox_users" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ - "getrandom", - "redox_syscall 0.2.16", + "getrandom 0.2.15", + "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.7.3" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -858,30 +1164,29 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "ring" -version = "0.16.20" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", + "cfg-if", + "getrandom 0.2.15", "libc", - "once_cell", - "spin", "untrusted", - "web-sys", - "winapi", + "windows-sys 0.52.0", ] [[package]] name = "rusqlite" -version = "0.28.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags", "fallible-iterator", @@ -893,88 +1198,114 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "0.37.7" +name = "rustc-demangle" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aae838e49b3d63e9274e1c01833cc8139d3fec468c3b84688c628f44b1ae11d" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags", "errno", - "io-lifetimes", "libc", - "linux-raw-sys", - "windows-sys 0.45.0", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.9.3", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.20.8" +version = "0.23.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" +checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" dependencies = [ + "aws-lc-rs", "log", - "ring", - "sct", - "webpki", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.6.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pki-types", "schannel", "security-framework", ] [[package]] -name = "rustls-pemfile" -version = "1.0.2" +name = "rustls-pki-types" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" -dependencies = [ - "base64 0.21.0", -] - -[[package]] -name = "ryu" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" - -[[package]] -name = "schannel" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" -dependencies = [ - "windows-sys 0.42.0", -] - -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "sct" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ + "aws-lc-rs", "ring", + "rustls-pki-types", "untrusted", ] [[package]] -name = "security-framework" -version = "2.8.2" +name = "ryu" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ "bitflags", "core-foundation", @@ -985,9 +1316,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.8.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31c9bb296072e961fcbd8853511dd39c2d8be2deb1e17c6860b1d30732b323b4" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -995,49 +1326,70 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.159" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.159" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn", +] + +[[package]] +name = "serde_either" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689643f4e7826ffcd227d2cc166bfdf5869750191ffe9fd593531e6ba351f2fb" +dependencies = [ + "serde", + "serde-value", ] [[package]] name = "serde_json" -version = "1.0.95" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" -version = "0.6.1" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "sha1" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -1045,10 +1397,16 @@ dependencies = [ ] [[package]] -name = "signal-hook" -version = "0.3.15" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", @@ -1056,9 +1414,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", @@ -1067,66 +1425,55 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "slab" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" -version = "1.10.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" -version = "0.4.9" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", - "winapi", + "windows-sys 0.52.0", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -1135,44 +1482,47 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.5.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" +checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" dependencies = [ - "cfg-if", "fastrand", - "redox_syscall 0.3.5", - "rustix", - "windows-sys 0.45.0", + "getrandom 0.3.1", + "once_cell", + "rustix 1.0.2", + "windows-sys 0.59.0", ] [[package]] name = "thiserror" -version = "1.0.40" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.40" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn", ] [[package]] name = "time" -version = "0.3.20" +version = "0.3.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" dependencies = [ + "deranged", "itoa", + "num-conv", + "powerfmt", "serde", "time-core", "time-macros", @@ -1180,24 +1530,25 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" [[package]] name = "time-macros" -version = "0.2.8" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" dependencies = [ + "num-conv", "time-core", ] [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] @@ -1210,50 +1561,48 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.27.0" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ - "autocfg", + "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.45.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.0.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn", ] [[package]] name = "tokio-rustls" -version = "0.23.4" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" dependencies = [ "rustls", "tokio", - "webpki", ] [[package]] name = "tokio-stream" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb52b74f05dbf495a8fba459fdc331812b96aa086d9eb78101fa0d4569c3313" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -1262,25 +1611,25 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.18.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", "rustls", "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-rustls", "tungstenite", - "webpki", ] [[package]] name = "toml" -version = "0.7.3" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b403acf6f2bb0859c93c7f0d967cb4a75a7ac552100f9322faf64dc047669b21" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" dependencies = [ "serde", "serde_spanned", @@ -1290,18 +1639,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.1" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.19.8" +version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", "serde", @@ -1312,9 +1661,10 @@ dependencies = [ [[package]] name = "toss" -version = "0.1.0" -source = "git+https://github.com/Garmelon/toss.git?rev=0d59116012a51516a821991e2969b1cf4779770f#0d59116012a51516a821991e2969b1cf4779770f" +version = "0.3.4" +source = "git+https://github.com/Garmelon/toss.git?tag=v0.3.4#57aa8c59308f6f0aa82bde415a42b56c3d6f7c4d" dependencies = [ + "async-trait", "crossterm", "unicode-linebreak", "unicode-segmentation", @@ -1323,90 +1673,67 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.18.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "base64 0.13.1", - "byteorder", "bytes", + "data-encoding", "http", "httparse", "log", "rand", "rustls", + "rustls-pki-types", "sha1", "thiserror", - "url", "utf-8", - "webpki", ] [[package]] name = "typenum" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" - -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-ident" -version = "1.0.8" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-linebreak" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5faade31a542b8b35855fff6e8def199853b2da8da256da52f52f1316ee3137" -dependencies = [ - "hashbrown", - "regex", -] +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.10" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" -version = "0.7.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "url" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "utf-8" @@ -1416,14 +1743,14 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vault" -version = "0.1.0" -source = "git+https://github.com/Garmelon/vault.git?tag=v0.1.0#028c72cac4e84bfbbf9fb03b15acb59989a31df9" +version = "0.4.0" +source = "git+https://github.com/Garmelon/vault.git?tag=v0.4.0#a53254d2e787d15fd2d00584fddf9b84e79572ee" dependencies = [ "rusqlite", "tokio", @@ -1437,9 +1764,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" @@ -1448,88 +1775,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.84" +name = "wasi" +version = "0.13.3+wasi-0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 1.0.109", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" - -[[package]] -name = "web-sys" -version = "0.3.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" -dependencies = [ - "ring", - "untrusted", + "wit-bindgen-rt", ] [[package]] name = "which" -version = "4.4.0" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", - "libc", + "home", "once_cell", + "rustix 0.38.44", ] [[package]] @@ -1556,37 +1819,32 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" -version = "0.42.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.45.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -1595,51 +1853,112 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.4.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" +checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" +dependencies = [ + "zerocopy-derive 0.8.23", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 8721800..33f245f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,57 +1,72 @@ -[package] -name = "cove" -version = "0.6.0" -edition = "2021" +[workspace] +resolver = "3" +members = ["cove", "cove-*"] -[dependencies] -anyhow = "1.0.70" -async-trait = "0.1.68" -clap = { version = "4.2.1", features = ["derive", "deprecated"] } -cookie = "0.17.0" -crossterm = "0.26.1" -directories = "5.0.0" -edit = "0.1.4" -linkify = "0.9.0" -log = { version = "0.4.17", features = ["std"] } -once_cell = "1.17.1" -open = "4.0.1" -parking_lot = "0.12.1" -rusqlite = { version = "0.28.0", features = ["bundled", "time"] } -serde = { version = "1.0.159", features = ["derive"] } -serde_json = "1.0.95" -thiserror = "1.0.40" -tokio = { version = "1.27.0", features = ["full"] } -toml = "0.7.3" -unicode-segmentation = "1.10.1" -unicode-width = "0.1.10" +[workspace.package] +version = "0.9.3" +edition = "2024" -[dependencies.time] -version = "0.3.20" -features = ["macros", "formatting", "parsing", "serde"] +[workspace.dependencies] +anyhow = "1.0.97" +async-trait = "0.1.87" +clap = { version = "4.5.32", features = ["derive", "deprecated"] } +cookie = "0.18.1" +crossterm = "0.28.1" +directories = "6.0.0" +edit = "0.1.5" +jiff = "0.2.4" +linkify = "0.10.0" +log = { version = "0.4.26", features = ["std"] } +open = "5.3.2" +parking_lot = "0.12.3" +proc-macro2 = "1.0.94" +quote = "1.0.40" +rusqlite = { version = "0.31.0", features = ["bundled", "time"] } +rustls = "0.23.23" +serde = { version = "1.0.219", features = ["derive"] } +serde_either = "0.2.1" +serde_json = "1.0.140" +syn = "2.0.100" +thiserror = "2.0.12" +tokio = { version = "1.44.1", features = ["full"] } +toml = "0.8.20" +unicode-width = "0.2.0" -[dependencies.tokio-tungstenite] -version = "0.18.0" -features = ["rustls-tls-native-roots"] - -[dependencies.euphoxide] +[workspace.dependencies.euphoxide] git = "https://github.com/Garmelon/euphoxide.git" -rev = "768a259f02f5743f7812904657a99f1f58b8e835" +tag = "v0.6.1" features = ["bot"] -# [patch."https://github.com/Garmelon/euphoxide.git"] -# euphoxide = { path = "../euphoxide/" } - -[dependencies.toss] +[workspace.dependencies.toss] git = "https://github.com/Garmelon/toss.git" -rev = "0d59116012a51516a821991e2969b1cf4779770f" +tag = "v0.3.4" -# [patch."https://github.com/Garmelon/toss.git"] -# toss = { path = "../toss/" } - -[dependencies.vault] +[workspace.dependencies.vault] git = "https://github.com/Garmelon/vault.git" -tag = "v0.1.0" +tag = "v0.4.0" features = ["tokio"] -# [patch."https://github.com/Garmelon/vault.git"] -# vault = { path = "../vault/" } +[workspace.lints] +rust.unsafe_code = { level = "forbid", priority = 1 } +# Lint groups +rust.deprecated_safe = "warn" +rust.future_incompatible = "warn" +rust.keyword_idents = "warn" +rust.rust_2018_idioms = "warn" +rust.unused = "warn" +# Individual lints +rust.non_local_definitions = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.single_use_lifetimes = "warn" +rust.unit_bindings = "warn" +rust.unnameable_types = "warn" +rust.unused_crate_dependencies = "warn" +rust.unused_import_braces = "warn" +rust.unused_lifetimes = "warn" +rust.unused_qualifications = "warn" +# Clippy +clippy.use_self = "warn" + +[profile.dev.package."*"] +opt-level = 3 diff --git a/README.md b/README.md index 4659f61..22fef83 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # cove -Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded +Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded real-time chat platform. ![A very meta screenshot](screenshot.png) -It runs on Linux, Windows and macOS. +It runs on Linux, Windows, and macOS. + +## Installing cove + +Download a binary of your choice from the +[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest). ## Using cove @@ -18,156 +23,11 @@ things in) won't automatically shrink. If it takes up too much space, try running `cove gc` and waiting for it to finish. This isn't done automatically because it can take quite a while. -## Installation +## Configuring cove -At this point, cove is not available via any package manager. - -Cove is available as a Nix Flake. To try it out, you can use -```bash -$ nix run --override-input nixpkgs nixpkgs github:Garmelon/cove/latest -``` - -## Manual installation - -This section contains instructions on how to install cove by compiling it yourself. -It doesn't assume you know how to program, but it does assume basic familiarity with the command line on your platform of choice. -Cove runs in the terminal, after all. - -### Installing rustup - -Cove is written in Rust, so the first step is to install rustup. Either install -it from your package manager of choice (if you have one) or use the -[installer](https://rustup.rs/). - -Test your installation by running `rustup --version` and `cargo --version`. If -rustup is installed correctly, both of these should show a version number. - -Cove is designed on the current version of the stable toolchain. If cove doesn't -compile, you can try switching to the stable toolchain and updating it using the -following commands: -```bash -$ rustup default stable -$ rustup update -``` - -### Installing cove - -To install or update to the latest release of cove, run the following command: - -```bash -$ cargo install --force --git https://github.com/Garmelon/cove --branch latest -``` - -If you like to live dangerously and want to install or update to the latest, -bleeding-edge, possibly-broken commit from the repo's main branch, run the -following command. - -**Warning:** This could corrupt your vault. Make sure to make a backup before -running the command. - -```bash -$ cargo install --force --git https://github.com/Garmelon/cove -``` - -To install a specific version of cove, run the following command and substitute -in the full version you want to install: - -```bash -$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0 -``` - -## Config file - -Cove's config file uses the [TOML](https://toml.io/) format. +A complete list of config options is available in the [CONFIG.md](CONFIG.md) +file or via `cove help-config`. When launched, cove prints the location it is loading its config file from. To configure cove, create a config file at that location. This location can be changed via the `--config` command line option. - -The following is a complete list of available options. If a command line option -with the same purpose exists, it takes precedence over the option specified in -the config file. - -### `data_dir` - -**Type:** String (representing path) -**Default:** Platform dependent - -The directory that cove stores its data in when not running in ephemeral mode. - -Relative paths are interpreted relative to the user's home directory. - -See also the `--data-dir` command line option. - -### `ephemeral` - -**Type:** Boolean -**Default:** `false` - -Whether to start in ephemeral mode. - -In ephemeral mode, cove doesn't store any data. It completely ignores any -options related to the data dir. - -See also the `--ephemeral` command line option. - -### `offline` - -**Type:** Boolean -**Default:** `false` - -Whether to start in offline mode. - -In offline mode, cove won't automatically join rooms marked via the `autojoin` -option on startup. You can still join those rooms manually by pressing `a` in -the rooms list. - -See also the `--offline` command line option. - -### `rooms_sort_order` - -**Type:** String, one of `alphabetic`, `importance` -**Default:** `alphabetic` - -Initial sort order of rooms list. - -`alphabetic` sorts rooms in alphabetic order. - -`importance` sorts rooms by the following criteria (in descending order of -priority): - -1. connected rooms before unconnected rooms -2. rooms with unread messages before rooms without -3. alphabetic order - -### `euph.rooms..autojoin` - -**Type:** Boolean -**Default:** `false` - -Whether to automatically join this room on startup. - -### `euph.rooms..username` - -**Type:** String -**Default:** Not set - -If set, cove will set this username upon joining if there is no username -associated with the current session. - -### `euph.rooms..force_username` - -**Type:** Boolean -**Default:** `false` - -If `euph.rooms..username` is set, this will force cove to set the username -even if there is already a different username associated with the current -session. - -### `euph.rooms..password` - -**Type:** String -**Default:** Not set - -If set, cove will try once to use this password to authenticate, should the room -be password-protected. diff --git a/cove-config/CONFIG.md b/cove-config/CONFIG.md new file mode 100644 index 0000000..e69de29 diff --git a/cove-config/Cargo.toml b/cove-config/Cargo.toml new file mode 100644 index 0000000..9102bfd --- /dev/null +++ b/cove-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cove-config" +version.workspace = true +edition.workspace = true + +[dependencies] +cove-input = { path = "../cove-input" } +cove-macro = { path = "../cove-macro" } + +serde.workspace = true +thiserror.workspace = true +toml.workspace = true + +[lints] +workspace = true diff --git a/cove-config/src/doc.rs b/cove-config/src/doc.rs new file mode 100644 index 0000000..35f6074 --- /dev/null +++ b/cove-config/src/doc.rs @@ -0,0 +1,267 @@ +//! Auto-generate markdown documentation. + +use std::{collections::HashMap, path::PathBuf}; + +use cove_input::KeyBinding; +pub use cove_macro::Document; +use serde::Serialize; + +const MARKDOWN_INTRODUCTION: &str = r#"# Config file format + +Cove's config file uses the [TOML](https://toml.io/) format. + +Here is an example config that changes a few different options: + +```toml +measure_widths = true +rooms_sort_order = "importance" + +[euph.servers."euphoria.leet.nu".rooms] +welcome.autojoin = true +test.username = "badingle" +test.force_username = true +private.password = "foobar" + +[keys] +general.abort = ["esc", "ctrl+c"] +general.exit = "ctrl+q" +tree.action.fold_tree = "f" +``` + +## Key bindings + +Key bindings are specified as strings or lists of strings. Each string specifies +a main key and zero or more modifier keys. The modifier keys (if any) are listed +first, followed by the main key. They are separated by the `+` character and +**no** whitespace. + +Examples of key bindings: +- `"ctrl+c"` +- `"X"` (not `"shift+x"`) +- `"space"` or `" "` (both space bar) +- `["g", "home"]` +- `["K", "ctrl+up"]` +- `["f1", "?"]` +- `"ctrl+alt+f3"` +- `["enter", "any+enter"]` (matches `enter` regardless of modifiers) + +Available main keys: +- Any single character that can be typed +- `esc`, `enter`, `space`, `tab`, `backtab` +- `backspace`, `delete`, `insert` +- `left`, `right`, `up`, `down` +- `home`, `end`, `pageup`, `pagedown` +- `f1`, `f2`, ... + +Available modifiers: +- `shift` (must not be used with single characters) +- `ctrl` +- `alt` +- `any` (matches as long as at least one modifier is pressed) + +## Available options +"#; + +pub fn toml_value_as_markdown(value: &T) -> String { + let mut result = String::new(); + value + .serialize(toml::ser::ValueSerializer::new(&mut result)) + .expect("not a valid toml value"); + format!("`{result}`") +} + +#[derive(Clone, Default)] +pub struct ValueInfo { + pub required: Option, + pub r#type: Option, + pub values: Option>, + pub default: Option, +} + +impl ValueInfo { + fn as_markdown(&self) -> String { + let mut lines = vec![]; + + if let Some(required) = self.required { + let yesno = if required { "yes" } else { "no" }; + lines.push(format!("**Required:** {yesno}")); + } + + if let Some(r#type) = &self.r#type { + lines.push(format!("**Type:** {type}")); + } + + if let Some(values) = &self.values { + let values = values.join(", "); + lines.push(format!("**Values:** {values}")); + } + + if let Some(default) = &self.default { + lines.push(format!("**Default:** {default}")); + } + + lines.join(" \n") + } +} + +#[derive(Clone, Default)] +pub struct StructInfo { + pub fields: HashMap>, +} + +#[derive(Clone, Default)] +pub struct WrapInfo { + pub inner: Option>, + pub metavar: Option, +} + +#[derive(Clone, Default)] +pub struct Doc { + pub description: Option, + + pub value_info: ValueInfo, + pub struct_info: StructInfo, + pub wrap_info: WrapInfo, +} + +struct Entry { + path: String, + description: String, + value_info: ValueInfo, +} + +impl Entry { + fn new(description: String, value_info: ValueInfo) -> Self { + Self { + path: String::new(), + description, + value_info, + } + } + + fn with_parent(mut self, segment: String) -> Self { + if self.path.is_empty() { + self.path = segment; + } else { + self.path = format!("{segment}.{}", self.path); + } + self + } +} + +impl Doc { + fn entries(&self) -> Vec { + let mut entries = vec![]; + + if let Some(description) = &self.description { + entries.push(Entry::new(description.clone(), self.value_info.clone())); + } + + for (segment, field) in &self.struct_info.fields { + entries.extend( + field + .entries() + .into_iter() + .map(|entry| entry.with_parent(segment.clone())), + ); + } + + if let Some(inner) = &self.wrap_info.inner { + let segment = match &self.wrap_info.metavar { + Some(metavar) => format!("<{metavar}>"), + None => "<...>".to_string(), + }; + entries.extend( + inner + .entries() + .into_iter() + .map(|entry| entry.with_parent(segment.clone())), + ); + } + + entries + } + + pub fn as_markdown(&self) -> String { + // Print entries in alphabetical order to make generated documentation + // format more stable. + let mut entries = self.entries(); + entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + + let mut result = String::new(); + + result.push_str(MARKDOWN_INTRODUCTION); + + for entry in entries { + result.push_str(&format!("\n### `{}`\n", entry.path)); + + let value_info = entry.value_info.as_markdown(); + if !value_info.is_empty() { + result.push_str(&format!("\n{value_info}\n")); + } + + if !entry.description.is_empty() { + result.push_str(&format!("\n{}\n", entry.description)); + } + } + + result + } +} + +pub trait Document { + fn doc() -> Doc; +} + +impl Document for String { + fn doc() -> Doc { + let mut doc = Doc::default(); + doc.value_info.required = Some(true); + doc.value_info.r#type = Some("string".to_string()); + doc + } +} + +impl Document for bool { + fn doc() -> Doc { + let mut doc = Doc::default(); + doc.value_info.required = Some(true); + doc.value_info.r#type = Some("boolean".to_string()); + doc + } +} + +impl Document for PathBuf { + fn doc() -> Doc { + let mut doc = Doc::default(); + doc.value_info.required = Some(true); + doc.value_info.r#type = Some("path".to_string()); + doc + } +} + +impl Document for Option { + fn doc() -> Doc { + let mut doc = I::doc(); + assert_eq!(doc.value_info.required, Some(true)); + doc.value_info.required = Some(false); + doc + } +} + +impl Document for HashMap { + fn doc() -> Doc { + let mut doc = Doc::default(); + doc.wrap_info.inner = Some(Box::new(I::doc())); + doc + } +} + +impl Document for KeyBinding { + fn doc() -> Doc { + let mut doc = Doc::default(); + doc.value_info.required = Some(true); + doc.value_info.r#type = Some("key binding".to_string()); + doc + } +} diff --git a/cove-config/src/euph.rs b/cove-config/src/euph.rs new file mode 100644 index 0000000..5ed0fb5 --- /dev/null +++ b/cove-config/src/euph.rs @@ -0,0 +1,47 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::doc::Document; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)] +#[serde(rename_all = "snake_case")] +pub enum RoomsSortOrder { + #[default] + Alphabet, + Importance, +} + +// TODO Mark favourite rooms via printable ascii characters +#[derive(Debug, Clone, Default, Deserialize, Document)] +pub struct EuphRoom { + /// Whether to automatically join this room on startup. + #[serde(default)] + pub autojoin: bool, + + /// If set, cove will set this username upon joining if there is no username + /// associated with the current session. + pub username: Option, + + /// If `euph.servers..rooms..username` is set, this will force + /// cove to set the username even if there is already a different username + /// associated with the current session. + #[serde(default)] + pub force_username: bool, + + /// If set, cove will try once to use this password to authenticate, should + /// the room be password-protected. + pub password: Option, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct EuphServer { + #[document(metavar = "room")] + pub rooms: HashMap, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Euph { + #[document(metavar = "domain")] + pub servers: HashMap, +} diff --git a/cove-config/src/keys.rs b/cove-config/src/keys.rs new file mode 100644 index 0000000..47c171c --- /dev/null +++ b/cove-config/src/keys.rs @@ -0,0 +1,427 @@ +use cove_input::{KeyBinding, KeyGroup, KeyGroupInfo}; +use serde::Deserialize; + +use crate::doc::Document; + +macro_rules! default_bindings { + ( $( + pub mod $mod:ident { $( + pub fn $name:ident => [ $($key:expr),* ]; + )* } + )*) => { + mod default { $( + pub mod $mod { $( + pub fn $name() -> ::cove_input::KeyBinding { + ::cove_input::KeyBinding::new().with_keys([ $($key),* ]).unwrap() + } + )* } + )* } + }; +} + +default_bindings! { + pub mod general { + pub fn exit => ["ctrl+c"]; + pub fn abort => ["esc"]; + pub fn confirm => ["enter"]; + pub fn focus => ["tab"]; + pub fn help => ["f1"]; + pub fn log => ["f12"]; + } + + pub mod scroll { + pub fn up_line => ["ctrl+y"]; + pub fn down_line => ["ctrl+e"]; + pub fn up_half => ["ctrl+u"]; + pub fn down_half => ["ctrl+d"]; + pub fn up_full => ["ctrl+b", "pageup"]; + pub fn down_full => ["ctrl+f", "pagedown"]; + pub fn center_cursor => ["z"]; + } + + pub mod cursor { + pub fn up => ["k", "up"]; + pub fn down => ["j", "down"]; + pub fn to_top => ["g", "home"]; + pub fn to_bottom => ["G", "end"]; + } + + pub mod editor_cursor { + pub fn left => ["ctrl+b","left"]; + pub fn right => ["ctrl+f", "right"]; + pub fn left_word => ["alt+b", "ctrl+left"]; + pub fn right_word => ["alt+f", "ctrl+right"]; + pub fn start => ["ctrl+a", "home"]; + pub fn end => ["ctrl+e", "end"]; + pub fn up => ["up"]; + pub fn down => ["down"]; + } + + pub mod editor_action { + pub fn backspace => ["ctrl+h", "backspace"]; + pub fn delete => ["ctrl+d", "delete"]; + pub fn clear => ["ctrl+l"]; + pub fn external => ["ctrl+x", "alt+e"]; + } + + pub mod rooms_action { + pub fn connect => ["c"]; + pub fn connect_all => ["C"]; + pub fn disconnect => ["d"]; + pub fn disconnect_all => ["D"]; + pub fn connect_autojoin => ["a"]; + pub fn disconnect_non_autojoin => ["A"]; + pub fn new => ["n"]; + pub fn delete => ["X"]; + pub fn change_sort_order => ["s"]; + } + + pub mod room_action { + pub fn authenticate => ["a"]; + pub fn nick => ["n"]; + pub fn more_messages => ["m"]; + pub fn account => ["A"]; + } + + pub mod tree_cursor { + pub fn to_above_sibling => ["K", "ctrl+up"]; + pub fn to_below_sibling => ["J", "ctrl+down"]; + pub fn to_parent => ["p"]; + pub fn to_root => ["P"]; + pub fn to_older_message => ["h", "left"]; + pub fn to_newer_message => ["l", "right"]; + pub fn to_older_unseen_message => ["H", "ctrl+left"]; + pub fn to_newer_unseen_message => ["L", "ctrl+right"]; + } + + pub mod tree_action { + pub fn reply => ["r"]; + pub fn reply_alternate => ["R"]; + pub fn new_thread => ["t"]; + pub fn fold_tree => [" "]; + pub fn toggle_seen => ["s"]; + pub fn mark_visible_seen => ["S"]; + pub fn mark_older_seen => ["ctrl+s"]; + pub fn info => ["i"]; + pub fn links => ["I"]; + pub fn toggle_nick_emoji => ["e"]; + pub fn increase_caesar => ["c"]; + pub fn decrease_caesar => ["C"]; + } + +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// General. +pub struct General { + /// Quit cove. + #[serde(default = "default::general::exit")] + pub exit: KeyBinding, + /// Abort/close. + #[serde(default = "default::general::abort")] + pub abort: KeyBinding, + /// Confirm. + #[serde(default = "default::general::confirm")] + pub confirm: KeyBinding, + /// Advance focus. + #[serde(default = "default::general::focus")] + pub focus: KeyBinding, + /// Show this help. + #[serde(default = "default::general::help")] + pub help: KeyBinding, + /// Show log. + #[serde(default = "default::general::log")] + pub log: KeyBinding, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Scrolling. +pub struct Scroll { + /// Scroll up one line. + #[serde(default = "default::scroll::up_line")] + pub up_line: KeyBinding, + /// Scroll down one line. + #[serde(default = "default::scroll::down_line")] + pub down_line: KeyBinding, + /// Scroll up half a screen. + #[serde(default = "default::scroll::up_half")] + pub up_half: KeyBinding, + /// Scroll down half a screen. + #[serde(default = "default::scroll::down_half")] + pub down_half: KeyBinding, + /// Scroll up a full screen. + #[serde(default = "default::scroll::up_full")] + pub up_full: KeyBinding, + /// Scroll down a full screen. + #[serde(default = "default::scroll::down_full")] + pub down_full: KeyBinding, + /// Center cursor. + #[serde(default = "default::scroll::center_cursor")] + pub center_cursor: KeyBinding, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Cursor movement. +pub struct Cursor { + /// Move up. + #[serde(default = "default::cursor::up")] + pub up: KeyBinding, + /// Move down. + #[serde(default = "default::cursor::down")] + pub down: KeyBinding, + /// Move to top. + #[serde(default = "default::cursor::to_top")] + pub to_top: KeyBinding, + /// Move to bottom. + #[serde(default = "default::cursor::to_bottom")] + pub to_bottom: KeyBinding, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Editor cursor movement. +pub struct EditorCursor { + /// Move left. + #[serde(default = "default::editor_cursor::left")] + pub left: KeyBinding, + /// Move right. + #[serde(default = "default::editor_cursor::right")] + pub right: KeyBinding, + /// Move left a word. + #[serde(default = "default::editor_cursor::left_word")] + pub left_word: KeyBinding, + /// Move right a word. + #[serde(default = "default::editor_cursor::right_word")] + pub right_word: KeyBinding, + /// Move to start of line. + #[serde(default = "default::editor_cursor::start")] + pub start: KeyBinding, + /// Move to end of line. + #[serde(default = "default::editor_cursor::end")] + pub end: KeyBinding, + /// Move up. + #[serde(default = "default::editor_cursor::up")] + pub up: KeyBinding, + /// Move down. + #[serde(default = "default::editor_cursor::down")] + pub down: KeyBinding, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Editor actions. +pub struct EditorAction { + /// Delete before cursor. + #[serde(default = "default::editor_action::backspace")] + pub backspace: KeyBinding, + /// Delete after cursor. + #[serde(default = "default::editor_action::delete")] + pub delete: KeyBinding, + /// Clear editor contents. + #[serde(default = "default::editor_action::clear")] + pub clear: KeyBinding, + /// Edit in external editor. + #[serde(default = "default::editor_action::external")] + pub external: KeyBinding, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Editor { + #[serde(default)] + #[document(no_default)] + pub cursor: EditorCursor, + + #[serde(default)] + #[document(no_default)] + pub action: EditorAction, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Room list actions. +pub struct RoomsAction { + /// Connect to selected room. + #[serde(default = "default::rooms_action::connect")] + pub connect: KeyBinding, + /// Connect to all rooms. + #[serde(default = "default::rooms_action::connect_all")] + pub connect_all: KeyBinding, + /// Disconnect from selected room. + #[serde(default = "default::rooms_action::disconnect")] + pub disconnect: KeyBinding, + /// Disconnect from all rooms. + #[serde(default = "default::rooms_action::disconnect_all")] + pub disconnect_all: KeyBinding, + /// Connect to all autojoin rooms. + #[serde(default = "default::rooms_action::connect_autojoin")] + pub connect_autojoin: KeyBinding, + /// Disconnect from all non-autojoin rooms. + #[serde(default = "default::rooms_action::disconnect_non_autojoin")] + pub disconnect_non_autojoin: KeyBinding, + /// Connect to new room. + #[serde(default = "default::rooms_action::new")] + pub new: KeyBinding, + /// Delete room. + #[serde(default = "default::rooms_action::delete")] + pub delete: KeyBinding, + /// Change sort order. + #[serde(default = "default::rooms_action::change_sort_order")] + pub change_sort_order: KeyBinding, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Rooms { + #[serde(default)] + #[document(no_default)] + pub action: RoomsAction, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Room actions. +pub struct RoomAction { + /// Authenticate. + #[serde(default = "default::room_action::authenticate")] + pub authenticate: KeyBinding, + /// Change nick. + #[serde(default = "default::room_action::nick")] + pub nick: KeyBinding, + /// Download more messages. + #[serde(default = "default::room_action::more_messages")] + pub more_messages: KeyBinding, + /// Manage account. + #[serde(default = "default::room_action::account")] + pub account: KeyBinding, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Room { + #[serde(default)] + #[document(no_default)] + pub action: RoomAction, +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Tree cursor movement. +pub struct TreeCursor { + /// Move to above sibling. + #[serde(default = "default::tree_cursor::to_above_sibling")] + pub to_above_sibling: KeyBinding, + /// Move to below sibling. + #[serde(default = "default::tree_cursor::to_below_sibling")] + pub to_below_sibling: KeyBinding, + /// Move to parent. + #[serde(default = "default::tree_cursor::to_parent")] + pub to_parent: KeyBinding, + /// Move to root. + #[serde(default = "default::tree_cursor::to_root")] + pub to_root: KeyBinding, + /// Move to older message. + #[serde(default = "default::tree_cursor::to_older_message")] + pub to_older_message: KeyBinding, + /// Move to newer message. + #[serde(default = "default::tree_cursor::to_newer_message")] + pub to_newer_message: KeyBinding, + /// Move to older unseen message. + #[serde(default = "default::tree_cursor::to_older_unseen_message")] + pub to_older_unseen_message: KeyBinding, + /// Move to newer unseen message. + #[serde(default = "default::tree_cursor::to_newer_unseen_message")] + pub to_newer_unseen_message: KeyBinding, + // TODO Bindings inspired by vim's ()/[]/{} bindings? +} + +#[derive(Debug, Deserialize, Document, KeyGroup)] +/// Tree actions. +pub struct TreeAction { + /// Reply to message, inline if possible. + #[serde(default = "default::tree_action::reply")] + pub reply: KeyBinding, + /// Reply opposite to normal reply. + #[serde(default = "default::tree_action::reply_alternate")] + pub reply_alternate: KeyBinding, + /// Start a new thread. + #[serde(default = "default::tree_action::new_thread")] + pub new_thread: KeyBinding, + /// Fold current message's subtree. + #[serde(default = "default::tree_action::fold_tree")] + pub fold_tree: KeyBinding, + /// Toggle current message's seen status. + #[serde(default = "default::tree_action::toggle_seen")] + pub toggle_seen: KeyBinding, + /// Mark all visible messages as seen. + #[serde(default = "default::tree_action::mark_visible_seen")] + pub mark_visible_seen: KeyBinding, + /// Mark all older messages as seen. + #[serde(default = "default::tree_action::mark_older_seen")] + pub mark_older_seen: KeyBinding, + /// Inspect selected element. + #[serde(default = "default::tree_action::info")] + pub inspect: KeyBinding, + /// List links found in message. + #[serde(default = "default::tree_action::links")] + pub links: KeyBinding, + /// Toggle agent id based nick emoji. + #[serde(default = "default::tree_action::toggle_nick_emoji")] + pub toggle_nick_emoji: KeyBinding, + /// Increase caesar cipher rotation. + #[serde(default = "default::tree_action::increase_caesar")] + pub increase_caesar: KeyBinding, + /// Decrease caesar cipher rotation. + #[serde(default = "default::tree_action::decrease_caesar")] + pub decrease_caesar: KeyBinding, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Tree { + #[serde(default)] + #[document(no_default)] + pub cursor: TreeCursor, + + #[serde(default)] + #[document(no_default)] + pub action: TreeAction, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Keys { + #[serde(default)] + #[document(no_default)] + pub general: General, + + #[serde(default)] + #[document(no_default)] + pub scroll: Scroll, + + #[serde(default)] + #[document(no_default)] + pub cursor: Cursor, + + #[serde(default)] + #[document(no_default)] + pub editor: Editor, + + #[serde(default)] + #[document(no_default)] + pub rooms: Rooms, + + #[serde(default)] + #[document(no_default)] + pub room: Room, + + #[serde(default)] + #[document(no_default)] + pub tree: Tree, +} + +impl Keys { + pub fn groups(&self) -> Vec> { + vec![ + KeyGroupInfo::new("general", &self.general), + KeyGroupInfo::new("scroll", &self.scroll), + KeyGroupInfo::new("cursor", &self.cursor), + KeyGroupInfo::new("editor.cursor", &self.editor.cursor), + KeyGroupInfo::new("editor.action", &self.editor.action), + KeyGroupInfo::new("rooms.action", &self.rooms.action), + KeyGroupInfo::new("room.action", &self.room.action), + KeyGroupInfo::new("tree.cursor", &self.tree.cursor), + KeyGroupInfo::new("tree.action", &self.tree.action), + ] + } +} diff --git a/cove-config/src/lib.rs b/cove-config/src/lib.rs new file mode 100644 index 0000000..0cb6cc7 --- /dev/null +++ b/cove-config/src/lib.rs @@ -0,0 +1,158 @@ +use std::{ + fs, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +use doc::Document; +use serde::{Deserialize, Serialize}; + +pub use crate::{euph::*, keys::*}; + +pub mod doc; +mod euph; +mod keys; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to read config file")] + Io(#[from] io::Error), + #[error("failed to parse config file")] + Toml(#[from] toml::de::Error), +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)] +#[serde(rename_all = "snake_case")] +pub enum WidthEstimationMethod { + #[default] + Legacy, + Unicode, +} + +#[derive(Debug, Default, Deserialize, Document)] +pub struct Config { + /// The directory that cove stores its data in when not running in ephemeral + /// mode. + /// + /// Relative paths are interpreted relative to the user's home directory. + /// + /// See also the `--data-dir` command line option. + #[document(default = "platform-dependent")] + pub data_dir: Option, + + /// Whether to start in ephemeral mode. + /// + /// In ephemeral mode, cove doesn't store any data. It completely ignores + /// any options related to the data dir. + /// + /// See also the `--ephemeral` command line option. + #[serde(default)] + pub ephemeral: bool, + + /// How to estimate the width of graphemes (i.e. characters) as displayed by + /// the terminal emulator. + /// + /// `"legacy"`: Use a legacy method that should mostly work on most terminal + /// emulators. This method will never be correct in all cases since every + /// terminal emulator handles grapheme widths slightly differently. However, + /// those cases are usually rare (unless you view a lot of emoji). + /// + /// `"unicode"`: Use the unicode standard in a best-effort manner to + /// determine grapheme widths. Some terminals (e.g. ghostty) can make use of + /// this. + /// + /// This method is used when `measure_widths` is set to `false`. + /// + /// See also the `--width-estimation-method` command line option. + #[serde(default)] + pub width_estimation_method: WidthEstimationMethod, + + /// Whether to measure the width of graphemes (i.e. characters) as displayed + /// by the terminal emulator instead of estimating the width. + /// + /// Enabling this makes rendering a bit slower but more accurate. The screen + /// might also flash when encountering new graphemes. + /// + /// See also the `--measure-widths` command line option. + #[serde(default)] + pub measure_widths: bool, + + /// Whether to start in offline mode. + /// + /// In offline mode, cove won't automatically join rooms marked via the + /// `autojoin` option on startup. You can still join those rooms manually by + /// pressing `a` in the rooms list. + /// + /// See also the `--offline` command line option. + #[serde(default)] + pub offline: bool, + + /// Initial sort order of rooms list. + /// + /// `"alphabet"` sorts rooms in alphabetic order. + /// + /// `"importance"` sorts rooms by the following criteria (in descending + /// order of priority): + /// + /// 1. connected rooms before unconnected rooms + /// 2. rooms with unread messages before rooms without + /// 3. alphabetic order + #[serde(default)] + pub rooms_sort_order: RoomsSortOrder, + + /// Ring the bell (character 0x07) when you are mentioned in a room. + #[serde(default)] + pub bell_on_mention: bool, + + /// Time zone that chat timestamps should be displayed in. + /// + /// This option can either be the string `"localtime"`, a [POSIX TZ string], + /// or a [tz identifier] from the [tz database]. + /// + /// When not set or when set to `"localtime"`, cove attempts to use your + /// system's configured time zone, falling back to UTC. + /// + /// When the string begins with a colon or doesn't match the a POSIX TZ + /// string format, it is interpreted as a tz identifier and looked up in + /// your system's tz database (or a bundled tz database on Windows). + /// + /// If the `TZ` environment variable exists, it overrides this option. + /// + /// [POSIX TZ string]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03 + /// [tz identifier]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + /// [tz database]: https://en.wikipedia.org/wiki/Tz_database + #[serde(default)] + #[document(default = "`$TZ` or local system time zone")] + pub time_zone: Option, + + #[serde(default)] + #[document(no_default)] + pub euph: Euph, + + #[serde(default)] + #[document(no_default)] + pub keys: Keys, +} + +impl Config { + pub fn load(path: &Path) -> Result { + Ok(match fs::read_to_string(path) { + Ok(content) => toml::from_str(&content)?, + Err(err) if err.kind() == ErrorKind::NotFound => Self::default(), + Err(err) => Err(err)?, + }) + } + + pub fn euph_room(&self, domain: &str, name: &str) -> EuphRoom { + if let Some(server) = self.euph.servers.get(domain) { + if let Some(room) = server.rooms.get(name) { + return room.clone(); + } + } + EuphRoom::default() + } + + pub fn time_zone_ref(&self) -> Option<&str> { + self.time_zone.as_ref().map(|s| s as &str) + } +} diff --git a/cove-input/Cargo.toml b/cove-input/Cargo.toml new file mode 100644 index 0000000..5005be2 --- /dev/null +++ b/cove-input/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cove-input" +version.workspace = true +edition.workspace = true + +[dependencies] +cove-macro = { path = "../cove-macro" } + +crossterm.workspace = true +edit.workspace = true +parking_lot.workspace = true +serde.workspace = true +serde_either.workspace = true +thiserror.workspace = true +toss.workspace = true + +[lints] +workspace = true diff --git a/cove-input/src/keys.rs b/cove-input/src/keys.rs new file mode 100644 index 0000000..8d2fdf1 --- /dev/null +++ b/cove-input/src/keys.rs @@ -0,0 +1,252 @@ +use std::{fmt, num::ParseIntError, str::FromStr}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; +use serde_either::SingleOrVec; + +#[derive(Debug, thiserror::Error)] +pub enum ParseKeysError { + #[error("no key code specified")] + NoKeyCode, + #[error("unknown key code: {0:?}")] + UnknownKeyCode(String), + #[error("invalid function key number: {0}")] + InvalidFNumber(#[from] ParseIntError), + #[error("unknown modifier: {0:?}")] + UnknownModifier(String), + #[error("modifier {0} conflicts with previous modifier")] + ConflictingModifier(String), +} + +fn conflicts_with_shift(code: KeyCode) -> bool { + match code { + KeyCode::Char(' ') => false, + KeyCode::Char(_) => true, + _ => false, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyPress { + pub code: KeyCode, + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub any: bool, +} + +impl KeyPress { + fn parse_key_code(code: &str) -> Result { + let code = match code { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "space" => KeyCode::Char(' '), + "tab" => KeyCode::Tab, + "backtab" => KeyCode::BackTab, + + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + + c if c.chars().count() == 1 => KeyCode::Char(c.chars().next().unwrap()), + c if c.starts_with('f') => KeyCode::F(c.strip_prefix('f').unwrap().parse()?), + + "" => return Err(ParseKeysError::NoKeyCode), + c => return Err(ParseKeysError::UnknownKeyCode(c.to_string())), + }; + Ok(Self { + code, + shift: false, + ctrl: false, + alt: false, + any: false, + }) + } + + fn display_key_code(code: KeyCode) -> String { + match code { + KeyCode::Esc => "esc".to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "backtab".to_string(), + + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::Insert => "insert".to_string(), + + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + + KeyCode::Char(c) => c.to_string(), + KeyCode::F(n) => format!("f{n}"), + + _ => "unknown".to_string(), + } + } + + fn parse_modifier( + &mut self, + modifier: &str, + shift_allowed: bool, + ) -> Result<(), ParseKeysError> { + match modifier { + m if self.any => return Err(ParseKeysError::ConflictingModifier(m.to_string())), + "shift" if shift_allowed && !self.shift => self.shift = true, + "ctrl" if !self.ctrl => self.ctrl = true, + "alt" if !self.alt => self.alt = true, + "any" if !self.shift && !self.ctrl && !self.alt => self.any = true, + m @ ("shift" | "ctrl" | "alt" | "any") => { + return Err(ParseKeysError::ConflictingModifier(m.to_string())); + } + m => return Err(ParseKeysError::UnknownModifier(m.to_string())), + } + Ok(()) + } + + pub fn matches(&self, event: KeyEvent) -> bool { + if event.code != self.code { + return false; + } + + if self.any && !event.modifiers.is_empty() { + return true; + } + + let ctrl = event.modifiers.contains(KeyModifiers::CONTROL) == self.ctrl; + let alt = event.modifiers.contains(KeyModifiers::ALT) == self.alt; + if conflicts_with_shift(self.code) { + ctrl && alt + } else { + let shift = event.modifiers.contains(KeyModifiers::SHIFT) == self.shift; + shift && ctrl && alt + } + } +} + +impl FromStr for KeyPress { + type Err = ParseKeysError; + + fn from_str(s: &str) -> Result { + let mut parts = s.split('+'); + let code = parts.next_back().ok_or(ParseKeysError::NoKeyCode)?; + + let mut keys = Self::parse_key_code(code)?; + let shift_allowed = !conflicts_with_shift(keys.code); + for modifier in parts { + keys.parse_modifier(modifier, shift_allowed)?; + } + + Ok(keys) + } +} + +impl fmt::Display for KeyPress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let code = Self::display_key_code(self.code); + + let mut segments = vec![]; + if self.shift { + segments.push("shift"); + } + if self.ctrl { + segments.push("ctrl"); + } + if self.alt { + segments.push("alt"); + } + if self.any { + segments.push("any"); + } + segments.push(&code); + + segments.join("+").fmt(f) + } +} + +impl Serialize for KeyPress { + fn serialize(&self, serializer: S) -> Result { + format!("{self}").serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for KeyPress { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(|e| D::Error::custom(format!("{e}"))) + } +} + +#[derive(Debug, Clone)] +pub struct KeyBinding(Vec); + +impl KeyBinding { + pub fn new() -> Self { + Self(vec![]) + } + + pub fn keys(&self) -> &[KeyPress] { + &self.0 + } + + pub fn with_key(self, key: &str) -> Result { + self.with_keys([key]) + } + + pub fn with_keys<'a, I>(mut self, keys: I) -> Result + where + I: IntoIterator, + { + for key in keys { + self.0.push(key.parse()?); + } + Ok(self) + } + + pub fn matches(&self, event: KeyEvent) -> bool { + self.0.iter().any(|kp| kp.matches(event)) + } +} + +impl Default for KeyBinding { + fn default() -> Self { + Self::new() + } +} + +impl Serialize for KeyBinding { + fn serialize(&self, serializer: S) -> Result { + if self.0.len() == 1 { + self.0[0].serialize(serializer) + } else { + self.0.serialize(serializer) + } + } +} + +impl<'de> Deserialize<'de> for KeyBinding { + fn deserialize>(deserializer: D) -> Result { + Ok(match SingleOrVec::::deserialize(deserializer)? { + SingleOrVec::Single(key) => Self(vec![key]), + SingleOrVec::Vec(keys) => Self(keys), + }) + } +} diff --git a/cove-input/src/lib.rs b/cove-input/src/lib.rs new file mode 100644 index 0000000..f6b2e92 --- /dev/null +++ b/cove-input/src/lib.rs @@ -0,0 +1,102 @@ +use std::{io, sync::Arc}; + +pub use cove_macro::KeyGroup; +use crossterm::event::{Event, KeyEvent, KeyEventKind}; +use parking_lot::FairMutex; +use toss::{Frame, Terminal, WidthDb}; + +pub use crate::keys::*; + +mod keys; + +pub struct KeyBindingInfo<'a> { + pub name: &'static str, + pub binding: &'a KeyBinding, + pub description: &'static str, +} + +/// A group of related key bindings. +pub trait KeyGroup { + const DESCRIPTION: &'static str; + + fn bindings(&self) -> Vec>; +} + +pub struct KeyGroupInfo<'a> { + pub name: &'static str, + pub description: &'static str, + pub bindings: Vec>, +} + +impl<'a> KeyGroupInfo<'a> { + pub fn new(name: &'static str, group: &'a G) -> Self { + Self { + name, + description: G::DESCRIPTION, + bindings: group.bindings(), + } + } +} + +pub struct InputEvent<'a> { + event: Event, + terminal: &'a mut Terminal, + crossterm_lock: Arc>, +} + +impl<'a> InputEvent<'a> { + pub fn new( + event: Event, + terminal: &'a mut Terminal, + crossterm_lock: Arc>, + ) -> Self { + Self { + event, + terminal, + crossterm_lock, + } + } + + /// If the current event represents a key press, returns the [`KeyEvent`] + /// associated with that key press. + pub fn key_event(&self) -> Option { + if let Event::Key(event) = &self.event { + if matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return Some(*event); + } + } + None + } + + pub fn paste_event(&self) -> Option<&str> { + match &self.event { + Event::Paste(string) => Some(string), + _ => None, + } + } + + pub fn matches(&self, binding: &KeyBinding) -> bool { + match self.key_event() { + Some(event) => binding.matches(event), + None => false, + } + } + + pub fn frame(&mut self) -> &mut Frame { + self.terminal.frame() + } + + pub fn widthdb(&mut self) -> &mut WidthDb { + self.terminal.widthdb() + } + + pub fn prompt(&mut self, initial_text: &str) -> io::Result { + let guard = self.crossterm_lock.lock(); + self.terminal.suspend().expect("failed to suspend"); + let content = edit::edit(initial_text); + self.terminal.unsuspend().expect("fauled to unsuspend"); + drop(guard); + + content + } +} diff --git a/cove-macro/Cargo.toml b/cove-macro/Cargo.toml new file mode 100644 index 0000000..6c01b7d --- /dev/null +++ b/cove-macro/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cove-macro" +version.workspace = true +edition.workspace = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[lib] +proc-macro = true + +[lints] +workspace = true diff --git a/cove-macro/src/document.rs b/cove-macro/src/document.rs new file mode 100644 index 0000000..afec84d --- /dev/null +++ b/cove-macro/src/document.rs @@ -0,0 +1,152 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr, spanned::Spanned}; + +use crate::util::{self, SerdeDefault}; + +#[derive(Default)] +struct FieldInfo { + description: Option, + metavar: Option, + default: Option, + serde_default: Option, + no_default: bool, +} + +impl FieldInfo { + fn initialize_from_field(&mut self, field: &Field) -> syn::Result<()> { + let docstring = util::docstring(&field.attrs)?; + if !docstring.is_empty() { + self.description = Some(docstring); + } + + for arg in util::attribute_arguments(&field.attrs, "document")? { + if arg.path.is_ident("metavar") { + // Parse `#[document(metavar = "bla")]` + if let Some(metavar) = arg.value.and_then(util::into_litstr) { + self.metavar = Some(metavar); + } else { + util::bail(arg.path.span(), "must be of the form `key = \"value\"`")?; + } + } else if arg.path.is_ident("default") { + // Parse `#[document(default = "bla")]` + if let Some(value) = arg.value.and_then(util::into_litstr) { + self.default = Some(value); + } else { + util::bail(arg.path.span(), "must be of the form `key = \"value\"`")?; + } + } else if arg.path.is_ident("no_default") { + // Parse #[document(no_default)] + if arg.value.is_some() { + util::bail(arg.path.span(), "must not have a value")?; + } + self.no_default = true; + } else { + util::bail(arg.path.span(), "unknown argument name")?; + } + } + + // Find `#[serde(default)]` or `#[serde(default = "bla")]`. + self.serde_default = util::serde_default(field)?; + + Ok(()) + } + + fn from_field(field: &Field) -> syn::Result { + let mut result = Self::default(); + result.initialize_from_field(field)?; + Ok(result) + } +} + +fn from_struct(ident: Ident, data: DataStruct) -> syn::Result { + let mut fields = vec![]; + for field in data.fields { + let Some(ident) = field.ident.as_ref() else { + return util::bail(field.span(), "must not be a tuple struct"); + }; + let ident = ident.to_string(); + + let info = FieldInfo::from_field(&field)?; + + let mut setters = vec![]; + if let Some(description) = info.description { + setters.push(quote! { + doc.description = Some(#description.to_string()); + }); + } + if let Some(metavar) = info.metavar { + setters.push(quote! { + doc.wrap_info.metavar = Some(#metavar.to_string()); + }); + } + if info.no_default { + } else if let Some(default) = info.default { + setters.push(quote! { + doc.value_info.default = Some(#default.to_string()); + }); + } else if let Some(serde_default) = info.serde_default { + let value = serde_default.value(); + setters.push(quote! { + doc.value_info.default = Some(crate::doc::toml_value_as_markdown(&#value)); + }); + } + + let ty = field.ty; + fields.push(quote! { + fields.insert( + #ident.to_string(), + { + let mut doc = <#ty as crate::doc::Document>::doc(); + #( #setters )* + ::std::boxed::Box::new(doc) + } + ); + }); + } + + let tokens = quote!( + impl crate::doc::Document for #ident { + fn doc() -> crate::doc::Doc { + let mut fields = ::std::collections::HashMap::new(); + #( #fields )* + + let mut doc = crate::doc::Doc::default(); + doc.struct_info.fields = fields; + doc + } + } + ); + + Ok(tokens) +} + +fn from_enum(ident: Ident, data: DataEnum) -> syn::Result { + let mut values = vec![]; + for variant in data.variants { + let ident = variant.ident; + values.push(quote! { + crate::doc::toml_value_as_markdown(&Self::#ident) + }); + } + + let tokens = quote!( + impl crate::doc::Document for #ident { + fn doc() -> crate::doc::Doc { + let mut doc = ::doc(); + doc.value_info.values = Some(vec![ #( #values ),* ]); + doc + } + } + ); + + Ok(tokens) +} + +pub fn derive_impl(input: DeriveInput) -> syn::Result { + match input.data { + Data::Struct(data) => from_struct(input.ident, data), + Data::Enum(data) => from_enum(input.ident, data), + Data::Union(_) => util::bail(input.span(), "must be an enum or a struct"), + } +} diff --git a/cove-macro/src/key_group.rs b/cove-macro/src/key_group.rs new file mode 100644 index 0000000..832bfd3 --- /dev/null +++ b/cove-macro/src/key_group.rs @@ -0,0 +1,74 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, spanned::Spanned}; + +use crate::util; + +fn decapitalize(s: &str) -> String { + let mut chars = s.chars(); + if let Some(char) = chars.next() { + char.to_lowercase().chain(chars).collect() + } else { + String::new() + } +} + +pub fn derive_impl(input: DeriveInput) -> syn::Result { + let Data::Struct(data) = input.data else { + return util::bail(input.span(), "must be a struct"); + }; + + let docstring = util::docstring(&input.attrs)?; + let description = docstring.strip_suffix('.').unwrap_or(&docstring); + + let mut bindings = vec![]; + let mut defaults = vec![]; + for field in &data.fields { + if let Some(field_ident) = &field.ident { + let field_name = field_ident.to_string(); + + let docstring = util::docstring(&field.attrs)?; + let description = decapitalize(&docstring); + let description = description.strip_suffix('.').unwrap_or(&description); + + let default = util::serde_default(field)?; + let Some(default) = default else { + return util::bail(field_ident.span(), "must have serde default"); + }; + let default_value = default.value(); + + bindings.push(quote! { + ::cove_input::KeyBindingInfo { + name: #field_name, + binding: &self.#field_ident, + description: #description + } + }); + + defaults.push(quote! { + #field_ident: #default_value, + }); + } + } + + let ident = input.ident; + Ok(quote! { + impl ::cove_input::KeyGroup for #ident { + const DESCRIPTION: &'static str = #description; + + fn bindings(&self) -> Vec<::cove_input::KeyBindingInfo<'_>> { + vec![ + #( #bindings, )* + ] + } + } + + impl Default for #ident { + fn default() -> Self { + Self { + #( #defaults )* + } + } + } + }) +} diff --git a/cove-macro/src/lib.rs b/cove-macro/src/lib.rs new file mode 100644 index 0000000..c655f2a --- /dev/null +++ b/cove-macro/src/lib.rs @@ -0,0 +1,23 @@ +use syn::{DeriveInput, parse_macro_input}; + +mod document; +mod key_group; +mod util; + +#[proc_macro_derive(Document, attributes(document))] +pub fn derive_document(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match document::derive_impl(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.into_compile_error().into(), + } +} + +#[proc_macro_derive(KeyGroup)] +pub fn derive_group(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match key_group::derive_impl(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.into_compile_error().into(), + } +} diff --git a/cove-macro/src/util.rs b/cove-macro/src/util.rs new file mode 100644 index 0000000..d73b7ca --- /dev/null +++ b/cove-macro/src/util.rs @@ -0,0 +1,117 @@ +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{ + Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type, parse::Parse, + punctuated::Punctuated, +}; + +pub fn bail(span: Span, message: &str) -> syn::Result { + Err(syn::Error::new(span, message)) +} + +pub fn litstr(expr: &Expr) -> Option<&LitStr> { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(lit), .. + }) => Some(lit), + _ => None, + } +} + +pub fn into_litstr(expr: Expr) -> Option { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(lit), .. + }) => Some(lit), + _ => None, + } +} + +/// Given a struct field, this finds all attributes like `#[doc = "bla"]`, +/// unindents, concatenates and returns them. +pub fn docstring(attributes: &[Attribute]) -> syn::Result { + let mut lines = vec![]; + + for attr in attributes.iter().filter(|attr| attr.path().is_ident("doc")) { + if let Some(lit) = litstr(&attr.meta.require_name_value()?.value) { + let value = lit.value(); + let value = value + .strip_prefix(' ') + .map(|value| value.to_string()) + .unwrap_or(value); + lines.push(value); + } + } + + Ok(lines.join("\n")) +} + +pub struct AttributeArgument { + pub path: Path, + pub value: Option, +} + +impl Parse for AttributeArgument { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let path = Path::parse(input)?; + let value = if input.peek(Token![=]) { + input.parse::()?; + Some(Expr::parse(input)?) + } else { + None + }; + Ok(Self { path, value }) + } +} + +/// Given a struct field, this finds all arguments of the form `#[path(key)]` +/// and `#[path(key = value)]`. Multiple arguments may be specified in a single +/// annotation, e.g. `#[foo(bar, baz = true)]`. +pub fn attribute_arguments( + attributes: &[Attribute], + path: &str, +) -> syn::Result> { + let mut attr_args = vec![]; + + for attr in attributes.iter().filter(|attr| attr.path().is_ident(path)) { + let args = + attr.parse_args_with(Punctuated::::parse_terminated)?; + attr_args.extend(args); + } + + Ok(attr_args) +} + +pub enum SerdeDefault { + Default(Type), + Path(ExprPath), +} + +impl SerdeDefault { + pub fn value(&self) -> TokenStream { + match self { + Self::Default(ty) => quote! { + <#ty as Default>::default() + }, + Self::Path(path) => quote! { + #path() + }, + } + } +} + +/// Find `#[serde(default)]` or `#[serde(default = "bla")]`. +pub fn serde_default(field: &Field) -> syn::Result> { + for arg in attribute_arguments(&field.attrs, "serde")? { + if arg.path.is_ident("default") { + if let Some(value) = arg.value { + if let Some(path) = into_litstr(value) { + return Ok(Some(SerdeDefault::Path(path.parse()?))); + } + } else { + return Ok(Some(SerdeDefault::Default(field.ty.clone()))); + } + } + } + Ok(None) +} diff --git a/cove/Cargo.toml b/cove/Cargo.toml new file mode 100644 index 0000000..3a60a5d --- /dev/null +++ b/cove/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cove" +version.workspace = true +edition.workspace = true + +[dependencies] +cove-config = { path = "../cove-config" } +cove-input = { path = "../cove-input" } + +anyhow.workspace = true +async-trait.workspace = true +clap.workspace = true +cookie.workspace = true +crossterm.workspace = true +directories.workspace = true +euphoxide.workspace = true +jiff.workspace = true +linkify.workspace = true +log.workspace = true +open.workspace = true +parking_lot.workspace = true +rusqlite.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +toss.workspace = true +unicode-width.workspace = true +vault.workspace = true +rustls.workspace = true + +[lints] +workspace = true diff --git a/src/euph.rs b/cove/src/euph.rs similarity index 72% rename from src/euph.rs rename to cove/src/euph.rs index ab93753..77bf1db 100644 --- a/src/euph.rs +++ b/cove/src/euph.rs @@ -1,7 +1,9 @@ -mod room; -mod small_message; -mod util; - +pub use highlight::*; pub use room::*; pub use small_message::*; pub use util::*; + +mod highlight; +mod room; +mod small_message; +mod util; diff --git a/cove/src/euph/highlight.rs b/cove/src/euph/highlight.rs new file mode 100644 index 0000000..1c9abd0 --- /dev/null +++ b/cove/src/euph/highlight.rs @@ -0,0 +1,211 @@ +use std::ops::Range; + +use crossterm::style::Stylize; +use toss::{Style, Styled}; + +use crate::euph::util; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpanType { + Mention, + Room, + Emoji, +} + +fn nick_char(ch: char) -> bool { + // Closely following the heim mention regex: + // https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15 + // `>` has been experimentally confirmed to delimit mentions as well. + match ch { + ',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false, + _ => !ch.is_whitespace(), + } +} + +fn room_char(ch: char) -> bool { + // Basically just \w, see also + // https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66 + ch.is_ascii_alphanumeric() || ch == '_' +} + +struct SpanFinder<'a> { + content: &'a str, + + span: Option<(SpanType, usize)>, + room_or_mention_possible: bool, + + result: Vec<(SpanType, Range)>, +} + +impl<'a> SpanFinder<'a> { + fn is_valid_span(&self, span: SpanType, range: Range) -> bool { + let text = &self.content[range.start..range.end]; + match span { + SpanType::Mention => range.len() > 1 && text.starts_with('@'), + SpanType::Room => range.len() > 1 && text.starts_with('&'), + SpanType::Emoji => { + if range.len() <= 2 { + return false; + } + + let Some(name) = Some(text) + .and_then(|it| it.strip_prefix(':')) + .and_then(|it| it.strip_suffix(':')) + else { + return false; + }; + + util::EMOJI.get(name).is_some() + } + } + } + + fn close_span(&mut self, end: usize) { + let Some((span, start)) = self.span else { + return; + }; + if self.is_valid_span(span, start..end) { + self.result.push((span, start..end)); + } + self.span = None; + } + + fn open_span(&mut self, span: SpanType, start: usize) { + self.close_span(start); + self.span = Some((span, start)) + } + + fn step(&mut self, idx: usize, char: char) { + match (char, self.span) { + ('@', Some((SpanType::Mention, _))) => {} // Continue the mention + ('@', _) if self.room_or_mention_possible => self.open_span(SpanType::Mention, idx), + ('&', _) if self.room_or_mention_possible => self.open_span(SpanType::Room, idx), + (':', None) => self.open_span(SpanType::Emoji, idx), + (':', Some((SpanType::Emoji, _))) => self.close_span(idx + 1), + (c, Some((SpanType::Mention, _))) if !nick_char(c) => self.close_span(idx), + (c, Some((SpanType::Room, _))) if !room_char(c) => self.close_span(idx), + _ => {} + } + + // More permissive than the heim web client + self.room_or_mention_possible = !char.is_alphanumeric(); + } + + fn find(content: &'a str) -> Vec<(SpanType, Range)> { + let mut this = Self { + content, + span: None, + room_or_mention_possible: true, + result: vec![], + }; + + for (idx, char) in content.char_indices() { + this.step(idx, char); + } + + this.close_span(content.len()); + + this.result + } +} + +pub fn find_spans(content: &str) -> Vec<(SpanType, Range)> { + SpanFinder::find(content) +} + +/// Highlight spans in a string. +/// +/// The list of spans must be non-overlapping and in ascending order. +/// +/// If `exact` is specified, colon-delimited emoji are not replaced with their +/// unicode counterparts. +pub fn apply_spans( + content: &str, + spans: &[(SpanType, Range)], + base: Style, + exact: bool, +) -> Styled { + let mut result = Styled::default(); + let mut i = 0; + + for (span, range) in spans { + assert!(i <= range.start); + assert!(range.end <= content.len()); + + if i < range.start { + result = result.then(&content[i..range.start], base); + } + + let text = &content[range.start..range.end]; + result = match span { + SpanType::Mention if exact => result.and_then(util::style_mention_exact(text, base)), + SpanType::Mention => result.and_then(util::style_mention(text, base)), + SpanType::Room => result.then(text, base.blue().bold()), + SpanType::Emoji if exact => result.then(text, base.magenta()), + SpanType::Emoji => { + let name = text.strip_prefix(':').unwrap_or(text); + let name = name.strip_suffix(':').unwrap_or(name); + if let Some(Some(replacement)) = util::EMOJI.get(name) { + result.then(replacement, base) + } else { + result.then(text, base.magenta()) + } + } + }; + + i = range.end; + } + + if i < content.len() { + result = result.then(&content[i..], base); + } + + result +} + +/// Highlight an euphoria message's content. +/// +/// If `exact` is specified, colon-delimited emoji are not replaced with their +/// unicode counterparts. +pub fn highlight(content: &str, base: Style, exact: bool) -> Styled { + apply_spans(content, &find_spans(content), base, exact) +} + +#[cfg(test)] +mod tests { + + use crate::euph::SpanType; + + use super::find_spans; + + #[test] + fn mentions() { + assert_eq!(find_spans("@foo"), vec![(SpanType::Mention, 0..4)]); + assert_eq!(find_spans("&@foo"), vec![(SpanType::Mention, 1..5)]); + assert_eq!(find_spans("a @foo b"), vec![(SpanType::Mention, 2..6)]); + assert_eq!(find_spans("@@foo@@"), vec![(SpanType::Mention, 0..7)]); + assert_eq!(find_spans("a @b@c d"), vec![(SpanType::Mention, 2..6)]); + assert_eq!( + find_spans("a @b @c d"), + vec![(SpanType::Mention, 2..4), (SpanType::Mention, 5..7)] + ); + } + + #[test] + fn rooms() { + assert_eq!(find_spans("&foo"), vec![(SpanType::Room, 0..4)]); + assert_eq!(find_spans("@&foo"), vec![(SpanType::Room, 1..5)]); + assert_eq!(find_spans("a &foo b"), vec![(SpanType::Room, 2..6)]); + assert_eq!(find_spans("&&foo&&"), vec![(SpanType::Room, 1..5)]); + assert_eq!(find_spans("a &b&c d"), vec![(SpanType::Room, 2..4)]); + assert_eq!( + find_spans("a &b &c d"), + vec![(SpanType::Room, 2..4), (SpanType::Room, 5..7)] + ); + } + + #[test] + fn emoji_in_mentions() { + assert_eq!(find_spans(" @a:b:c "), vec![(SpanType::Mention, 1..7)]); + } +} diff --git a/src/euph/room.rs b/cove/src/euph/room.rs similarity index 83% rename from src/euph/room.rs rename to cove/src/euph/room.rs index e38c2ed..a4e29cf 100644 --- a/src/euph/room.rs +++ b/cove/src/euph/room.rs @@ -1,24 +1,21 @@ -// TODO Stop if room does not exist (e. g. 404) +use std::{convert::Infallible, time::Duration}; -use std::convert::Infallible; -use std::time::Duration; - -use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{ - Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time, - UserId, +use euphoxide::{ + api::{ + Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, + Time, UserId, packet::ParsedPacket, + }, + bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig}, + conn::{self, ConnTx, Joined}, }; -use euphoxide::bot::instance::{Event, Instance, InstanceConfig, Snapshot}; -use euphoxide::conn::{self, ConnTx}; -use log::{debug, error, info, warn}; -use tokio::select; -use tokio::sync::oneshot; +use log::{debug, info, warn}; +use tokio::{select, sync::oneshot}; -use crate::macros::{logging_unwrap, ok_or_return}; -use crate::vault::EuphRoomVault; +use crate::{macros::logging_unwrap, vault::EuphRoomVault}; const LOG_INTERVAL: Duration = Duration::from_secs(10); +#[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum State { Disconnected, @@ -35,6 +32,13 @@ impl State { None } } + + pub fn joined(&self) -> Option<&Joined> { + match self { + Self::Connected(_, conn::State::Joined(joined)) => Some(joined), + _ => None, + } + } } #[derive(Debug, thiserror::Error)] @@ -65,19 +69,13 @@ impl Room { where F: Fn(Event) + std::marker::Send + Sync + 'static, { - // &rl2dev's message history is broken and requesting old messages past - // a certain point results in errors. Cove should not keep retrying log - // requests when hitting that limit, so &rl2dev is always opened in - // ephemeral mode. - let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev"; - Self { - vault, - ephemeral, + ephemeral: vault.vault().vault().ephemeral(), instance: instance_config.build(on_event), state: State::Disconnected, last_msg_id: None, log_request_canary: None, + vault, } } @@ -106,7 +104,7 @@ impl Room { self.last_msg_id = None; self.log_request_canary = None; } - Event::Connected(_, Snapshot { conn_tx, state }) => { + Event::Connected(_, ConnSnapshot { conn_tx, state }) => { if !self.ephemeral { let (tx, rx) = oneshot::channel(); self.log_request_canary = Some(tx); @@ -125,9 +123,10 @@ impl Room { let cookies = &*self.instance.config().server.cookies; let cookies = cookies.lock().unwrap().clone(); - logging_unwrap!(self.vault.vault().set_cookies(cookies).await); + let domain = self.vault.room().domain.clone(); + logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await); } - Event::Packet(_, packet, Snapshot { conn_tx, state }) => { + Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => { self.state = State::Connected(conn_tx, state); self.on_packet(packet).await; } @@ -137,7 +136,6 @@ impl Room { self.log_request_canary = None; } Event::Stopped(_) => { - // TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization self.state = State::Stopped; } } @@ -183,15 +181,9 @@ impl Room { None => None, }; - debug!("{}: requesting logs", vault.room()); + debug!("{:?}: requesting logs", vault.room()); - // &rl2dev's message history is broken and requesting old messages past - // a certain point results in errors. By reducing the amount of messages - // in each log request, we can get closer to this point. Since &rl2dev - // is fairly low in activity, this should be fine. - let n = if vault.room() == "rl2dev" { 50 } else { 1000 }; - - let _ = conn_tx.send(Log { n, before }).await; + let _ = conn_tx.send(Log { n: 1000, before }).await; // The code handling incoming events and replies also handles // `LogReply`s, so we don't need to do anything special here. } @@ -209,7 +201,9 @@ impl Room { async fn on_packet(&mut self, packet: ParsedPacket) { let room_name = &self.instance.config().room; - let data = ok_or_return!(&packet.content); + let Ok(data) = &packet.content else { + return; + }; match data { Data::BounceEvent(_) => {} Data::DisconnectEvent(_) => {} @@ -316,7 +310,7 @@ impl Room { } pub fn logout(&self) -> Result<(), Error> { - self.conn_tx()?.send_only(Logout); + self.conn_tx()?.send_only(Logout {}); Ok(()) } } diff --git a/cove/src/euph/small_message.rs b/cove/src/euph/small_message.rs new file mode 100644 index 0000000..5db1790 --- /dev/null +++ b/cove/src/euph/small_message.rs @@ -0,0 +1,102 @@ +use crossterm::style::Stylize; +use euphoxide::api::{MessageId, Snowflake, Time, UserId}; +use jiff::Timestamp; +use toss::{Style, Styled}; + +use crate::{store::Msg, ui::ChatMsg}; + +use super::util; + +#[derive(Debug, Clone)] +pub struct SmallMessage { + pub id: MessageId, + pub parent: Option, + pub time: Time, + pub user_id: UserId, + pub nick: String, + pub content: String, + pub seen: bool, +} + +fn as_me(content: &str) -> Option<&str> { + content.strip_prefix("/me") +} + +fn style_me() -> Style { + Style::new().grey().italic() +} + +fn styled_nick(nick: &str) -> Styled { + Styled::new_plain("[") + .and_then(super::style_nick(nick, Style::new())) + .then_plain("]") +} + +fn styled_nick_me(nick: &str) -> Styled { + let style = style_me(); + Styled::new("*", style).and_then(super::style_nick(nick, style)) +} + +fn styled_content(content: &str) -> Styled { + super::highlight(content.trim(), Style::new(), false) +} + +fn styled_content_me(content: &str) -> Styled { + let style = style_me(); + super::highlight(content.trim(), style, false).then("*", style) +} + +fn styled_editor_content(content: &str) -> Styled { + let style = if as_me(content).is_some() { + style_me() + } else { + Style::new() + }; + super::highlight(content, style, true) +} + +impl Msg for SmallMessage { + type Id = MessageId; + + fn id(&self) -> Self::Id { + self.id + } + + fn parent(&self) -> Option { + self.parent + } + + fn seen(&self) -> bool { + self.seen + } + + fn last_possible_id() -> Self::Id { + MessageId(Snowflake::MAX) + } + + fn nick_emoji(&self) -> Option { + Some(util::user_id_emoji(&self.user_id)) + } +} + +impl ChatMsg for SmallMessage { + fn time(&self) -> Option { + Some(self.time.as_timestamp()) + } + + fn styled(&self) -> (Styled, Styled) { + Self::pseudo(&self.nick, &self.content) + } + + fn edit(nick: &str, content: &str) -> (Styled, Styled) { + (styled_nick(nick), styled_editor_content(content)) + } + + fn pseudo(nick: &str, content: &str) -> (Styled, Styled) { + if let Some(content) = as_me(content) { + (styled_nick_me(nick), styled_content_me(content)) + } else { + (styled_nick(nick), styled_content(content)) + } + } +} diff --git a/src/euph/util.rs b/cove/src/euph/util.rs similarity index 52% rename from src/euph/util.rs rename to cove/src/euph/util.rs index 4769c7b..ea1782a 100644 --- a/src/euph/util.rs +++ b/cove/src/euph/util.rs @@ -1,9 +1,27 @@ -use crossterm::style::{Color, ContentStyle, Stylize}; -use euphoxide::Emoji; -use once_cell::sync::Lazy; -use toss::styled::Styled; +use std::{ + collections::HashSet, + hash::{DefaultHasher, Hash, Hasher}, + sync::LazyLock, +}; -pub static EMOJI: Lazy = Lazy::new(Emoji::load); +use crossterm::style::{Color, Stylize}; +use euphoxide::{Emoji, api::UserId}; +use toss::{Style, Styled}; + +pub static EMOJI: LazyLock = LazyLock::new(Emoji::load); + +pub static EMOJI_LIST: LazyLock> = LazyLock::new(|| { + let mut list = EMOJI + .0 + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + list.sort_unstable(); + list +}); /// Convert HSL to RGB following [this approach from wikipedia][1]. /// @@ -42,15 +60,37 @@ pub fn nick_color(nick: &str) -> (u8, u8, u8) { hsl_to_rgb(hue, 1.0, 0.72) } -pub fn nick_style(nick: &str, base: ContentStyle) -> ContentStyle { +pub fn nick_style(nick: &str, base: Style) -> Style { let (r, g, b) = nick_color(nick); base.bold().with(Color::Rgb { r, g, b }) } -pub fn style_nick(nick: &str, base: ContentStyle) -> Styled { +pub fn style_nick(nick: &str, base: Style) -> Styled { Styled::new(EMOJI.replace(nick), nick_style(nick, base)) } -pub fn style_nick_exact(nick: &str, base: ContentStyle) -> Styled { +pub fn style_nick_exact(nick: &str, base: Style) -> Styled { Styled::new(nick, nick_style(nick, base)) } + +pub fn style_mention(mention: &str, base: Style) -> Styled { + let nick = mention + .strip_prefix('@') + .expect("mention must start with @"); + Styled::new(EMOJI.replace(mention), nick_style(nick, base)) +} + +pub fn style_mention_exact(mention: &str, base: Style) -> Styled { + let nick = mention + .strip_prefix('@') + .expect("mention must start with @"); + Styled::new(mention, nick_style(nick, base)) +} + +pub fn user_id_emoji(user_id: &UserId) -> String { + let mut hasher = DefaultHasher::new(); + user_id.0.hash(&mut hasher); + let hash = hasher.finish(); + let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()]; + emoji.clone() +} diff --git a/src/export.rs b/cove/src/export.rs similarity index 80% rename from src/export.rs rename to cove/src/export.rs index 545f48b..80db7b6 100644 --- a/src/export.rs +++ b/cove/src/export.rs @@ -1,21 +1,24 @@ //! Export logs from the vault to plain text files. +use std::{ + fs::File, + io::{self, BufWriter, Write}, +}; + +use crate::vault::{EuphRoomVault, EuphVault, RoomIdentifier}; + mod json; mod text; -use std::fs::File; -use std::io::{self, BufWriter, Write}; - -use crate::vault::{EuphRoomVault, EuphVault}; - #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum Format { /// Human-readable tree-structured messages. Text, /// Array of message objects in the same format as the euphoria API uses. Json, - /// Message objects in the same format as the euphoria API uses, one per line. - JsonStream, + /// Message objects in the same format as the euphoria API uses, one per + /// line (https://jsonlines.org/). + JsonLines, } impl Format { @@ -23,14 +26,15 @@ impl Format { match self { Self::Text => "text", Self::Json => "json", - Self::JsonStream => "json stream", + Self::JsonLines => "json lines", } } fn extension(&self) -> &'static str { match self { Self::Text => "txt", - Self::Json | Self::JsonStream => "json", + Self::Json => "json", + Self::JsonLines => "jsonl", } } } @@ -43,6 +47,10 @@ pub struct Args { #[arg(long, short)] all: bool, + /// Domain to resolve the room names with. + #[arg(long, short, default_value = "euphoria.leet.nu")] + domain: String, + /// Format of the output file. #[arg(long, short, value_enum, default_value_t = Format::Text)] format: Format, @@ -74,7 +82,7 @@ async fn export_room( match format { Format::Text => text::export(vault, out).await?, Format::Json => json::export(vault, out).await?, - Format::JsonStream => json::export_stream(vault, out).await?, + Format::JsonLines => json::export_lines(vault, out).await?, } Ok(()) } @@ -85,7 +93,12 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> { } let rooms = if args.all { - let mut rooms = vault.rooms().await?; + let mut rooms = vault + .rooms() + .await? + .into_iter() + .map(|id| id.name) + .collect::>(); rooms.sort_unstable(); rooms } else { @@ -101,14 +114,14 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> { for room in rooms { if args.out == "-" { eprintln!("Exporting &{room} as {} to stdout", args.format.name()); - let vault = vault.room(room); + let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room)); let mut stdout = BufWriter::new(io::stdout()); export_room(&vault, &mut stdout, args.format).await?; stdout.flush()?; } else { let out = format_out(&args.out, &room, args.format); eprintln!("Exporting &{room} as {} to {out}", args.format.name()); - let vault = vault.room(room); + let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room)); let mut file = BufWriter::new(File::create(out)?); export_room(&vault, &mut file, args.format).await?; file.flush()?; diff --git a/src/export/json.rs b/cove/src/export/json.rs similarity index 62% rename from src/export/json.rs rename to cove/src/export/json.rs index 258b7bd..9c16e46 100644 --- a/src/export/json.rs +++ b/cove/src/export/json.rs @@ -8,14 +8,13 @@ pub async fn export(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re write!(file, "[")?; let mut total = 0; - let mut offset = 0; + let mut last_msg_id = None; loop { - let messages = vault.chunk_at_offset(CHUNK_SIZE, offset).await?; - offset += messages.len(); - - if messages.is_empty() { - break; - } + let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?; + last_msg_id = Some(match messages.last() { + Some(last_msg) => last_msg.id, + None => break, // No more messages, export finished + }); for message in messages { if total == 0 { @@ -38,16 +37,15 @@ pub async fn export(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re Ok(()) } -pub async fn export_stream(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> { +pub async fn export_lines(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> { let mut total = 0; - let mut offset = 0; + let mut last_msg_id = None; loop { - let messages = vault.chunk_at_offset(CHUNK_SIZE, offset).await?; - offset += messages.len(); - - if messages.is_empty() { - break; - } + let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?; + last_msg_id = Some(match messages.last() { + Some(last_msg) => last_msg.id, + None => break, // No more messages, export finished + }); for message in messages { serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D diff --git a/src/export/text.rs b/cove/src/export/text.rs similarity index 81% rename from src/export/text.rs rename to cove/src/export/text.rs index bb3cfa1..2ca6687 100644 --- a/src/export/text.rs +++ b/cove/src/export/text.rs @@ -1,16 +1,11 @@ use std::io::Write; use euphoxide::api::MessageId; -use time::format_description::FormatItem; -use time::macros::format_description; use unicode_width::UnicodeWidthStr; -use crate::euph::SmallMessage; -use crate::store::Tree; -use crate::vault::EuphRoomVault; +use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault}; -const TIME_FORMAT: &[FormatItem<'_>] = - format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); +const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; const TIME_EMPTY: &str = " "; pub async fn export(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> { @@ -67,11 +62,7 @@ fn write_msg( for (i, line) in msg.content.lines().enumerate() { if i == 0 { - let time = msg - .time - .0 - .format(TIME_FORMAT) - .expect("time can be formatted"); + let time = msg.time.as_timestamp().strftime(TIME_FORMAT); writeln!(file, "{time} {indent_string}[{nick}] {line}")?; } else { writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?; diff --git a/src/logger.rs b/cove/src/logger.rs similarity index 89% rename from src/logger.rs rename to cove/src/logger.rs index f1d3cc0..940e1a9 100644 --- a/src/logger.rs +++ b/cove/src/logger.rs @@ -1,22 +1,22 @@ -use std::convert::Infallible; -use std::sync::Arc; -use std::vec; +use std::{convert::Infallible, sync::Arc, vec}; use async_trait::async_trait; -use crossterm::style::{ContentStyle, Stylize}; +use crossterm::style::Stylize; +use jiff::Timestamp; use log::{Level, LevelFilter, Log}; use parking_lot::Mutex; -use time::OffsetDateTime; use tokio::sync::mpsc; -use toss::styled::Styled; +use toss::{Style, Styled}; -use crate::store::{Msg, MsgStore, Path, Tree}; -use crate::ui::ChatMsg; +use crate::{ + store::{Msg, MsgStore, Path, Tree}, + ui::ChatMsg, +}; #[derive(Debug, Clone)] pub struct LogMsg { id: usize, - time: OffsetDateTime, + time: Timestamp, level: Level, content: String, } @@ -42,17 +42,17 @@ impl Msg for LogMsg { } impl ChatMsg for LogMsg { - fn time(&self) -> OffsetDateTime { - self.time + fn time(&self) -> Option { + Some(self.time) } fn styled(&self) -> (Styled, Styled) { let nick_style = match self.level { - Level::Error => ContentStyle::default().bold().red(), - Level::Warn => ContentStyle::default().bold().yellow(), - Level::Info => ContentStyle::default().bold().green(), - Level::Debug => ContentStyle::default().bold().blue(), - Level::Trace => ContentStyle::default().bold().magenta(), + Level::Error => Style::new().bold().red(), + Level::Warn => Style::new().bold().yellow(), + Level::Info => Style::new().bold().green(), + Level::Debug => Style::new().bold().blue(), + Level::Trace => Style::new().bold().magenta(), }; let nick = Styled::new(format!("{}", self.level), nick_style); let content = Styled::new_plain(&self.content); @@ -209,7 +209,7 @@ impl Log for Logger { let mut guard = self.messages.lock(); let msg = LogMsg { id: guard.len(), - time: OffsetDateTime::now_utc(), + time: Timestamp::now(), level: record.level(), content: format!("<{}> {}", record.target(), record.args()), }; diff --git a/cove/src/macros.rs b/cove/src/macros.rs new file mode 100644 index 0000000..bb5834c --- /dev/null +++ b/cove/src/macros.rs @@ -0,0 +1,12 @@ +macro_rules! logging_unwrap { + ($e:expr) => { + match $e { + Ok(value) => value, + Err(err) => { + log::error!("{err}"); + panic!("{err}"); + } + } + }; +} +pub(crate) use logging_unwrap; diff --git a/cove/src/main.rs b/cove/src/main.rs new file mode 100644 index 0000000..51bc502 --- /dev/null +++ b/cove/src/main.rs @@ -0,0 +1,253 @@ +// TODO Remove unnecessary Debug impls and compare compile times +// TODO Invoke external notification command? + +use std::path::PathBuf; + +use anyhow::Context; +use clap::Parser; +use cove_config::{Config, doc::Document}; +use directories::{BaseDirs, ProjectDirs}; +use log::info; +use tokio::sync::mpsc; +use toss::Terminal; + +use crate::{ + logger::Logger, + ui::Ui, + vault::Vault, + version::{NAME, VERSION}, +}; + +mod euph; +mod export; +mod logger; +mod macros; +mod store; +mod ui; +mod util; +mod vault; +mod version; + +#[derive(Debug, clap::Parser)] +enum Command { + /// Run the client interactively (default). + Run, + /// Export room logs as plain text files. + Export(export::Args), + /// Compact and clean up vault. + Gc, + /// Clear euphoria session cookies. + ClearCookies { + /// Clear cookies for a specific domain only. + #[arg(long, short)] + domain: Option, + }, + /// Print config documentation as markdown. + HelpConfig, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum WidthEstimationMethod { + Legacy, + Unicode, +} + +impl Default for Command { + fn default() -> Self { + Self::Run + } +} + +#[derive(Debug, clap::Parser)] +#[command(version)] +struct Args { + /// Show more detailed log messages. + #[arg(long, short)] + verbose: bool, + + /// Path to the config file. + /// + /// Relative paths are interpreted relative to the current directory. + #[arg(long, short)] + config: Option, + + /// Path to a directory for cove to store its data in. + /// + /// Relative paths are interpreted relative to the current directory. + #[arg(long, short)] + data_dir: Option, + + /// If set, cove won't store data permanently. + #[arg(long, short)] + ephemeral: bool, + + /// If set, cove will ignore the autojoin config option. + #[arg(long, short)] + offline: bool, + + /// Method for estimating the width of characters as displayed by the + /// terminal emulator. + #[arg(long, short)] + width_estimation_method: Option, + + /// Measure the width of characters as displayed by the terminal emulator + /// instead of guessing the width. + #[arg(long, short)] + measure_widths: bool, + + #[command(subcommand)] + command: Option, +} + +fn config_path(args: &Args, dirs: &ProjectDirs) -> PathBuf { + args.config + .clone() + .unwrap_or_else(|| dirs.config_dir().join("config.toml")) +} + +fn data_dir(config: &Config, dirs: &ProjectDirs) -> PathBuf { + config + .data_dir + .clone() + .unwrap_or_else(|| dirs.data_dir().to_path_buf()) +} + +fn update_config_with_args(config: &mut Config, args: &Args) { + if let Some(data_dir) = args.data_dir.clone() { + // The data dir specified via args_data_dir is relative to the current + // directory and needs no resolving. + config.data_dir = Some(data_dir); + } else if let Some(data_dir) = &config.data_dir { + // Resolve the data dir specified in the config file relative to the + // user's home directory, if possible. + let base_dirs = BaseDirs::new().expect("failed to find home directory"); + config.data_dir = Some(base_dirs.home_dir().join(data_dir)); + } + + config.ephemeral |= args.ephemeral; + if let Some(method) = args.width_estimation_method { + config.width_estimation_method = match method { + WidthEstimationMethod::Legacy => cove_config::WidthEstimationMethod::Legacy, + WidthEstimationMethod::Unicode => cove_config::WidthEstimationMethod::Unicode, + } + } + config.measure_widths |= args.measure_widths; + config.offline |= args.offline; +} + +fn open_vault(config: &Config, dirs: &ProjectDirs) -> anyhow::Result { + let vault = if config.ephemeral { + vault::launch_in_memory()? + } else { + let data_dir = data_dir(config, dirs); + eprintln!("Data dir: {}", data_dir.to_string_lossy()); + vault::launch(&data_dir.join("vault.db"))? + }; + + Ok(vault) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + let (logger, logger_guard, logger_rx) = Logger::init(args.verbose); + let dirs = ProjectDirs::from("de", "plugh", "cove").expect("failed to find config directory"); + + // https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837 + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + // Locate config + let config_path = config_path(&args, &dirs); + eprintln!("Config file: {}", config_path.to_string_lossy()); + + // Load config + let mut config = Config::load(&config_path)?; + update_config_with_args(&mut config, &args); + let config = Box::leak(Box::new(config)); + + match args.command.unwrap_or_default() { + Command::Run => run(logger, logger_rx, config, &dirs).await?, + Command::Export(args) => export(config, &dirs, args).await?, + Command::Gc => gc(config, &dirs).await?, + Command::ClearCookies { domain } => clear_cookies(config, &dirs, domain).await?, + Command::HelpConfig => help_config(), + } + + // Print all logged errors. This should always happen, even if cove panics, + // because the errors may be key in diagnosing what happened. Because of + // this, it is not implemented via a normal function call. + drop(logger_guard); + + eprintln!("Goodbye!"); + Ok(()) +} + +async fn run( + logger: Logger, + logger_rx: mpsc::UnboundedReceiver<()>, + config: &'static Config, + dirs: &ProjectDirs, +) -> anyhow::Result<()> { + info!("Welcome to {NAME} {VERSION}",); + + let tz = util::load_time_zone(config.time_zone_ref()).context("failed to load time zone")?; + + let vault = open_vault(config, dirs)?; + + let mut terminal = Terminal::new()?; + terminal.set_measuring(config.measure_widths); + terminal.set_width_estimation_method(match config.width_estimation_method { + cove_config::WidthEstimationMethod::Legacy => toss::WidthEstimationMethod::Legacy, + cove_config::WidthEstimationMethod::Unicode => toss::WidthEstimationMethod::Unicode, + }); + Ui::run(config, tz, &mut terminal, vault.clone(), logger, logger_rx).await?; + drop(terminal); + + vault.close().await; + Ok(()) +} + +async fn export( + config: &'static Config, + dirs: &ProjectDirs, + args: export::Args, +) -> anyhow::Result<()> { + let vault = open_vault(config, dirs)?; + + export::export(&vault.euph(), args).await?; + + vault.close().await; + Ok(()) +} + +async fn gc(config: &'static Config, dirs: &ProjectDirs) -> anyhow::Result<()> { + let vault = open_vault(config, dirs)?; + + eprintln!("Cleaning up and compacting vault"); + eprintln!("This may take a while..."); + vault.gc().await?; + + vault.close().await; + Ok(()) +} + +async fn clear_cookies( + config: &'static Config, + dirs: &ProjectDirs, + domain: Option, +) -> anyhow::Result<()> { + let vault = open_vault(config, dirs)?; + + eprintln!("Clearing cookies"); + vault.euph().clear_cookies(domain).await?; + + vault.close().await; + Ok(()) +} + +fn help_config() { + print!("{}", Config::doc().as_markdown()); +} diff --git a/src/store.rs b/cove/src/store.rs similarity index 93% rename from src/store.rs rename to cove/src/store.rs index d762752..b7031c1 100644 --- a/src/store.rs +++ b/cove/src/store.rs @@ -1,7 +1,4 @@ -use std::collections::HashMap; -use std::fmt::Debug; -use std::hash::Hash; -use std::vec; +use std::{collections::HashMap, fmt::Debug, hash::Hash, vec}; use async_trait::async_trait; @@ -11,6 +8,10 @@ pub trait Msg { fn parent(&self) -> Option; fn seen(&self) -> bool; + fn nick_emoji(&self) -> Option { + None + } + fn last_possible_id() -> Self::Id; } @@ -27,12 +28,12 @@ impl Path { self.0.iter().take(self.0.len() - 1) } - pub fn push(&mut self, segment: I) { - self.0.push(segment) + pub fn first(&self) -> &I { + self.0.first().expect("path is empty") } - pub fn first(&self) -> &I { - self.0.first().expect("path is not empty") + pub fn into_first(self) -> I { + self.0.into_iter().next().expect("path is empty") } } @@ -130,6 +131,7 @@ impl Tree { } } +#[allow(dead_code)] #[async_trait] pub trait MsgStore { type Error; diff --git a/src/ui.rs b/cove/src/ui.rs similarity index 53% rename from src/ui.rs rename to cove/src/ui.rs index a9caaf9..5ebd540 100644 --- a/src/ui.rs +++ b/cove/src/ui.rs @@ -1,37 +1,56 @@ +use std::{ + convert::Infallible, + io, + sync::{Arc, Weak}, + time::{Duration, Instant}, +}; + +use cove_config::Config; +use cove_input::InputEvent; +use jiff::tz::TimeZone; +use parking_lot::FairMutex; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender, error::TryRecvError}, + task, +}; +use toss::{Terminal, WidgetExt, widgets::BoxedAsync}; + +use crate::{ + logger::{LogMsg, Logger}, + macros::logging_unwrap, + util::InfallibleExt, + vault::Vault, +}; + +pub use self::chat::ChatMsg; +use self::{chat::ChatState, rooms::Rooms, widgets::ListState}; + mod chat; mod euph; -mod input; +mod key_bindings; mod rooms; mod util; mod widgets; -use std::convert::Infallible; -use std::io; -use std::sync::{Arc, Weak}; -use std::time::{Duration, Instant}; - -use parking_lot::FairMutex; -use tokio::sync::mpsc::error::TryRecvError; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use tokio::task; -use toss::terminal::Terminal; - -use crate::config::Config; -use crate::logger::{LogMsg, Logger}; -use crate::macros::{logging_unwrap, ok_or_return, some_or_return}; -use crate::vault::Vault; - -pub use self::chat::ChatMsg; -use self::chat::ChatState; -use self::input::{key, InputEvent, KeyBindingsList}; -use self::rooms::Rooms; -use self::widgets::layer::Layer; -use self::widgets::list::ListState; -use self::widgets::BoxedWidget; - /// Time to spend batch processing events before redrawing the screen. const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps +/// Error for anything that can go wrong while rendering. +#[derive(Debug, thiserror::Error)] +pub enum UiError { + #[error("{0}")] + Vault(#[from] vault::tokio::Error), + #[error("{0}")] + Io(#[from] io::Error), +} + +impl From for UiError { + fn from(value: Infallible) -> Self { + Err(value).infallible() + } +} + +#[expect(clippy::large_enum_variant)] pub enum UiEvent { GraphemeWidthsChanged, LogChanged, @@ -51,16 +70,17 @@ enum Mode { Log, } -// TODO Add Error for anything that can go wrong while rendering - pub struct Ui { + config: &'static Config, event_tx: UnboundedSender, mode: Mode, rooms: Rooms, log_chat: ChatState, - key_bindings_list: Option>, + + key_bindings_visible: bool, + key_bindings_list: ListState, } impl Ui { @@ -68,6 +88,7 @@ impl Ui { pub async fn run( config: &'static Config, + tz: TimeZone, terminal: &mut Terminal, vault: Vault, logger: Logger, @@ -93,11 +114,13 @@ impl Ui { // On the other hand, if the crossterm_event_task stops for any reason, // the rest of the UI is also shut down and the client stops. let mut ui = Self { + config, event_tx: event_tx.clone(), mode: Mode::Main, - rooms: Rooms::new(config, vault, event_tx.clone()).await, - log_chat: ChatState::new(logger), - key_bindings_list: None, + rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await, + log_chat: ChatState::new(logger, tz), + key_bindings_visible: false, + key_bindings_list: ListState::new(), }; tokio::select! { e = ui.run_main(terminal, event_rx, crossterm_lock) => e?, @@ -110,13 +133,17 @@ impl Ui { fn poll_crossterm_events( tx: UnboundedSender, lock: Weak>, - ) -> crossterm::Result<()> { + ) -> io::Result<()> { loop { - let lock = some_or_return!(lock.upgrade(), Ok(())); + let Some(lock) = lock.upgrade() else { + return Ok(()); + }; let _guard = lock.lock(); if crossterm::event::poll(Self::POLL_DURATION)? { let event = crossterm::event::read()?; - ok_or_return!(tx.send(UiEvent::Term(event)), Ok(())); + if tx.send(UiEvent::Term(event)).is_err() { + return Ok(()); + } } } } @@ -126,8 +153,12 @@ impl Ui { event_tx: &UnboundedSender, ) { loop { - some_or_return!(logger_rx.recv().await); - ok_or_return!(event_tx.send(UiEvent::LogChanged)); + if logger_rx.recv().await.is_none() { + return; + } + if event_tx.send(UiEvent::LogChanged).is_err() { + return; + } } } @@ -136,27 +167,28 @@ impl Ui { terminal: &mut Terminal, mut event_rx: UnboundedReceiver, crossterm_lock: Arc>, - ) -> io::Result<()> { - // Initial render so we don't show a blank screen until the first event - terminal.autoresize()?; - terminal.frame().reset(); - self.widget().await.render(terminal.frame()).await; - terminal.present()?; + ) -> Result<(), UiError> { + let mut redraw = true; loop { - // 1. Measure grapheme widths if required - if terminal.measuring_required() { - let _guard = crossterm_lock.lock(); - terminal.measure_widths()?; - ok_or_return!(self.event_tx.send(UiEvent::GraphemeWidthsChanged), Ok(())); + // Redraw if necessary + if redraw { + redraw = false; + terminal.present_async_widget(self.widget().await).await?; + + if terminal.measuring_required() { + let _guard = crossterm_lock.lock(); + terminal.measure_widths()?; + if self.event_tx.send(UiEvent::GraphemeWidthsChanged).is_err() { + return Ok(()); + } + } } - // 2. Handle events (in batches) - let mut event = match event_rx.recv().await { - Some(event) => event, - None => return Ok(()), + // Handle events (in batches) + let Some(mut event) = event_rx.recv().await else { + return Ok(()); }; - let mut redraw = false; let end_time = Instant::now() + EVENT_PROCESSING_TIME; loop { match self.handle_event(terminal, &crossterm_lock, event).await { @@ -173,49 +205,23 @@ impl Ui { Err(TryRecvError::Disconnected) => return Ok(()), }; } - - // 3. Render and present final state - if redraw { - terminal.autoresize()?; - terminal.frame().reset(); - self.widget().await.render(terminal.frame()).await; - terminal.present()?; - } } } - async fn widget(&mut self) -> BoxedWidget { + async fn widget(&mut self) -> BoxedAsync<'_, UiError> { let widget = match self.mode { Mode::Main => self.rooms.widget().await, - Mode::Log => self.log_chat.widget(String::new(), true).into(), + Mode::Log => self.log_chat.widget(String::new(), true), }; - if let Some(key_bindings_list) = &self.key_bindings_list { - let mut bindings = KeyBindingsList::new(key_bindings_list); - self.list_key_bindings(&mut bindings).await; - Layer::new(vec![widget, bindings.widget()]).into() + if self.key_bindings_visible { + let popup = key_bindings::widget(&mut self.key_bindings_list, self.config); + popup.desync().above(widget).boxed_async() } else { widget } } - fn show_key_bindings(&mut self) { - if self.key_bindings_list.is_none() { - self.key_bindings_list = Some(ListState::new()) - } - } - - async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("ctrl+c", "quit cove"); - bindings.binding("F1, ?", "show this menu"); - bindings.binding("F12", "toggle log"); - bindings.empty(); - match self.mode { - Mode::Main => self.rooms.list_key_bindings(bindings).await, - Mode::Log => self.log_chat.list_key_bindings(bindings, false).await, - } - } - async fn handle_event( &mut self, terminal: &mut Terminal, @@ -228,7 +234,7 @@ impl Ui { UiEvent::LogChanged => EventHandleResult::Continue, UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw, UiEvent::Term(event) => { - self.handle_term_event(terminal, crossterm_lock, event) + self.handle_term_event(terminal, crossterm_lock.clone(), event) .await } UiEvent::Euph(event) => { @@ -244,74 +250,62 @@ impl Ui { async fn handle_term_event( &mut self, terminal: &mut Terminal, - crossterm_lock: &Arc>, + crossterm_lock: Arc>, event: crossterm::event::Event, ) -> EventHandleResult { - let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue); + let mut event = InputEvent::new(event, terminal, crossterm_lock); + let keys = &self.config.keys; - if let key!(Ctrl + 'c') = event { - // Exit unconditionally on ctrl+c. Previously, shift+q would also - // unconditionally exit, but that interfered with typing text in - // inline editors. + if event.matches(&keys.general.exit) { return EventHandleResult::Stop; } // Key bindings list overrides any other bindings if visible - if let Some(key_bindings_list) = &mut self.key_bindings_list { - match event { - key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None, - key!('k') | key!(Up) => key_bindings_list.scroll_up(1), - key!('j') | key!(Down) => key_bindings_list.scroll_down(1), - _ => return EventHandleResult::Continue, + if self.key_bindings_visible { + if event.matches(&keys.general.abort) || event.matches(&keys.general.help) { + self.key_bindings_visible = false; + return EventHandleResult::Redraw; } + if key_bindings::handle_input_event(&mut self.key_bindings_list, &mut event, keys) { + return EventHandleResult::Redraw; + } + // ... and does not let anything below the popup receive events + return EventHandleResult::Continue; + } + + if event.matches(&keys.general.help) { + self.key_bindings_visible = true; return EventHandleResult::Redraw; } - match event { - key!(F 1) => { - self.key_bindings_list = Some(ListState::new()); - return EventHandleResult::Redraw; - } - key!(F 12) => { - self.mode = match self.mode { - Mode::Main => Mode::Log, - Mode::Log => Mode::Main, - }; - return EventHandleResult::Redraw; - } - _ => {} - } - - let mut handled = match self.mode { + match self.mode { Mode::Main => { - self.rooms - .handle_input_event(terminal, crossterm_lock, &event) - .await + if event.matches(&keys.general.log) { + self.mode = Mode::Log; + return EventHandleResult::Redraw; + } + + if self.rooms.handle_input_event(&mut event, keys).await { + return EventHandleResult::Redraw; + } } Mode::Log => { + if event.matches(&keys.general.abort) || event.matches(&keys.general.log) { + self.mode = Mode::Main; + return EventHandleResult::Redraw; + } + let reaction = self .log_chat - .handle_input_event(terminal, crossterm_lock, &event, false) + .handle_input_event(&mut event, keys, false) .await; let reaction = logging_unwrap!(reaction); - reaction.handled() - } - }; - - // Pressing '?' should only open the key bindings list if it doesn't - // interfere with any part of the main UI, such as entering text in a - // text editor. - if !handled { - if let key!('?') = event { - self.show_key_bindings(); - handled = true; + if reaction.handled() { + return EventHandleResult::Redraw; + } } } - if handled { - EventHandleResult::Redraw - } else { - EventHandleResult::Continue - } + EventHandleResult::Continue } } diff --git a/cove/src/ui/chat.rs b/cove/src/ui/chat.rs new file mode 100644 index 0000000..1116935 --- /dev/null +++ b/cove/src/ui/chat.rs @@ -0,0 +1,186 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use jiff::{Timestamp, tz::TimeZone}; +use toss::{ + Styled, WidgetExt, + widgets::{BoxedAsync, EditorState}, +}; + +use crate::{ + store::{Msg, MsgStore}, + util, +}; + +use super::UiError; + +use self::{cursor::Cursor, tree::TreeViewState}; + +mod blocks; +mod cursor; +mod renderer; +mod tree; +mod widgets; + +pub trait ChatMsg { + fn time(&self) -> Option; + fn styled(&self) -> (Styled, Styled); + fn edit(nick: &str, content: &str) -> (Styled, Styled); + fn pseudo(nick: &str, content: &str) -> (Styled, Styled); +} + +pub enum Mode { + Tree, +} + +pub struct ChatState> { + store: S, + + cursor: Cursor, + editor: EditorState, + nick_emoji: bool, + caesar: i8, + + mode: Mode, + tree: TreeViewState, +} + +impl + Clone> ChatState { + pub fn new(store: S, tz: TimeZone) -> Self { + Self { + cursor: Cursor::Bottom, + editor: EditorState::new(), + nick_emoji: false, + caesar: 0, + + mode: Mode::Tree, + tree: TreeViewState::new(store.clone(), tz), + + store, + } + } + + pub fn nick_emoji(&self) -> bool { + self.nick_emoji + } +} + +impl> ChatState { + pub fn store(&self) -> &S { + &self.store + } + + pub fn widget(&mut self, nick: String, focused: bool) -> BoxedAsync<'_, UiError> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + UiError: From, + { + match self.mode { + Mode::Tree => self + .tree + .widget( + &mut self.cursor, + &mut self.editor, + nick, + focused, + self.nick_emoji, + self.caesar, + ) + .boxed_async(), + } + } + + pub async fn handle_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + can_compose: bool, + ) -> Result, S::Error> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + let reaction = match self.mode { + Mode::Tree => { + self.tree + .handle_input_event( + event, + keys, + &mut self.cursor, + &mut self.editor, + can_compose, + ) + .await? + } + }; + + Ok(match reaction { + Reaction::Composed { parent, content } if self.caesar != 0 => { + let content = util::caesar(&content, self.caesar); + Reaction::Composed { parent, content } + } + + Reaction::NotHandled if event.matches(&keys.tree.action.toggle_nick_emoji) => { + self.nick_emoji = !self.nick_emoji; + Reaction::Handled + } + + Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => { + self.caesar = (self.caesar + 1).rem_euclid(26); + Reaction::Handled + } + + Reaction::NotHandled if event.matches(&keys.tree.action.decrease_caesar) => { + self.caesar = (self.caesar - 1).rem_euclid(26); + Reaction::Handled + } + + reaction => reaction, + }) + } + + pub fn cursor(&self) -> Option<&M::Id> { + match &self.cursor { + Cursor::Msg(id) => Some(id), + Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None, + } + } + + /// A [`Reaction::Composed`] message was sent successfully. + pub fn send_successful(&mut self, id: M::Id) { + if let Cursor::Pseudo { .. } = &self.cursor { + self.tree.send_successful(&id); + self.cursor = Cursor::Msg(id); + self.editor.clear(); + } + } + + /// A [`Reaction::Composed`] message failed to be sent. + pub fn send_failed(&mut self) { + if let Cursor::Pseudo { coming_from, .. } = &self.cursor { + self.cursor = match coming_from { + Some(id) => Cursor::Msg(id.clone()), + None => Cursor::Bottom, + }; + } + } +} + +pub enum Reaction { + NotHandled, + Handled, + Composed { + parent: Option, + content: String, + }, +} + +impl Reaction { + pub fn handled(&self) -> bool { + !matches!(self, Self::NotHandled) + } +} diff --git a/cove/src/ui/chat/blocks.rs b/cove/src/ui/chat/blocks.rs new file mode 100644 index 0000000..8360e83 --- /dev/null +++ b/cove/src/ui/chat/blocks.rs @@ -0,0 +1,214 @@ +//! Common rendering logic. + +use std::collections::{VecDeque, vec_deque}; + +use toss::widgets::Predrawn; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Range { + pub top: T, + pub bottom: T, +} + +impl Range { + pub fn new(top: T, bottom: T) -> Self { + Self { top, bottom } + } +} + +impl Range { + pub fn shifted(self, delta: i32) -> Self { + Self::new(self.top + delta, self.bottom + delta) + } + + pub fn with_top(self, top: i32) -> Self { + self.shifted(top - self.top) + } + + pub fn with_bottom(self, bottom: i32) -> Self { + self.shifted(bottom - self.bottom) + } +} + +pub struct Block { + id: Id, + widget: Predrawn, + focus: Range, + can_be_cursor: bool, +} + +impl Block { + pub fn new(id: Id, widget: Predrawn, can_be_cursor: bool) -> Self { + let height: i32 = widget.size().height.into(); + Self { + id, + widget, + focus: Range::new(0, height), + can_be_cursor, + } + } + + pub fn id(&self) -> &Id { + &self.id + } + + pub fn into_widget(self) -> Predrawn { + self.widget + } + + fn height(&self) -> i32 { + self.widget.size().height.into() + } + + pub fn set_focus(&mut self, focus: Range) { + assert!(0 <= focus.top); + assert!(focus.top <= focus.bottom); + assert!(focus.bottom <= self.height()); + self.focus = focus; + } + + pub fn focus(&self, range: Range) -> Range { + Range::new(range.top + self.focus.top, range.top + self.focus.bottom) + } + + pub fn can_be_cursor(&self) -> bool { + self.can_be_cursor + } +} + +pub struct Blocks { + blocks: VecDeque>, + range: Range, + end: Range, +} + +impl Blocks { + pub fn new(at: i32) -> Self { + Self { + blocks: VecDeque::new(), + range: Range::new(at, at), + end: Range::new(false, false), + } + } + + pub fn range(&self) -> Range { + self.range + } + + pub fn end(&self) -> Range { + self.end + } + + pub fn iter(&self) -> Iter<'_, Id> { + Iter { + iter: self.blocks.iter(), + range: self.range, + } + } + + pub fn into_iter(self) -> IntoIter { + IntoIter { + iter: self.blocks.into_iter(), + range: self.range, + } + } + + pub fn find_block(&self, id: &Id) -> Option<(Range, &Block)> + where + Id: Eq, + { + self.iter().find(|(_, block)| block.id == *id) + } + + pub fn push_top(&mut self, block: Block) { + assert!(!self.end.top); + self.range.top -= block.height(); + self.blocks.push_front(block); + } + + pub fn push_bottom(&mut self, block: Block) { + assert!(!self.end.bottom); + self.range.bottom += block.height(); + self.blocks.push_back(block); + } + + pub fn append_top(&mut self, other: Self) { + assert!(!self.end.top); + assert!(!other.end.bottom); + for block in other.blocks.into_iter().rev() { + self.push_top(block); + } + self.end.top = other.end.top; + } + + pub fn append_bottom(&mut self, other: Self) { + assert!(!self.end.bottom); + assert!(!other.end.top); + for block in other.blocks { + self.push_bottom(block); + } + self.end.bottom = other.end.bottom; + } + + pub fn end_top(&mut self) { + self.end.top = true; + } + + pub fn end_bottom(&mut self) { + self.end.bottom = true; + } + + pub fn shift(&mut self, delta: i32) { + self.range = self.range.shifted(delta); + } +} + +pub struct Iter<'a, Id> { + iter: vec_deque::Iter<'a, Block>, + range: Range, +} + +impl<'a, Id> Iterator for Iter<'a, Id> { + type Item = (Range, &'a Block); + + fn next(&mut self) -> Option { + let block = self.iter.next()?; + let range = Range::new(self.range.top, self.range.top + block.height()); + self.range.top = range.bottom; + Some((range, block)) + } +} + +impl DoubleEndedIterator for Iter<'_, Id> { + fn next_back(&mut self) -> Option { + let block = self.iter.next_back()?; + let range = Range::new(self.range.bottom - block.height(), self.range.bottom); + self.range.bottom = range.top; + Some((range, block)) + } +} + +pub struct IntoIter { + iter: vec_deque::IntoIter>, + range: Range, +} + +impl Iterator for IntoIter { + type Item = (Range, Block); + + fn next(&mut self) -> Option { + let block = self.iter.next()?; + let range = Range::new(self.range.top, self.range.top + block.height()); + self.range.top = range.bottom; + Some((range, block)) + } +} + +impl DoubleEndedIterator for IntoIter { + fn next_back(&mut self) -> Option { + let block = self.iter.next_back()?; + let range = Range::new(self.range.bottom - block.height(), self.range.bottom); + self.range.bottom = range.top; + Some((range, block)) + } +} diff --git a/cove/src/ui/chat/cursor.rs b/cove/src/ui/chat/cursor.rs new file mode 100644 index 0000000..87bd8fc --- /dev/null +++ b/cove/src/ui/chat/cursor.rs @@ -0,0 +1,527 @@ +//! Common cursor movement logic. + +use std::{collections::HashSet, hash::Hash}; + +use crate::store::{Msg, MsgStore, Tree}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cursor { + Bottom, + Msg(Id), + Editor { + coming_from: Option, + parent: Option, + }, + Pseudo { + coming_from: Option, + parent: Option, + }, +} + +impl Cursor { + fn find_parent(tree: &Tree, id: &mut Id) -> bool + where + M: Msg, + { + if let Some(parent) = tree.parent(id) { + *id = parent; + true + } else { + false + } + } + + /// Move to the previous sibling, or don't move if this is not possible. + /// + /// Always stays at the same level of indentation. + async fn find_prev_sibling( + store: &S, + tree: &mut Tree, + id: &mut Id, + ) -> Result + where + M: Msg, + S: MsgStore, + { + let moved = if let Some(prev_sibling) = tree.prev_sibling(id) { + *id = prev_sibling; + true + } else if tree.parent(id).is_none() { + // We're at the root of our tree, so we need to move to the root of + // the previous tree. + if let Some(prev_root_id) = store.prev_root_id(tree.root()).await? { + *tree = store.tree(&prev_root_id).await?; + *id = prev_root_id; + true + } else { + false + } + } else { + false + }; + Ok(moved) + } + + /// Move to the next sibling, or don't move if this is not possible. + /// + /// Always stays at the same level of indentation. + async fn find_next_sibling( + store: &S, + tree: &mut Tree, + id: &mut Id, + ) -> Result + where + M: Msg, + S: MsgStore, + { + let moved = if let Some(next_sibling) = tree.next_sibling(id) { + *id = next_sibling; + true + } else if tree.parent(id).is_none() { + // We're at the root of our tree, so we need to move to the root of + // the next tree. + if let Some(next_root_id) = store.next_root_id(tree.root()).await? { + *tree = store.tree(&next_root_id).await?; + *id = next_root_id; + true + } else { + false + } + } else { + false + }; + Ok(moved) + } + + fn find_first_child_in_tree(folded: &HashSet, tree: &Tree, id: &mut Id) -> bool + where + M: Msg, + { + if folded.contains(id) { + return false; + } + + if let Some(child) = tree.children(id).and_then(|c| c.first()) { + *id = child.clone(); + true + } else { + false + } + } + + fn find_last_child_in_tree(folded: &HashSet, tree: &Tree, id: &mut Id) -> bool + where + M: Msg, + { + if folded.contains(id) { + return false; + } + + if let Some(child) = tree.children(id).and_then(|c| c.last()) { + *id = child.clone(); + true + } else { + false + } + } + + /// Move to the message above, or don't move if this is not possible. + async fn find_above_msg_in_tree( + store: &S, + folded: &HashSet, + tree: &mut Tree, + id: &mut Id, + ) -> Result + where + M: Msg, + S: MsgStore, + { + // Move to previous sibling, then to its last child + // If not possible, move to parent + let moved = if Self::find_prev_sibling(store, tree, id).await? { + while Self::find_last_child_in_tree(folded, tree, id) {} + true + } else { + Self::find_parent(tree, id) + }; + Ok(moved) + } + + /// Move to the next message, or don't move if this is not possible. + async fn find_below_msg_in_tree( + store: &S, + folded: &HashSet, + tree: &mut Tree, + id: &mut Id, + ) -> Result + where + M: Msg, + S: MsgStore, + { + if Self::find_first_child_in_tree(folded, tree, id) { + return Ok(true); + } + + if Self::find_next_sibling(store, tree, id).await? { + return Ok(true); + } + + // Temporary id to avoid modifying the original one if no parent-sibling + // can be found. + let mut tmp_id = id.clone(); + while Self::find_parent(tree, &mut tmp_id) { + if Self::find_next_sibling(store, tree, &mut tmp_id).await? { + *id = tmp_id; + return Ok(true); + } + } + + Ok(false) + } + + pub async fn move_to_top(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + if let Some(first_root_id) = store.first_root_id().await? { + *self = Self::Msg(first_root_id); + } + Ok(()) + } + + pub fn move_to_bottom(&mut self) { + *self = Self::Bottom; + } + + pub async fn move_to_older_msg(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(id) => { + if let Some(prev_id) = store.older_msg_id(id).await? { + *id = prev_id; + } + } + Self::Bottom | Self::Pseudo { .. } => { + if let Some(id) = store.newest_msg_id().await? { + *self = Self::Msg(id); + } + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_newer_msg(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(id) => { + if let Some(prev_id) = store.newer_msg_id(id).await? { + *id = prev_id; + } else { + *self = Self::Bottom; + } + } + Self::Pseudo { .. } => { + *self = Self::Bottom; + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_older_unseen_msg(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(id) => { + if let Some(prev_id) = store.older_unseen_msg_id(id).await? { + *id = prev_id; + } + } + Self::Bottom | Self::Pseudo { .. } => { + if let Some(id) = store.newest_unseen_msg_id().await? { + *self = Self::Msg(id); + } + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_newer_unseen_msg(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(id) => { + if let Some(prev_id) = store.newer_unseen_msg_id(id).await? { + *id = prev_id; + } else { + *self = Self::Bottom; + } + } + Self::Pseudo { .. } => { + *self = Self::Bottom; + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_parent(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Editor { parent, .. } | Self::Pseudo { parent, .. } => { + if let Some(parent_id) = parent { + *self = Self::Msg(parent_id.clone()) + } + } + + Self::Msg(id) => { + let path = store.path(id).await?; + if let Some(parent_id) = path.parent_segments().last() { + *id = parent_id.clone(); + } + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_root(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Pseudo { + parent: Some(parent), + .. + } => { + let path = store.path(parent).await?; + *self = Self::Msg(path.first().clone()); + } + Self::Msg(id) => { + let path = store.path(id).await?; + *id = path.first().clone(); + } + _ => {} + } + Ok(()) + } + + pub async fn move_to_prev_sibling(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Bottom | Self::Pseudo { parent: None, .. } => { + if let Some(last_root_id) = store.last_root_id().await? { + *self = Self::Msg(last_root_id); + } + } + Self::Msg(msg) => { + let path = store.path(msg).await?; + let mut tree = store.tree(path.first()).await?; + Self::find_prev_sibling(store, &mut tree, msg).await?; + } + Self::Editor { .. } => {} + Self::Pseudo { + parent: Some(parent), + .. + } => { + let path = store.path(parent).await?; + let tree = store.tree(path.first()).await?; + if let Some(children) = tree.children(parent) { + if let Some(last_child) = children.last() { + *self = Self::Msg(last_child.clone()); + } + } + } + } + Ok(()) + } + + pub async fn move_to_next_sibling(&mut self, store: &S) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(msg) => { + let path = store.path(msg).await?; + let mut tree = store.tree(path.first()).await?; + if !Self::find_next_sibling(store, &mut tree, msg).await? + && tree.parent(msg).is_none() + { + *self = Self::Bottom; + } + } + Self::Pseudo { parent: None, .. } => { + *self = Self::Bottom; + } + _ => {} + } + Ok(()) + } + + pub async fn move_up_in_tree( + &mut self, + store: &S, + folded: &HashSet, + ) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Bottom | Self::Pseudo { parent: None, .. } => { + if let Some(last_root_id) = store.last_root_id().await? { + let tree = store.tree(&last_root_id).await?; + let mut id = last_root_id; + while Self::find_last_child_in_tree(folded, &tree, &mut id) {} + *self = Self::Msg(id); + } + } + Self::Msg(msg) => { + let path = store.path(msg).await?; + let mut tree = store.tree(path.first()).await?; + Self::find_above_msg_in_tree(store, folded, &mut tree, msg).await?; + } + Self::Editor { .. } => {} + Self::Pseudo { + parent: Some(parent), + .. + } => { + let tree = store.tree(parent).await?; + let mut id = parent.clone(); + while Self::find_last_child_in_tree(folded, &tree, &mut id) {} + *self = Self::Msg(id); + } + } + Ok(()) + } + + pub async fn move_down_in_tree( + &mut self, + store: &S, + folded: &HashSet, + ) -> Result<(), S::Error> + where + M: Msg, + S: MsgStore, + { + match self { + Self::Msg(msg) => { + let path = store.path(msg).await?; + let mut tree = store.tree(path.first()).await?; + if !Self::find_below_msg_in_tree(store, folded, &mut tree, msg).await? { + *self = Self::Bottom; + } + } + Self::Pseudo { parent: None, .. } => { + *self = Self::Bottom; + } + Self::Pseudo { + parent: Some(parent), + .. + } => { + let mut tree = store.tree(parent).await?; + let mut id = parent.clone(); + while Self::find_last_child_in_tree(folded, &tree, &mut id) {} + // Now we're at the previous message + if Self::find_below_msg_in_tree(store, folded, &mut tree, &mut id).await? { + *self = Self::Msg(id); + } else { + *self = Self::Bottom; + } + } + _ => {} + } + Ok(()) + } + + /// The outer `Option` shows whether a parent exists or not. The inner + /// `Option` shows if that parent has an id. + pub async fn parent_for_normal_tree_reply( + &self, + store: &S, + ) -> Result>, S::Error> + where + M: Msg, + S: MsgStore, + { + Ok(match self { + Self::Bottom => Some(None), + Self::Msg(id) => { + let path = store.path(id).await?; + let tree = store.tree(path.first()).await?; + + Some(Some(if tree.next_sibling(id).is_some() { + // A reply to a message that has further siblings should be + // a direct reply. An indirect reply might end up a lot + // further down in the current conversation. + id.clone() + } else if let Some(parent) = tree.parent(id) { + // A reply to a message without younger siblings should be + // an indirect reply so as not to create unnecessarily deep + // threads. In the case that our message has children, this + // might get a bit confusing. I'm not sure yet how well this + // "smart" reply actually works in practice. + parent + } else { + // When replying to a top-level message, it makes sense to + // avoid creating unnecessary new threads. + id.clone() + })) + } + _ => None, + }) + } + + /// The outer `Option` shows whether a parent exists or not. The inner + /// `Option` shows if that parent has an id. + pub async fn parent_for_alternate_tree_reply( + &self, + store: &S, + ) -> Result>, S::Error> + where + M: Msg, + S: MsgStore, + { + Ok(match self { + Self::Bottom => Some(None), + Self::Msg(id) => { + let path = store.path(id).await?; + let tree = store.tree(path.first()).await?; + + Some(Some(if tree.next_sibling(id).is_none() { + // The opposite of replying normally + id.clone() + } else if let Some(parent) = tree.parent(id) { + // The opposite of replying normally + parent + } else { + // The same as replying normally, still to avoid creating + // unnecessary new threads + id.clone() + })) + } + _ => None, + }) + } +} diff --git a/cove/src/ui/chat/renderer.rs b/cove/src/ui/chat/renderer.rs new file mode 100644 index 0000000..a619e7c --- /dev/null +++ b/cove/src/ui/chat/renderer.rs @@ -0,0 +1,328 @@ +use std::cmp::Ordering; + +use async_trait::async_trait; +use toss::Size; + +use super::blocks::{Blocks, Range}; + +#[async_trait] +pub trait Renderer { + type Error; + + fn size(&self) -> Size; + fn scrolloff(&self) -> i32; + + fn blocks(&self) -> &Blocks; + fn blocks_mut(&mut self) -> &mut Blocks; + + async fn expand_top(&mut self) -> Result<(), Self::Error>; + async fn expand_bottom(&mut self) -> Result<(), Self::Error>; +} + +/// A range of all the lines that are visible given the renderer's size. +pub fn visible_area(r: &R) -> Range +where + R: Renderer, +{ + let height: i32 = r.size().height.into(); + Range::new(0, height) +} + +/// The renderer's visible area, reduced by its scrolloff at the top and bottom. +fn scroll_area(r: &R) -> Range +where + R: Renderer, +{ + let range = visible_area(r); + let scrolloff = r.scrolloff(); + let top = range.top + scrolloff; + let bottom = top.max(range.bottom - scrolloff); + Range::new(top, bottom) +} + +/// Compute a delta that makes the object partially or fully overlap the area +/// when added to the object. This delta should be as close to zero as possible. +/// +/// If the object has a height of zero, it must be within the area or exactly on +/// its border to be considered overlapping. +/// +/// If the object has a nonzero height, at least one line of the object must be +/// within the area for the object to be considered overlapping. +fn overlap_delta(area: Range, object: Range) -> i32 { + assert!(object.top <= object.bottom, "object range not well-formed"); + assert!(area.top <= area.bottom, "area range not well-formed"); + + if object.top == object.bottom || area.top == area.bottom { + // Delta that moves the object.bottom to area.top. If this is positive, + // we need to move the object because it is too high. + let move_to_top = area.top - object.bottom; + + // Delta that moves the object.top to area.bottom. If this is negative, + // we need to move the object because it is too low. + let move_to_bottom = area.bottom - object.top; + + // move_to_top <= move_to_bottom because... + // + // Case 1: object.top == object.bottom + // Premise follows from rom area.top <= area.bottom + // + // Case 2: area.top == area.bottom + // Premise follows from object.top <= object.bottom + 0.clamp(move_to_top, move_to_bottom) + } else { + // Delta that moves object.bottom one line below area.top. If this is + // positive, we need to move the object because it is too high. + let move_to_top = (area.top + 1) - object.bottom; + + // Delta that moves object.top one line above area.bottom. If this is + // negative, we need to move the object because it is too low. + let move_to_bottom = (area.bottom - 1) - object.top; + + // move_to_top <= move_to_bottom because... + // + // We know that area.top < area.bottom and object.top < object.bottom, + // otherwise we'd be in the previous `if` branch. + // + // We get the largest value for move_to_top if area.top is largest and + // object.bottom is smallest. We get the smallest value for + // move_to_bottom if area.bottom is smallest and object.top is largest. + // + // This means that the worst case scenario is when area.top and + // area.bottom as well as object.top and object.bottom are closest + // together. In other words: + // + // area.top + 1 == area.bottom + // object.top + 1 == object.bottom + // + // Inserting that into our formulas for move_to_top and move_to_bottom, + // we get: + // + // move_to_top = (area.top + 1) - (object.top + 1) = area.top + object.top + // move_to_bottom = (area.top + 1 - 1) - object.top = area.top + object.top + 0.clamp(move_to_top, move_to_bottom) + } +} + +pub fn overlaps(area: Range, object: Range) -> bool { + overlap_delta(area, object) == 0 +} + +/// Move the object such that it overlaps the area. +fn overlap(area: Range, object: Range) -> Range { + object.shifted(overlap_delta(area, object)) +} + +/// Compute a delta that makes the object fully overlap the area when added to +/// the object. This delta should be as close to zero as possible. +/// +/// If the object is higher than the area, it should be moved such that +/// object.top == area.top. +fn full_overlap_delta(area: Range, object: Range) -> i32 { + assert!(object.top <= object.bottom, "object range not well-formed"); + assert!(area.top <= area.bottom, "area range not well-formed"); + + // Delta that moves object.top to area.top. If this is positive, we need to + // move the object because it is too high. + let move_to_top = area.top - object.top; + + // Delta that moves object.bottom to area.bottom. If this is negative, we + // need to move the object because it is too low. + let move_to_bottom = area.bottom - object.bottom; + + // If the object is higher than the area, move_to_top becomes larger than + // move_to_bottom. In that case, this function should return move_to_top. + 0.min(move_to_bottom).max(move_to_top) +} + +async fn expand_upwards_until(r: &mut R, top: i32) -> Result<(), R::Error> +where + R: Renderer, +{ + loop { + let blocks = r.blocks(); + if blocks.end().top || blocks.range().top <= top { + break; + } + + r.expand_top().await?; + } + + Ok(()) +} + +async fn expand_downwards_until(r: &mut R, bottom: i32) -> Result<(), R::Error> +where + R: Renderer, +{ + loop { + let blocks = r.blocks(); + if blocks.end().bottom || blocks.range().bottom >= bottom { + break; + } + + r.expand_bottom().await?; + } + + Ok(()) +} + +pub async fn expand_to_fill_visible_area(r: &mut R) -> Result<(), R::Error> +where + R: Renderer, +{ + let area = visible_area(r); + expand_upwards_until(r, area.top).await?; + expand_downwards_until(r, area.bottom).await?; + Ok(()) +} + +/// Expand blocks such that the screen is full for any offset where the +/// specified block is visible. The block must exist. +pub async fn expand_to_fill_screen_around_block(r: &mut R, id: &Id) -> Result<(), R::Error> +where + Id: Eq, + R: Renderer, +{ + let screen = visible_area(r); + let (block, _) = r.blocks().find_block(id).expect("no block with that id"); + + let top = overlap(block, screen.with_bottom(block.top)).top; + let bottom = overlap(block, screen.with_top(block.bottom)).bottom; + + expand_upwards_until(r, top).await?; + expand_downwards_until(r, bottom).await?; + + Ok(()) +} + +/// Scroll so that the top of the block is at the specified value. Returns +/// `true` if successful, or `false` if the block could not be found. +pub fn scroll_to_set_block_top(r: &mut R, id: &Id, top: i32) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, _)) = r.blocks().find_block(id) { + let delta = top - range.top; + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn scroll_so_block_is_centered(r: &mut R, id: &Id) +where + Id: Eq, + R: Renderer, +{ + let area = visible_area(r); + let (range, block) = r.blocks().find_block(id).expect("no block with that id"); + let focus = block.focus(range); + let focus_height = focus.bottom - focus.top; + let top = (area.top + area.bottom - focus_height) / 2; + r.blocks_mut().shift(top - range.top); +} + +pub fn scroll_blocks_fully_above_screen(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks_mut(); + let delta = area.top - blocks.range().bottom; + blocks.shift(delta); +} + +pub fn scroll_blocks_fully_below_screen(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks_mut(); + let delta = area.bottom - blocks.range().top; + blocks.shift(delta); +} + +pub fn scroll_so_block_focus_overlaps_scroll_area(r: &mut R, id: &Id) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, block)) = r.blocks().find_block(id) { + let area = scroll_area(r); + let delta = overlap_delta(area, block.focus(range)); + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn scroll_so_block_focus_fully_overlaps_scroll_area(r: &mut R, id: &Id) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, block)) = r.blocks().find_block(id) { + let area = scroll_area(r); + let delta = full_overlap_delta(area, block.focus(range)); + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn clamp_scroll_biased_downwards(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks().range(); + + // Delta that moves blocks.top to the top of the screen. If this is + // negative, we need to move the blocks because they're too low. + let move_to_top = area.top - blocks.top; + + // Delta that moves blocks.bottom to the bottom of the screen. If this is + // positive, we need to move the blocks because they're too high. + let move_to_bottom = area.bottom - blocks.bottom; + + // If the screen is higher, the blocks should rather be moved to the bottom + // than the top because of the downwards bias. + let delta = 0.min(move_to_top).max(move_to_bottom); + r.blocks_mut().shift(delta); +} + +pub fn find_cursor_starting_at<'a, Id, R>(r: &'a R, id: &Id) -> Option<&'a Id> +where + Id: Eq, + R: Renderer, +{ + let area = scroll_area(r); + let (range, block) = r.blocks().find_block(id)?; + let delta = overlap_delta(area, block.focus(range)); + match delta.cmp(&0) { + Ordering::Equal => Some(block.id()), + + // Blocks must be scrolled downwards to become visible, meaning the + // cursor must be above the visible area. + Ordering::Greater => r + .blocks() + .iter() + .filter(|(_, block)| block.can_be_cursor()) + .find(|(range, block)| overlaps(area, block.focus(*range))) + .map(|(_, block)| block.id()), + + // Blocks must be scrolled upwards to become visible, meaning the cursor + // must be below the visible area. + Ordering::Less => r + .blocks() + .iter() + .rev() + .filter(|(_, block)| block.can_be_cursor()) + .find(|(range, block)| overlaps(area, block.focus(*range))) + .map(|(_, block)| block.id()), + } +} diff --git a/cove/src/ui/chat/tree.rs b/cove/src/ui/chat/tree.rs new file mode 100644 index 0000000..d9905fc --- /dev/null +++ b/cove/src/ui/chat/tree.rs @@ -0,0 +1,480 @@ +//! Rendering messages as full trees. + +// TODO Focusing on sub-trees + +use std::collections::HashSet; + +use async_trait::async_trait; +use cove_config::Keys; +use cove_input::InputEvent; +use jiff::tz::TimeZone; +use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb, widgets::EditorState}; + +use crate::{ + store::{Msg, MsgStore}, + ui::{UiError, util}, + util::InfallibleExt, +}; + +use super::{ChatMsg, Reaction, cursor::Cursor}; + +use self::renderer::{TreeContext, TreeRenderer}; + +mod renderer; +mod scroll; +mod widgets; + +pub struct TreeViewState> { + store: S, + tz: TimeZone, + + last_size: Size, + last_nick: String, + last_cursor: Cursor, + last_cursor_top: i32, + last_visible_msgs: Vec, + + folded: HashSet, +} + +impl> TreeViewState { + pub fn new(store: S, tz: TimeZone) -> Self { + Self { + store, + tz, + last_size: Size::ZERO, + last_nick: String::new(), + last_cursor: Cursor::Bottom, + last_cursor_top: 0, + last_visible_msgs: vec![], + folded: HashSet::new(), + } + } + + async fn handle_movement_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + cursor: &mut Cursor, + editor: &mut EditorState, + ) -> Result + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + let chat_height: i32 = (event.frame().size().height - 3).into(); + + // Basic cursor movement + if event.matches(&keys.cursor.up) { + cursor.move_up_in_tree(&self.store, &self.folded).await?; + return Ok(true); + } + if event.matches(&keys.cursor.down) { + cursor.move_down_in_tree(&self.store, &self.folded).await?; + return Ok(true); + } + if event.matches(&keys.cursor.to_top) { + cursor.move_to_top(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.cursor.to_bottom) { + cursor.move_to_bottom(); + return Ok(true); + } + + // Tree cursor movement + if event.matches(&keys.tree.cursor.to_above_sibling) { + cursor.move_to_prev_sibling(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_below_sibling) { + cursor.move_to_next_sibling(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_parent) { + cursor.move_to_parent(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_root) { + cursor.move_to_root(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_older_message) { + cursor.move_to_older_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_newer_message) { + cursor.move_to_newer_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_older_unseen_message) { + cursor.move_to_older_unseen_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_newer_unseen_message) { + cursor.move_to_newer_unseen_msg(&self.store).await?; + return Ok(true); + } + + // Scrolling + if event.matches(&keys.scroll.up_line) { + self.scroll_by(cursor, editor, event.widthdb(), 1).await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_line) { + self.scroll_by(cursor, editor, event.widthdb(), -1).await?; + return Ok(true); + } + if event.matches(&keys.scroll.up_half) { + let delta = chat_height / 2; + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_half) { + let delta = -(chat_height / 2); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.up_full) { + let delta = chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_full) { + let delta = -chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.center_cursor) { + self.center_cursor(cursor, editor, event.widthdb()).await?; + return Ok(true); + } + + Ok(false) + } + + async fn handle_action_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + id: Option<&M::Id>, + ) -> Result { + if event.matches(&keys.tree.action.fold_tree) { + if let Some(id) = id { + if !self.folded.remove(id) { + self.folded.insert(id.clone()); + } + } + return Ok(true); + } + + if event.matches(&keys.tree.action.toggle_seen) { + if let Some(id) = id { + if let Some(msg) = self.store.tree(id).await?.msg(id) { + self.store.set_seen(id, !msg.seen()).await?; + } + } + return Ok(true); + } + + if event.matches(&keys.tree.action.mark_visible_seen) { + for id in &self.last_visible_msgs { + self.store.set_seen(id, true).await?; + } + return Ok(true); + } + + if event.matches(&keys.tree.action.mark_older_seen) { + if let Some(id) = id { + self.store.set_older_seen(id, true).await?; + } else { + self.store + .set_older_seen(&M::last_possible_id(), true) + .await?; + } + return Ok(true); + } + + Ok(false) + } + + async fn handle_edit_initiating_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + cursor: &mut Cursor, + id: Option, + ) -> Result { + if event.matches(&keys.tree.action.reply) { + if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? { + *cursor = Cursor::Editor { + coming_from: id, + parent, + }; + } + return Ok(true); + } + + if event.matches(&keys.tree.action.reply_alternate) { + if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? { + *cursor = Cursor::Editor { + coming_from: id, + parent, + }; + } + return Ok(true); + } + + if event.matches(&keys.tree.action.new_thread) { + *cursor = Cursor::Editor { + coming_from: id, + parent: None, + }; + return Ok(true); + } + + Ok(false) + } + + async fn handle_normal_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + cursor: &mut Cursor, + editor: &mut EditorState, + can_compose: bool, + id: Option, + ) -> Result + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + if self + .handle_movement_input_event(event, keys, cursor, editor) + .await? + { + return Ok(true); + } + + if self + .handle_action_input_event(event, keys, id.as_ref()) + .await? + { + return Ok(true); + } + + if can_compose + && self + .handle_edit_initiating_input_event(event, keys, cursor, id) + .await? + { + return Ok(true); + } + + Ok(false) + } + + fn handle_editor_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + cursor: &mut Cursor, + editor: &mut EditorState, + coming_from: Option, + parent: Option, + ) -> Reaction { + // Abort edit + if event.matches(&keys.general.abort) { + *cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); + return Reaction::Handled; + } + + // Send message + if event.matches(&keys.general.confirm) { + let content = editor.text().to_string(); + if content.trim().is_empty() { + return Reaction::Handled; + } + *cursor = Cursor::Pseudo { + coming_from, + parent: parent.clone(), + }; + return Reaction::Composed { parent, content }; + } + + // TODO Tab-completion + + // Editing + if util::handle_editor_input_event(editor, event, keys, |_| true) { + return Reaction::Handled; + } + + Reaction::NotHandled + } + + pub async fn handle_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + cursor: &mut Cursor, + editor: &mut EditorState, + can_compose: bool, + ) -> Result, S::Error> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + Ok(match cursor { + Cursor::Bottom => { + if self + .handle_normal_input_event(event, keys, cursor, editor, can_compose, None) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + Cursor::Msg(id) => { + let id = id.clone(); + if self + .handle_normal_input_event(event, keys, cursor, editor, can_compose, Some(id)) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + Cursor::Editor { + coming_from, + parent, + } => { + let coming_from = coming_from.clone(); + let parent = parent.clone(); + self.handle_editor_input_event(event, keys, cursor, editor, coming_from, parent) + } + Cursor::Pseudo { .. } => { + if self + .handle_movement_input_event(event, keys, cursor, editor) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + }) + } + + pub fn send_successful(&mut self, id: &M::Id) { + if let Cursor::Pseudo { .. } = self.last_cursor { + self.last_cursor = Cursor::Msg(id.clone()); + } + } + + pub fn widget<'a>( + &'a mut self, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + nick: String, + focused: bool, + nick_emoji: bool, + caesar: i8, + ) -> TreeView<'a, M, S> { + TreeView { + state: self, + cursor, + editor, + nick, + focused, + nick_emoji, + caesar, + } + } +} + +pub struct TreeView<'a, M: Msg, S: MsgStore> { + state: &'a mut TreeViewState, + + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + + nick: String, + focused: bool, + + nick_emoji: bool, + caesar: i8, +} + +#[async_trait] +impl AsyncWidget for TreeView<'_, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, + UiError: From, +{ + async fn size( + &self, + _widthdb: &mut WidthDb, + _max_width: Option, + _max_height: Option, + ) -> Result { + Ok(Size::ZERO) + } + + async fn draw(self, frame: &mut Frame) -> Result<(), UiError> { + let size = frame.size(); + + let context = TreeContext { + size, + nick: self.nick.clone(), + focused: self.focused, + nick_emoji: self.nick_emoji, + caesar: self.caesar, + last_cursor: self.state.last_cursor.clone(), + last_cursor_top: self.state.last_cursor_top, + }; + + let mut renderer = TreeRenderer::new( + context, + &self.state.store, + &self.state.tz, + &mut self.state.folded, + self.cursor, + self.editor, + frame.widthdb(), + ); + + renderer.prepare_blocks_for_drawing().await?; + + self.state.last_size = size; + self.state.last_nick = self.nick; + renderer.update_render_info( + &mut self.state.last_cursor, + &mut self.state.last_cursor_top, + &mut self.state.last_visible_msgs, + ); + + for (range, block) in renderer.into_visible_blocks() { + let widget = block.into_widget(); + frame.push(Pos::new(0, range.top), widget.size()); + widget.desync().draw(frame).await.infallible(); + frame.pop(); + } + + Ok(()) + } +} diff --git a/cove/src/ui/chat/tree/renderer.rs b/cove/src/ui/chat/tree/renderer.rs new file mode 100644 index 0000000..225191b --- /dev/null +++ b/cove/src/ui/chat/tree/renderer.rs @@ -0,0 +1,523 @@ +//! A [`Renderer`] for message trees. + +use std::{collections::HashSet, convert::Infallible}; + +use async_trait::async_trait; +use jiff::tz::TimeZone; +use toss::{ + Size, Widget, WidthDb, + widgets::{EditorState, Empty, Predrawn, Resize}, +}; + +use crate::{ + store::{Msg, MsgStore, Tree}, + ui::{ + ChatMsg, + chat::{ + blocks::{Block, Blocks, Range}, + cursor::Cursor, + renderer::{self, Renderer, overlaps}, + }, + }, + util::InfallibleExt, +}; + +use super::widgets; + +/// When rendering messages as full trees, special ids and zero-height messages +/// are used for robust scrolling behaviour. +#[derive(PartialEq, Eq)] +pub enum TreeBlockId { + /// There is a zero-height block at the very bottom of the chat that has + /// this id. It is used for positioning [`Cursor::Bottom`]. + Bottom, + /// Normal messages have this id. It is used for positioning + /// [`Cursor::Msg`]. + Msg(Id), + /// After all children of a message, a zero-height block with this id is + /// rendered. It is used for positioning [`Cursor::Editor`] and + /// [`Cursor::Pseudo`]. + After(Id), +} + +impl TreeBlockId { + pub fn from_cursor(cursor: &Cursor) -> Self { + match cursor { + Cursor::Bottom + | Cursor::Editor { parent: None, .. } + | Cursor::Pseudo { parent: None, .. } => Self::Bottom, + + Cursor::Msg(id) => Self::Msg(id.clone()), + + Cursor::Editor { + parent: Some(id), .. + } + | Cursor::Pseudo { + parent: Some(id), .. + } => Self::After(id.clone()), + } + } + + pub fn any_id(&self) -> Option<&Id> { + match self { + Self::Bottom => None, + Self::Msg(id) | Self::After(id) => Some(id), + } + } + + pub fn msg_id(&self) -> Option<&Id> { + match self { + Self::Bottom | Self::After(_) => None, + Self::Msg(id) => Some(id), + } + } +} + +type TreeBlock = Block>; +type TreeBlocks = Blocks>; + +pub struct TreeContext { + pub size: Size, + pub nick: String, + pub focused: bool, + pub nick_emoji: bool, + pub caesar: i8, + pub last_cursor: Cursor, + pub last_cursor_top: i32, +} + +pub struct TreeRenderer<'a, M: Msg, S: MsgStore> { + context: TreeContext, + + store: &'a S, + tz: &'a TimeZone, + folded: &'a mut HashSet, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + widthdb: &'a mut WidthDb, + + /// Root id of the topmost tree in the blocks. When set to `None`, only the + /// bottom of the chat history has been rendered. + top_root_id: Option, + /// Root id of the bottommost tree in the blocks. When set to `None`, only + /// the bottom of the chat history has been rendered. + bottom_root_id: Option, + + blocks: TreeBlocks, +} + +impl<'a, M, S> TreeRenderer<'a, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + /// You must call [`Self::prepare_blocks_for_drawing`] immediately after + /// calling this function. + pub fn new( + context: TreeContext, + store: &'a S, + tz: &'a TimeZone, + folded: &'a mut HashSet, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + widthdb: &'a mut WidthDb, + ) -> Self { + Self { + context, + store, + tz, + folded, + cursor, + editor, + widthdb, + top_root_id: None, + bottom_root_id: None, + blocks: Blocks::new(0), + } + } + + fn predraw(widget: W, size: Size, widthdb: &mut WidthDb) -> Predrawn + where + W: Widget, + { + Predrawn::new(Resize::new(widget).with_max_width(size.width), widthdb).infallible() + } + + fn zero_height_block(&mut self, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + let widget = Self::predraw(Empty::new(), self.context.size, self.widthdb); + Block::new(id, widget, false) + } + + fn editor_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + let widget = widgets::editor::( + indent, + &self.context.nick, + self.context.focused, + self.editor, + ); + let widget = Self::predraw(widget, self.context.size, self.widthdb); + let mut block = Block::new(id, widget, false); + + // Since the editor was rendered when the `Predrawn` was created, the + // last cursor pos is accurate now. + let cursor_line = self.editor.last_cursor_pos().y; + block.set_focus(Range::new(cursor_line, cursor_line + 1)); + + block + } + + fn pseudo_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + let widget = widgets::pseudo::(indent, &self.context.nick, self.editor); + let widget = Self::predraw(widget, self.context.size, self.widthdb); + Block::new(id, widget, false) + } + + fn message_block( + &mut self, + indent: usize, + msg: &M, + folded_info: Option, + ) -> TreeBlock { + let msg_id = msg.id(); + + let highlighted = match self.cursor { + Cursor::Msg(id) => *id == msg_id, + _ => false, + }; + let highlighted = highlighted && self.context.focused; + + let widget = widgets::msg( + highlighted, + self.tz.clone(), + indent, + msg, + self.context.nick_emoji, + self.context.caesar, + folded_info, + ); + let widget = Self::predraw(widget, self.context.size, self.widthdb); + Block::new(TreeBlockId::Msg(msg_id), widget, true) + } + + fn message_placeholder_block( + &mut self, + indent: usize, + msg_id: &M::Id, + folded_info: Option, + ) -> TreeBlock { + let highlighted = match self.cursor { + Cursor::Msg(id) => id == msg_id, + _ => false, + }; + let highlighted = highlighted && self.context.focused; + + let widget = widgets::msg_placeholder(highlighted, indent, folded_info); + let widget = Self::predraw(widget, self.context.size, self.widthdb); + Block::new(TreeBlockId::Msg(msg_id.clone()), widget, true) + } + + fn layout_bottom(&mut self) -> TreeBlocks { + let mut blocks = Blocks::new(0); + + match self.cursor { + Cursor::Editor { parent: None, .. } => blocks.push_bottom(self.editor_block(0, None)), + Cursor::Pseudo { parent: None, .. } => blocks.push_bottom(self.pseudo_block(0, None)), + _ => blocks.push_bottom(self.zero_height_block(None)), + } + + blocks + } + + fn layout_subtree( + &mut self, + tree: &Tree, + indent: usize, + msg_id: &M::Id, + blocks: &mut TreeBlocks, + ) { + let folded = self.folded.contains(msg_id); + let folded_info = if folded { + Some(tree.subtree_size(msg_id)).filter(|s| *s > 0) + } else { + None + }; + + // Message itself + let block = if let Some(msg) = tree.msg(msg_id) { + self.message_block(indent, msg, folded_info) + } else { + self.message_placeholder_block(indent, msg_id, folded_info) + }; + blocks.push_bottom(block); + + // Children, recursively + if !folded { + if let Some(children) = tree.children(msg_id) { + for child in children { + self.layout_subtree(tree, indent + 1, child, blocks); + } + } + } + + // After message (zero-height block, editor, or placeholder) + let block = match self.cursor { + Cursor::Editor { + parent: Some(id), .. + } if id == msg_id => self.editor_block(indent + 1, Some(msg_id)), + + Cursor::Pseudo { + parent: Some(id), .. + } if id == msg_id => self.pseudo_block(indent + 1, Some(msg_id)), + + _ => self.zero_height_block(Some(msg_id)), + }; + blocks.push_bottom(block); + } + + fn layout_tree(&mut self, tree: Tree) -> TreeBlocks { + let mut blocks = Blocks::new(0); + self.layout_subtree(&tree, 0, tree.root(), &mut blocks); + blocks + } + + async fn root_id(&self, id: &TreeBlockId) -> Result, S::Error> { + let Some(id) = id.any_id() else { + return Ok(None); + }; + let path = self.store.path(id).await?; + Ok(Some(path.into_first())) + } + + /// Render the tree containing the cursor to the blocks and set the top and + /// bottom root id accordingly. This function will always render a block + /// that has the cusor id. + async fn prepare_initial_tree( + &mut self, + cursor_id: &TreeBlockId, + root_id: &Option, + ) -> Result<(), S::Error> { + self.top_root_id = root_id.clone(); + self.bottom_root_id = root_id.clone(); + + let blocks = if let Some(root_id) = root_id { + let tree = self.store.tree(root_id).await?; + + // To ensure the cursor block will be rendered, all its parents must + // be unfolded. + if let TreeBlockId::Msg(id) | TreeBlockId::After(id) = cursor_id { + let mut id = id.clone(); + while let Some(parent_id) = tree.parent(&id) { + self.folded.remove(&parent_id); + id = parent_id; + } + } + + self.layout_tree(tree) + } else { + self.layout_bottom() + }; + self.blocks.append_bottom(blocks); + + Ok(()) + } + + fn make_cursor_visible(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + if *self.cursor == self.context.last_cursor { + // Cursor did not move, so we just need to ensure it overlaps the + // scroll area + renderer::scroll_so_block_focus_overlaps_scroll_area(self, &cursor_id); + } else { + // Cursor moved, so it should fully overlap the scroll area + renderer::scroll_so_block_focus_fully_overlaps_scroll_area(self, &cursor_id); + } + } + + fn root_id_is_above_root_id(first: Option, second: Option) -> bool { + match (first, second) { + (Some(_), None) => true, + (Some(a), Some(b)) => a < b, + _ => false, + } + } + + pub async fn prepare_blocks_for_drawing(&mut self) -> Result<(), S::Error> { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + let cursor_root_id = self.root_id(&cursor_id).await?; + + // Render cursor and blocks around it so that the screen will always be + // filled as long as the cursor is visible, regardless of how the screen + // is scrolled. + self.prepare_initial_tree(&cursor_id, &cursor_root_id) + .await?; + renderer::expand_to_fill_screen_around_block(self, &cursor_id).await?; + + // Scroll based on last cursor position + let last_cursor_id = TreeBlockId::from_cursor(&self.context.last_cursor); + if !renderer::scroll_to_set_block_top(self, &last_cursor_id, self.context.last_cursor_top) { + // Since the last cursor is not within scrolling distance of our + // current cursor, we need to estimate whether the last cursor was + // above or below the current cursor. + let last_cursor_root_id = self.root_id(&last_cursor_id).await?; + if Self::root_id_is_above_root_id(last_cursor_root_id, cursor_root_id) { + renderer::scroll_blocks_fully_below_screen(self); + } else { + renderer::scroll_blocks_fully_above_screen(self); + } + } + + // Fulfill scroll constraints + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + + Ok(()) + } + + fn move_cursor_so_it_is_visible(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + if matches!(cursor_id, TreeBlockId::Bottom | TreeBlockId::Msg(_)) { + match renderer::find_cursor_starting_at(self, &cursor_id) { + Some(TreeBlockId::Bottom) => *self.cursor = Cursor::Bottom, + Some(TreeBlockId::Msg(id)) => *self.cursor = Cursor::Msg(id.clone()), + _ => {} + } + } + } + + pub async fn scroll_by(&mut self, delta: i32) -> Result<(), S::Error> { + self.blocks.shift(delta); + renderer::expand_to_fill_visible_area(self).await?; + renderer::clamp_scroll_biased_downwards(self); + + self.move_cursor_so_it_is_visible(); + + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + + Ok(()) + } + + pub fn center_cursor(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + renderer::scroll_so_block_is_centered(self, &cursor_id); + + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + } + + pub fn update_render_info( + &self, + last_cursor: &mut Cursor, + last_cursor_top: &mut i32, + last_visible_msgs: &mut Vec, + ) { + *last_cursor = self.cursor.clone(); + + let cursor_id = TreeBlockId::from_cursor(self.cursor); + let (range, _) = self.blocks.find_block(&cursor_id).unwrap(); + *last_cursor_top = range.top; + + let area = renderer::visible_area(self); + *last_visible_msgs = self + .blocks + .iter() + .filter(|(range, _)| overlaps(area, *range)) + .filter_map(|(_, block)| block.id().msg_id()) + .cloned() + .collect() + } + + pub fn into_visible_blocks( + self, + ) -> impl Iterator, Block>)> + use { + let area = renderer::visible_area(&self); + self.blocks + .into_iter() + .filter(move |(range, block)| overlaps(area, block.focus(*range))) + } +} + +#[async_trait] +impl Renderer> for TreeRenderer<'_, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + type Error = S::Error; + + fn size(&self) -> Size { + self.context.size + } + + fn scrolloff(&self) -> i32 { + 2 // TODO Make configurable + } + + fn blocks(&self) -> &TreeBlocks { + &self.blocks + } + + fn blocks_mut(&mut self) -> &mut TreeBlocks { + &mut self.blocks + } + + async fn expand_top(&mut self) -> Result<(), Self::Error> { + let prev_root_id = if let Some(top_root_id) = &self.top_root_id { + self.store.prev_root_id(top_root_id).await? + } else { + self.store.last_root_id().await? + }; + + if let Some(prev_root_id) = prev_root_id { + let tree = self.store.tree(&prev_root_id).await?; + let blocks = self.layout_tree(tree); + self.blocks.append_top(blocks); + self.top_root_id = Some(prev_root_id); + } else { + self.blocks.end_top(); + } + + Ok(()) + } + + async fn expand_bottom(&mut self) -> Result<(), Self::Error> { + let Some(bottom_root_id) = &self.bottom_root_id else { + self.blocks.end_bottom(); + return Ok(()); + }; + + let next_root_id = self.store.next_root_id(bottom_root_id).await?; + if let Some(next_root_id) = next_root_id { + let tree = self.store.tree(&next_root_id).await?; + let blocks = self.layout_tree(tree); + self.blocks.append_bottom(blocks); + self.bottom_root_id = Some(next_root_id); + } else { + let blocks = self.layout_bottom(); + self.blocks.append_bottom(blocks); + self.blocks.end_bottom(); + self.bottom_root_id = None; + }; + + Ok(()) + } +} diff --git a/cove/src/ui/chat/tree/scroll.rs b/cove/src/ui/chat/tree/scroll.rs new file mode 100644 index 0000000..a8a1305 --- /dev/null +++ b/cove/src/ui/chat/tree/scroll.rs @@ -0,0 +1,88 @@ +use toss::{WidthDb, widgets::EditorState}; + +use crate::{ + store::{Msg, MsgStore}, + ui::{ChatMsg, chat::cursor::Cursor}, +}; + +use super::{ + TreeViewState, + renderer::{TreeContext, TreeRenderer}, +}; + +impl TreeViewState +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + fn last_context(&self) -> TreeContext { + TreeContext { + size: self.last_size, + nick: self.last_nick.clone(), + focused: true, + nick_emoji: false, + caesar: 0, + last_cursor: self.last_cursor.clone(), + last_cursor_top: self.last_cursor_top, + } + } + + pub async fn scroll_by( + &mut self, + cursor: &mut Cursor, + editor: &mut EditorState, + widthdb: &mut WidthDb, + delta: i32, + ) -> Result<(), S::Error> { + let context = self.last_context(); + let mut renderer = TreeRenderer::new( + context, + &self.store, + &self.tz, + &mut self.folded, + cursor, + editor, + widthdb, + ); + renderer.prepare_blocks_for_drawing().await?; + + renderer.scroll_by(delta).await?; + + renderer.update_render_info( + &mut self.last_cursor, + &mut self.last_cursor_top, + &mut self.last_visible_msgs, + ); + Ok(()) + } + + pub async fn center_cursor( + &mut self, + cursor: &mut Cursor, + editor: &mut EditorState, + widthdb: &mut WidthDb, + ) -> Result<(), S::Error> { + let context = self.last_context(); + let mut renderer = TreeRenderer::new( + context, + &self.store, + &self.tz, + &mut self.folded, + cursor, + editor, + widthdb, + ); + renderer.prepare_blocks_for_drawing().await?; + + renderer.center_cursor(); + + renderer.update_render_info( + &mut self.last_cursor, + &mut self.last_cursor_top, + &mut self.last_visible_msgs, + ); + Ok(()) + } +} diff --git a/cove/src/ui/chat/tree/widgets.rs b/cove/src/ui/chat/tree/widgets.rs new file mode 100644 index 0000000..dd7fa89 --- /dev/null +++ b/cove/src/ui/chat/tree/widgets.rs @@ -0,0 +1,214 @@ +use std::convert::Infallible; + +use crossterm::style::Stylize; +use jiff::tz::TimeZone; +use toss::{ + Style, Styled, WidgetExt, + widgets::{Boxed, EditorState, Join2, Join4, Join5, Text}, +}; + +use crate::{ + store::Msg, + ui::{ + ChatMsg, + chat::widgets::{Indent, Seen, Time}, + }, + util, +}; + +pub const PLACEHOLDER: &str = "[...]"; + +pub fn style_placeholder() -> Style { + Style::new().dark_grey() +} + +fn style_time(highlighted: bool) -> Style { + if highlighted { + Style::new().black().on_white() + } else { + Style::new().grey() + } +} + +fn style_indent(highlighted: bool) -> Style { + if highlighted { + Style::new().black().on_white() + } else { + Style::new().dark_grey() + } +} + +fn style_caesar() -> Style { + Style::new().green() +} + +fn style_info() -> Style { + Style::new().italic().dark_grey() +} + +fn style_editor_highlight() -> Style { + Style::new().black().on_cyan() +} + +fn style_pseudo_highlight() -> Style { + Style::new().black().on_yellow() +} + +pub fn msg( + highlighted: bool, + tz: TimeZone, + indent: usize, + msg: &M, + nick_emoji: bool, + caesar: i8, + folded_info: Option, +) -> Boxed<'static, Infallible> { + let (mut nick, mut content) = msg.styled(); + + if nick_emoji { + if let Some(emoji) = msg.nick_emoji() { + nick = nick.then_plain("(").then_plain(emoji).then_plain(")"); + } + } + + if caesar != 0 { + // Apply caesar in inverse because we're decoding + let rotated = util::caesar(content.text(), -caesar); + content = content + .then_plain("\n") + .then(format!("{rotated} [rot{caesar}]"), style_caesar()); + } + + if let Some(amount) = folded_info { + content = content + .then_plain("\n") + .then(format!("[{amount} more]"), style_info()); + } + + Join5::horizontal( + Seen::new(msg.seen()).segment().with_fixed(true), + Time::new(msg.time().map(|t| t.to_zoned(tz)), style_time(highlighted)) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_indent(highlighted)) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + // TODO Minimum content width + // TODO Minimizing and maximizing messages + Text::new(content).segment(), + ) + .boxed() +} + +pub fn msg_placeholder( + highlighted: bool, + indent: usize, + folded_info: Option, +) -> Boxed<'static, Infallible> { + let mut content = Styled::new(PLACEHOLDER, style_placeholder()); + + if let Some(amount) = folded_info { + content = content + .then_plain("\n") + .then(format!("[{amount} more]"), style_info()); + } + + Join4::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_time(highlighted)) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_indent(highlighted)) + .segment() + .with_fixed(true), + Text::new(content).segment(), + ) + .boxed() +} + +pub fn editor<'a, M: ChatMsg>( + indent: usize, + nick: &str, + focus: bool, + editor: &'a mut EditorState, +) -> Boxed<'a, Infallible> { + let (nick, content) = M::edit(nick, editor.text()); + let editor = editor + .widget() + .with_highlight(|_| content) + .with_focus(focus); + + Join5::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_editor_highlight()) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_editor_highlight()) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + editor.segment(), + ) + .boxed() +} + +pub fn pseudo<'a, M: ChatMsg>( + indent: usize, + nick: &str, + editor: &'a mut EditorState, +) -> Boxed<'a, Infallible> { + let (nick, content) = M::edit(nick, editor.text()); + + Join5::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_pseudo_highlight()) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_pseudo_highlight()) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + Text::new(content).segment(), + ) + .boxed() +} diff --git a/cove/src/ui/chat/widgets.rs b/cove/src/ui/chat/widgets.rs new file mode 100644 index 0000000..e0e2fe5 --- /dev/null +++ b/cove/src/ui/chat/widgets.rs @@ -0,0 +1,117 @@ +use std::convert::Infallible; + +use crossterm::style::Stylize; +use jiff::Zoned; +use toss::{ + Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb, + widgets::{Boxed, Empty, Text}, +}; + +use crate::util::InfallibleExt; + +pub const INDENT_STR: &str = "│ "; +pub const INDENT_WIDTH: usize = 2; + +pub struct Indent { + level: usize, + style: Style, +} + +impl Indent { + pub fn new(level: usize, style: Style) -> Self { + Self { level, style } + } +} + +impl Widget for Indent { + fn size( + &self, + _widthdb: &mut WidthDb, + _max_width: Option, + _max_height: Option, + ) -> Result { + let width = (INDENT_WIDTH * self.level).try_into().unwrap_or(u16::MAX); + Ok(Size::new(width, 0)) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + let size = frame.size(); + let indent_string = INDENT_STR.repeat(self.level); + + for y in 0..size.height { + frame.write(Pos::new(0, y.into()), (&indent_string, self.style)) + } + + Ok(()) + } +} + +const TIME_FORMAT: &str = "%Y-%m-%d %H:%M"; +const TIME_WIDTH: u16 = 16; + +pub struct Time(Boxed<'static, Infallible>); + +impl Time { + pub fn new(time: Option, style: Style) -> Self { + let widget = if let Some(time) = time { + let text = time.strftime(TIME_FORMAT).to_string(); + Text::new((text, style)) + .background() + .with_style(style) + .boxed() + } else { + Empty::new() + .with_width(TIME_WIDTH) + .background() + .with_style(style) + .boxed() + }; + Self(widget) + } +} + +impl Widget for Time { + fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + max_height: Option, + ) -> Result { + Ok(self.0.size(widthdb, max_width, max_height).infallible()) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + self.0.draw(frame).infallible(); + Ok(()) + } +} + +pub struct Seen(Boxed<'static, Infallible>); + +impl Seen { + pub fn new(seen: bool) -> Self { + let widget = if seen { + Empty::new().with_width(1).boxed() + } else { + let style = Style::new().black().on_green(); + Text::new("*").background().with_style(style).boxed() + }; + Self(widget) + } +} + +impl Widget for Seen { + fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + max_height: Option, + ) -> Result { + Ok(self.0.size(widthdb, max_width, max_height).infallible()) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + self.0.draw(frame).infallible(); + Ok(()) + } +} diff --git a/src/ui/euph.rs b/cove/src/ui/euph.rs similarity index 100% rename from src/ui/euph.rs rename to cove/src/ui/euph.rs diff --git a/cove/src/ui/euph/account.rs b/cove/src/ui/euph/account.rs new file mode 100644 index 0000000..7aa776f --- /dev/null +++ b/cove/src/ui/euph/account.rs @@ -0,0 +1,195 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use euphoxide::{api::PersonalAccountView, conn}; +use toss::{ + Style, Widget, WidgetExt, + widgets::{EditorState, Empty, Join3, Join4, Join5, Text}, +}; + +use crate::{ + euph::{self, Room}, + ui::{UiError, util, widgets::Popup}, +}; + +use super::popup::PopupResult; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Email, + Password, +} + +pub struct LoggedOut { + focus: Focus, + email: EditorState, + password: EditorState, +} + +impl LoggedOut { + fn new() -> Self { + Self { + focus: Focus::Email, + email: EditorState::new(), + password: EditorState::new(), + } + } + + fn widget(&mut self) -> impl Widget { + let bold = Style::new().bold(); + Join4::vertical( + Text::new(("Not logged in", bold.yellow())).segment(), + Empty::new().with_height(1).segment(), + Join3::horizontal( + Text::new(("Email address:", bold)) + .segment() + .with_fixed(true), + Empty::new().with_width(1).segment().with_fixed(true), + self.email + .widget() + .with_focus(self.focus == Focus::Email) + .segment(), + ) + .segment(), + Join3::horizontal( + Text::new(("Password:", bold)).segment().with_fixed(true), + Empty::new().with_width(5 + 1).segment().with_fixed(true), + self.password + .widget() + .with_focus(self.focus == Focus::Password) + .with_hidden_default_placeholder() + .segment(), + ) + .segment(), + ) + } +} + +pub struct LoggedIn(PersonalAccountView); + +impl LoggedIn { + fn widget(&self) -> impl Widget + use<> { + let bold = Style::new().bold(); + Join5::vertical( + Text::new(("Logged in", bold.green())).segment(), + Empty::new().with_height(1).segment(), + Join3::horizontal( + Text::new(("Email address:", bold)) + .segment() + .with_fixed(true), + Empty::new().with_width(1).segment().with_fixed(true), + Text::new((&self.0.email,)).segment(), + ) + .segment(), + Empty::new().with_height(1).segment(), + Text::new(("Log out", Style::new().black().on_white())).segment(), + ) + } +} + +pub enum AccountUiState { + LoggedOut(LoggedOut), + LoggedIn(LoggedIn), +} + +impl AccountUiState { + pub fn new() -> Self { + Self::LoggedOut(LoggedOut::new()) + } + + /// Returns `false` if the account UI should not be displayed any longer. + pub fn stabilize(&mut self, state: Option<&euph::State>) -> bool { + if let Some(euph::State::Connected(_, conn::State::Joined(state))) = state { + match (&self, &state.account) { + (Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())), + (Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()), + _ => {} + } + true + } else { + false + } + } + + pub fn widget(&mut self) -> impl Widget { + let inner = match self { + Self::LoggedOut(logged_out) => logged_out.widget().first2(), + Self::LoggedIn(logged_in) => logged_in.widget().second2(), + } + .resize() + .with_min_width(40); + + Popup::new(inner, "Account") + } + + pub fn handle_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + room: &Option, + ) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; + } + + match self { + Self::LoggedOut(logged_out) => { + if event.matches(&keys.general.focus) { + logged_out.focus = match logged_out.focus { + Focus::Email => Focus::Password, + Focus::Password => Focus::Email, + }; + return PopupResult::Handled; + } + + match logged_out.focus { + Focus::Email => { + if event.matches(&keys.general.confirm) { + logged_out.focus = Focus::Password; + return PopupResult::Handled; + } + + if util::handle_editor_input_event( + &mut logged_out.email, + event, + keys, + |c| c != '\n', + ) { + return PopupResult::Handled; + } + } + Focus::Password => { + if event.matches(&keys.general.confirm) { + if let Some(room) = room { + let _ = room.login( + logged_out.email.text().to_string(), + logged_out.password.text().to_string(), + ); + } + return PopupResult::Handled; + } + + if util::handle_editor_input_event( + &mut logged_out.password, + event, + keys, + |c| c != '\n', + ) { + return PopupResult::Handled; + } + } + } + } + Self::LoggedIn(_) => { + if event.matches(&keys.general.confirm) { + if let Some(room) = room { + let _ = room.logout(); + } + return PopupResult::Handled; + } + } + } + + PopupResult::NotHandled + } +} diff --git a/cove/src/ui/euph/auth.rs b/cove/src/ui/euph/auth.rs new file mode 100644 index 0000000..15f8fe1 --- /dev/null +++ b/cove/src/ui/euph/auth.rs @@ -0,0 +1,45 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use toss::{Widget, widgets::EditorState}; + +use crate::{ + euph::Room, + ui::{UiError, util, widgets::Popup}, +}; + +use super::popup::PopupResult; + +pub fn new() -> EditorState { + EditorState::new() +} + +pub fn widget(editor: &mut EditorState) -> impl Widget { + Popup::new( + editor.widget().with_hidden_default_placeholder(), + "Enter password", + ) +} + +pub fn handle_input_event( + event: &mut InputEvent<'_>, + keys: &Keys, + room: &Option, + editor: &mut EditorState, +) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; + } + + if event.matches(&keys.general.confirm) { + if let Some(room) = &room { + let _ = room.auth(editor.text().to_string()); + } + return PopupResult::Close; + } + + if util::handle_editor_input_event(editor, event, keys, |_| true) { + return PopupResult::Handled; + } + + PopupResult::NotHandled +} diff --git a/src/ui/euph/inspect.rs b/cove/src/ui/euph/inspect.rs similarity index 66% rename from src/ui/euph/inspect.rs rename to cove/src/ui/euph/inspect.rs index 4f1f427..b3c4e0e 100644 --- a/src/ui/euph/inspect.rs +++ b/cove/src/ui/euph/inspect.rs @@ -1,41 +1,46 @@ -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::{Message, NickEvent, SessionView}; -use euphoxide::conn::SessionInfo; -use toss::styled::Styled; +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use euphoxide::{ + api::{Message, NickEvent, SessionView}, + conn::SessionInfo, +}; +use toss::{Style, Styled, Widget, widgets::Text}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; +use crate::ui::{UiError, widgets::Popup}; + +use super::popup::PopupResult; macro_rules! line { ( $text:ident, $name:expr, $val:expr ) => { $text = $text - .then($name, ContentStyle::default().cyan()) + .then($name, Style::new().cyan()) .then_plain(format!(" {}\n", $val)); }; ( $text:ident, $name:expr, $val:expr, debug ) => { $text = $text - .then($name, ContentStyle::default().cyan()) + .then($name, Style::new().cyan()) .then_plain(format!(" {:?}\n", $val)); }; ( $text:ident, $name:expr, $val:expr, optional ) => { if let Some(val) = $val { $text = $text - .then($name, ContentStyle::default().cyan()) + .then($name, Style::new().cyan()) .then_plain(format!(" {val}\n")); } else { $text = $text - .then($name, ContentStyle::default().cyan()) + .then($name, Style::new().cyan()) .then_plain(" ") - .then("none", ContentStyle::default().italic().grey()) + .then("none", Style::new().italic().grey()) .then_plain("\n"); } }; ( $text:ident, $name:expr, $val:expr, yes or no ) => { - $text = $text - .then($name, ContentStyle::default().cyan()) - .then_plain(if $val { " yes\n" } else { " no\n" }); + $text = $text.then($name, Style::new().cyan()).then_plain(if $val { + " yes\n" + } else { + " no\n" + }); }; } @@ -86,8 +91,8 @@ fn message_lines(mut text: Styled, msg: &Message) -> Styled { text } -pub fn session_widget(session: &SessionInfo) -> BoxedWidget { - let heading_style = ContentStyle::default().bold(); +pub fn session_widget(session: &SessionInfo) -> impl Widget + use<> { + let heading_style = Style::new().bold(); let text = match session { SessionInfo::Full(session) => { @@ -100,11 +105,11 @@ pub fn session_widget(session: &SessionInfo) -> BoxedWidget { } }; - Popup::new(Text::new(text)).title("Inspect session").build() + Popup::new(Text::new(text), "Inspect session") } -pub fn message_widget(msg: &Message) -> BoxedWidget { - let heading_style = ContentStyle::default().bold(); +pub fn message_widget(msg: &Message) -> impl Widget + use<> { + let heading_style = Style::new().bold(); let mut text = Styled::new("Message", heading_style).then_plain("\n"); @@ -117,21 +122,13 @@ pub fn message_widget(msg: &Message) -> BoxedWidget { text = session_view_lines(text, &msg.sender); - Popup::new(Text::new(text)).title("Inspect message").build() + Popup::new(Text::new(text), "Inspect message") } -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close"); -} - -pub enum EventResult { - NotHandled, - Close, -} - -pub fn handle_input_event(event: &InputEvent) -> EventResult { - match event { - key!(Esc) => EventResult::Close, - _ => EventResult::NotHandled, +pub fn handle_input_event(event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } + + PopupResult::NotHandled } diff --git a/cove/src/ui/euph/links.rs b/cove/src/ui/euph/links.rs new file mode 100644 index 0000000..c64830d --- /dev/null +++ b/cove/src/ui/euph/links.rs @@ -0,0 +1,192 @@ +use cove_config::{Config, Keys}; +use cove_input::InputEvent; +use crossterm::{event::KeyCode, style::Stylize}; +use linkify::{LinkFinder, LinkKind}; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{Join2, Text}, +}; + +use crate::{ + euph::{self, SpanType}, + ui::{ + UiError, key_bindings, util, + widgets::{ListBuilder, ListState, Popup}, + }, +}; + +use super::popup::PopupResult; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Link { + Url(String), + Room(String), +} + +pub struct LinksState { + config: &'static Config, + links: Vec, + list: ListState, +} + +const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; + +impl LinksState { + pub fn new(config: &'static Config, content: &str) -> Self { + let mut links = vec![]; + + // Collect URL-like links + for link in LinkFinder::new() + .url_must_have_scheme(false) + .kinds(&[LinkKind::Url]) + .links(content) + { + links.push(( + link.start(), + link.end(), + Link::Url(link.as_str().to_string()), + )); + } + + // Collect room links + for (span, range) in euph::find_spans(content) { + if span == SpanType::Room { + let name = &content[range.start + 1..range.end]; + links.push((range.start, range.end, Link::Room(name.to_string()))); + } + } + + links.sort(); + let links = links + .into_iter() + .map(|(_, _, link)| link) + .collect::>(); + + Self { + config, + links, + list: ListState::new(), + } + } + + pub fn widget(&mut self) -> impl Widget { + let style_selected = Style::new().black().on_white(); + + let mut list_builder = ListBuilder::new(); + + if self.links.is_empty() { + list_builder.add_unsel(Text::new(("No links found", Style::new().grey().italic()))) + } + + for (id, link) in self.links.iter().enumerate() { + let link = link.clone(); + list_builder.add_sel(id, move |selected| { + let mut text = Styled::default(); + + // Number key indicator + text = match NUMBER_KEYS.get(id) { + None if selected => text.then(" ", style_selected), + None => text.then_plain(" "), + Some(key) if selected => text.then(format!("[{key}] "), style_selected.bold()), + Some(key) => text.then(format!("[{key}] "), Style::new().dark_grey().bold()), + }; + + // The link itself + text = match link { + Link::Url(url) if selected => text.then(url, style_selected), + Link::Url(url) => text.then_plain(url), + Link::Room(name) if selected => { + text.then(format!("&{name}"), style_selected.bold()) + } + Link::Room(name) => text.then(format!("&{name}"), Style::new().blue().bold()), + }; + + Text::new(text).with_wrap(false) + }); + } + + let hint_style = Style::new().grey().italic(); + let hint = Styled::new("Open links with ", hint_style) + .and_then(key_bindings::format_binding( + &self.config.keys.general.confirm, + )) + .then(" or the number keys.", hint_style); + + Popup::new( + Join2::vertical( + list_builder.build(&mut self.list).segment(), + Text::new(hint) + .padding() + .with_top(1) + .segment() + .with_fixed(true), + ), + "Links", + ) + } + + fn open_link_by_id(&self, id: usize) -> PopupResult { + match self.links.get(id) { + Some(Link::Url(url)) => { + // The `http://` or `https://` schema is necessary for + // open::that to successfully open the link in the browser. + let link = if url.starts_with("http://") || url.starts_with("https://") { + url.clone() + } else { + format!("https://{url}") + }; + + if let Err(error) = open::that(&link) { + return PopupResult::ErrorOpeningLink { link, error }; + } + } + + Some(Link::Room(name)) => return PopupResult::SwitchToRoom { name: name.clone() }, + + _ => {} + } + PopupResult::Handled + } + + fn open_link(&self) -> PopupResult { + if let Some(id) = self.list.selected() { + self.open_link_by_id(*id) + } else { + PopupResult::Handled + } + } + + pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; + } + + if event.matches(&keys.general.confirm) { + return self.open_link(); + } + + if util::handle_list_input_event(&mut self.list, event, keys) { + return PopupResult::Handled; + } + + if let Some(key_event) = event.key_event() { + if key_event.modifiers.is_empty() { + match key_event.code { + KeyCode::Char('1') => return self.open_link_by_id(0), + KeyCode::Char('2') => return self.open_link_by_id(1), + KeyCode::Char('3') => return self.open_link_by_id(2), + KeyCode::Char('4') => return self.open_link_by_id(3), + KeyCode::Char('5') => return self.open_link_by_id(4), + KeyCode::Char('6') => return self.open_link_by_id(5), + KeyCode::Char('7') => return self.open_link_by_id(6), + KeyCode::Char('8') => return self.open_link_by_id(7), + KeyCode::Char('9') => return self.open_link_by_id(8), + KeyCode::Char('0') => return self.open_link_by_id(9), + _ => {} + } + } + } + + PopupResult::NotHandled + } +} diff --git a/cove/src/ui/euph/nick.rs b/cove/src/ui/euph/nick.rs new file mode 100644 index 0000000..707e992 --- /dev/null +++ b/cove/src/ui/euph/nick.rs @@ -0,0 +1,47 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use euphoxide::conn::Joined; +use toss::{Style, Widget, widgets::EditorState}; + +use crate::{ + euph::{self, Room}, + ui::{UiError, util, widgets::Popup}, +}; + +use super::popup::PopupResult; + +pub fn new(joined: Joined) -> EditorState { + EditorState::with_initial_text(joined.session.name) +} + +pub fn widget(editor: &mut EditorState) -> impl Widget { + let inner = editor + .widget() + .with_highlight(|s| euph::style_nick_exact(s, Style::new())); + + Popup::new(inner, "Choose nick") +} + +pub fn handle_input_event( + event: &mut InputEvent<'_>, + keys: &Keys, + room: &Option, + editor: &mut EditorState, +) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; + } + + if event.matches(&keys.general.confirm) { + if let Some(room) = &room { + let _ = room.nick(editor.text().to_string()); + } + return PopupResult::Close; + } + + if util::handle_editor_input_event(editor, event, keys, |c| c != '\n') { + return PopupResult::Handled; + } + + PopupResult::NotHandled +} diff --git a/cove/src/ui/euph/nick_list.rs b/cove/src/ui/euph/nick_list.rs new file mode 100644 index 0000000..8fbdb7b --- /dev/null +++ b/cove/src/ui/euph/nick_list.rs @@ -0,0 +1,222 @@ +use std::iter; + +use crossterm::style::{Color, Stylize}; +use euphoxide::{ + api::{NickEvent, SessionId, SessionType, SessionView, UserId}, + conn::{Joined, SessionInfo}, +}; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{Background, Text}, +}; + +use crate::{ + euph, + ui::{ + UiError, + widgets::{ListBuilder, ListState}, + }, +}; + +pub fn widget<'a>( + list: &'a mut ListState, + joined: &Joined, + focused: bool, + nick_emoji: bool, +) -> impl Widget + use<'a> { + let mut list_builder = ListBuilder::new(); + render_rows(&mut list_builder, joined, focused, nick_emoji); + list_builder.build(list) +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct HalfSession { + name: String, + id: UserId, + session_id: SessionId, + is_staff: bool, + is_manager: bool, +} + +impl HalfSession { + fn from_session_view(sess: &SessionView) -> Self { + Self { + name: sess.name.clone(), + id: sess.id.clone(), + session_id: sess.session_id.clone(), + is_staff: sess.is_staff, + is_manager: sess.is_manager, + } + } + + fn from_nick_event(nick: &NickEvent) -> Self { + Self { + name: nick.to.clone(), + id: nick.id.clone(), + session_id: nick.session_id.clone(), + is_staff: false, + is_manager: false, + } + } + + fn from_session_info(info: &SessionInfo) -> Self { + match info { + SessionInfo::Full(sess) => Self::from_session_view(sess), + SessionInfo::Partial(nick) => Self::from_nick_event(nick), + } + } +} + +fn render_rows( + list_builder: &mut ListBuilder<'_, SessionId, Background>, + joined: &Joined, + focused: bool, + nick_emoji: bool, +) { + let mut people = vec![]; + let mut bots = vec![]; + let mut lurkers = vec![]; + let mut nurkers = vec![]; + + let sessions = joined + .listing + .values() + .map(HalfSession::from_session_info) + .chain(iter::once(HalfSession::from_session_view(&joined.session))); + for sess in sessions { + match sess.id.session_type() { + Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess), + Some(SessionType::Bot) => bots.push(sess), + _ if sess.name.is_empty() => lurkers.push(sess), + _ => people.push(sess), + } + } + + people.sort_unstable(); + bots.sort_unstable(); + lurkers.sort_unstable(); + nurkers.sort_unstable(); + + render_section( + list_builder, + "People", + &people, + &joined.session, + focused, + nick_emoji, + ); + render_section( + list_builder, + "Bots", + &bots, + &joined.session, + focused, + nick_emoji, + ); + render_section( + list_builder, + "Lurkers", + &lurkers, + &joined.session, + focused, + nick_emoji, + ); + render_section( + list_builder, + "Nurkers", + &nurkers, + &joined.session, + focused, + nick_emoji, + ); +} + +fn render_section( + list_builder: &mut ListBuilder<'_, SessionId, Background>, + name: &str, + sessions: &[HalfSession], + own_session: &SessionView, + focused: bool, + nick_emoji: bool, +) { + if sessions.is_empty() { + return; + } + + let heading_style = Style::new().bold(); + + if !list_builder.is_empty() { + list_builder.add_unsel(Text::new("").background()); + } + + let row = Styled::new_plain(" ") + .then(name, heading_style) + .then_plain(format!(" ({})", sessions.len())); + list_builder.add_unsel(Text::new(row).background()); + + for session in sessions { + render_row(list_builder, session, own_session, focused, nick_emoji); + } +} + +fn render_row( + list_builder: &mut ListBuilder<'_, SessionId, Background>, + session: &HalfSession, + own_session: &SessionView, + focused: bool, + nick_emoji: bool, +) { + let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() { + let name = "lurk".to_string(); + let style = Style::new().grey(); + let style_inv = Style::new().black().on_grey(); + (name, style, style_inv, style_inv) + } else { + let name = &session.name as &str; + let (r, g, b) = euph::nick_color(name); + let name = euph::EMOJI.replace(name).to_string(); + let color = Color::Rgb { r, g, b }; + let style = Style::new().bold().with(color); + let style_inv = Style::new().bold().black().on(color); + let perms_style_inv = Style::new().black().on(color); + (name, style, style_inv, perms_style_inv) + }; + + let perms = if session.is_staff { + "!" + } else if session.is_manager { + "*" + } else if session.id.session_type() == Some(SessionType::Account) { + "~" + } else { + "" + }; + + let owner = if session.session_id == own_session.session_id { + ">" + } else { + " " + }; + + let emoji = if nick_emoji { + format!(" ({})", euph::user_id_emoji(&session.id)) + } else { + "".to_string() + }; + + list_builder.add_sel(session.session_id.clone(), move |selected| { + if focused && selected { + let text = Styled::new_plain(owner) + .then(name, style_inv) + .then(perms, perms_style_inv) + .then(emoji, perms_style_inv); + Text::new(text).background().with_style(style_inv) + } else { + let text = Styled::new_plain(owner) + .then(&name, style) + .then_plain(perms) + .then_plain(emoji); + Text::new(text).background() + } + }); +} diff --git a/cove/src/ui/euph/popup.rs b/cove/src/ui/euph/popup.rs new file mode 100644 index 0000000..c434fb6 --- /dev/null +++ b/cove/src/ui/euph/popup.rs @@ -0,0 +1,40 @@ +use std::io; + +use crossterm::style::Stylize; +use toss::{Style, Styled, Widget, widgets::Text}; + +use crate::ui::{UiError, widgets::Popup}; + +pub enum RoomPopup { + Error { description: String, reason: String }, +} + +impl RoomPopup { + fn server_error_widget(description: &str, reason: &str) -> impl Widget + use<> { + let border_style = Style::new().red().bold(); + let text = Styled::new_plain(description) + .then_plain("\n\n") + .then("Reason:", Style::new().bold()) + .then_plain(" ") + .then_plain(reason); + + Popup::new(Text::new(text), ("Error", border_style)).with_border_style(border_style) + } + + pub fn widget(&self) -> impl Widget + use<> { + match self { + Self::Error { + description, + reason, + } => Self::server_error_widget(description, reason), + } + } +} + +pub enum PopupResult { + NotHandled, + Handled, + Close, + SwitchToRoom { name: String }, + ErrorOpeningLink { link: String, error: io::Error }, +} diff --git a/cove/src/ui/euph/room.rs b/cove/src/ui/euph/room.rs new file mode 100644 index 0000000..7e8ff99 --- /dev/null +++ b/cove/src/ui/euph/room.rs @@ -0,0 +1,708 @@ +use std::collections::VecDeque; + +use cove_config::{Config, Keys}; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use euphoxide::{ + api::{Data, Message, MessageId, PacketType, SessionId, packet::ParsedPacket}, + bot::instance::{ConnSnapshot, Event, ServerConfig}, + conn::{self, Joined, Joining, SessionInfo}, +}; +use jiff::tz::TimeZone; +use tokio::sync::{ + mpsc, + oneshot::{self, error::TryRecvError}, +}; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{BoxedAsync, EditorState, Join2, Layer, Text}, +}; + +use crate::{ + euph::{self, SpanType}, + macros::logging_unwrap, + ui::{ + UiError, UiEvent, + chat::{ChatState, Reaction}, + util, + widgets::ListState, + }, + vault::{EuphRoomVault, RoomIdentifier}, +}; + +use super::{ + account::AccountUiState, + auth, inspect, + links::LinksState, + nick, nick_list, + popup::{PopupResult, RoomPopup}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Chat, + NickList, +} + +#[allow(clippy::large_enum_variant)] +enum State { + Normal, + Auth(EditorState), + Nick(EditorState), + Account(AccountUiState), + Links(LinksState), + InspectMessage(Message), + InspectSession(SessionInfo), +} + +type EuphChatState = ChatState; + +pub struct EuphRoom { + config: &'static Config, + server_config: ServerConfig, + room_config: cove_config::EuphRoom, + ui_event_tx: mpsc::UnboundedSender, + + room: Option, + + focus: Focus, + state: State, + popups: VecDeque, + + chat: EuphChatState, + last_msg_sent: Option>, + + nick_list: ListState, + + mentioned: bool, +} + +impl EuphRoom { + pub fn new( + config: &'static Config, + server_config: ServerConfig, + room_config: cove_config::EuphRoom, + vault: EuphRoomVault, + tz: TimeZone, + ui_event_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + config, + server_config, + room_config, + ui_event_tx, + room: None, + focus: Focus::Chat, + state: State::Normal, + popups: VecDeque::new(), + chat: ChatState::new(vault, tz), + last_msg_sent: None, + nick_list: ListState::new(), + mentioned: false, + } + } + + fn vault(&self) -> &EuphRoomVault { + self.chat.store() + } + + fn domain(&self) -> &str { + &self.vault().room().domain + } + + fn name(&self) -> &str { + &self.vault().room().name + } + + pub fn connect(&mut self, next_instance_id: &mut usize) { + if self.room.is_none() { + let room = self.vault().room(); + let instance_config = self + .server_config + .clone() + .room(self.vault().room().name.clone()) + .name(format!("{room:?}-{next_instance_id}")) + .human(true) + .username(self.room_config.username.clone()) + .force_username(self.room_config.force_username) + .password(self.room_config.password.clone()); + *next_instance_id = next_instance_id.wrapping_add(1); + + let tx = self.ui_event_tx.clone(); + self.room = Some(euph::Room::new( + self.vault().clone(), + instance_config, + move |e| { + let _ = tx.send(UiEvent::Euph(e)); + }, + )); + } + } + + pub fn disconnect(&mut self) { + self.room = None; + } + + pub fn room_state(&self) -> Option<&euph::State> { + if let Some(room) = &self.room { + Some(room.state()) + } else { + None + } + } + + pub fn room_state_joined(&self) -> Option<&Joined> { + self.room_state().and_then(|s| s.joined()) + } + + pub fn stopped(&self) -> bool { + self.room.as_ref().map(|r| r.stopped()).unwrap_or(true) + } + + pub fn retain(&mut self) { + if let Some(room) = &self.room { + if room.stopped() { + self.room = None; + } + } + } + + pub fn retrieve_mentioned(&mut self) -> bool { + let mentioned = self.mentioned; + self.mentioned = false; + mentioned + } + + pub async fn unseen_msgs_count(&self) -> usize { + logging_unwrap!(self.vault().unseen_msgs_count().await) + } + + async fn stabilize_pseudo_msg(&mut self) { + if let Some(id_rx) = &mut self.last_msg_sent { + match id_rx.try_recv() { + Ok(id) => { + self.chat.send_successful(id); + self.last_msg_sent = None; + } + Err(TryRecvError::Empty) => {} // Wait a bit longer + Err(TryRecvError::Closed) => { + self.chat.send_failed(); + self.last_msg_sent = None; + } + } + } + } + + fn stabilize_focus(&mut self) { + if self.room_state_joined().is_none() { + self.focus = Focus::Chat; // There is no nick list to focus on + } + } + + fn stabilize_state(&mut self) { + let room_state = self.room.as_ref().map(|r| r.state()); + match (&mut self.state, room_state) { + ( + State::Auth(_), + Some(euph::State::Connected( + _, + conn::State::Joining(Joining { + bounce: Some(_), .. + }), + )), + ) => {} // Nothing to see here + (State::Auth(_), _) => self.state = State::Normal, + + (State::Nick(_), Some(euph::State::Connected(_, conn::State::Joined(_)))) => {} + (State::Nick(_), _) => self.state = State::Normal, + + (State::Account(account), state) => { + if !account.stabilize(state) { + self.state = State::Normal + } + } + + _ => {} + } + } + + async fn stabilize(&mut self) { + self.stabilize_pseudo_msg().await; + self.stabilize_focus(); + self.stabilize_state(); + } + + pub async fn widget(&mut self) -> BoxedAsync<'_, UiError> { + self.stabilize().await; + + let room_state = self.room.as_ref().map(|room| room.state()); + let status_widget = self.status_widget(room_state).await; + let chat = match room_state.and_then(|s| s.joined()) { + Some(joined) => Self::widget_with_nick_list( + &mut self.chat, + status_widget, + &mut self.nick_list, + joined, + self.focus, + ), + None => Self::widget_without_nick_list(&mut self.chat, status_widget), + }; + + let mut layers = vec![chat]; + + match &mut self.state { + State::Normal => {} + State::Auth(editor) => layers.push(auth::widget(editor).desync().boxed_async()), + State::Nick(editor) => layers.push(nick::widget(editor).desync().boxed_async()), + State::Account(account) => layers.push(account.widget().desync().boxed_async()), + State::Links(links) => layers.push(links.widget().desync().boxed_async()), + State::InspectMessage(message) => { + layers.push(inspect::message_widget(message).desync().boxed_async()) + } + State::InspectSession(session) => { + layers.push(inspect::session_widget(session).desync().boxed_async()) + } + } + + for popup in &self.popups { + layers.push(popup.widget().desync().boxed_async()); + } + + Layer::new(layers).boxed_async() + } + + fn widget_without_nick_list( + chat: &mut EuphChatState, + status_widget: impl Widget + Send + Sync + 'static, + ) -> BoxedAsync<'_, UiError> { + let chat_widget = chat.widget(String::new(), true); + + Join2::vertical( + status_widget.desync().segment().with_fixed(true), + chat_widget.segment(), + ) + .boxed_async() + } + + fn widget_with_nick_list<'a>( + chat: &'a mut EuphChatState, + status_widget: impl Widget + Send + Sync + 'static, + nick_list: &'a mut ListState, + joined: &Joined, + focus: Focus, + ) -> BoxedAsync<'a, UiError> { + let nick_list_widget = nick_list::widget( + nick_list, + joined, + focus == Focus::NickList, + chat.nick_emoji(), + ) + .padding() + .with_right(1) + .border() + .desync(); + + let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat); + + Join2::horizontal( + Join2::vertical( + status_widget.desync().segment().with_fixed(true), + chat_widget.segment(), + ) + .segment(), + nick_list_widget.segment().with_fixed(true), + ) + .boxed_async() + } + + async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget + use<> { + let room_style = Style::new().bold().blue(); + let mut info = Styled::new(format!("{} ", self.domain()), Style::new().grey()) + .then(format!("&{}", self.name()), room_style); + + info = match state { + None | Some(euph::State::Stopped) => info.then_plain(", archive"), + Some(euph::State::Disconnected) => info.then_plain(", waiting..."), + Some(euph::State::Connecting) => info.then_plain(", connecting..."), + Some(euph::State::Connected(_, conn::State::Joining(j))) if j.bounce.is_some() => { + info.then_plain(", auth required") + } + Some(euph::State::Connected(_, conn::State::Joining(_))) => { + info.then_plain(", joining...") + } + Some(euph::State::Connected(_, conn::State::Joined(j))) => { + let nick = &j.session.name; + if nick.is_empty() { + info.then_plain(", present without nick") + } else { + info.then_plain(", present as ") + .and_then(euph::style_nick(nick, Style::new())) + } + } + }; + + let unseen = self.unseen_msgs_count().await; + if unseen > 0 { + info = info + .then_plain(" (") + .then(format!("{unseen}"), Style::new().bold().green()) + .then_plain(")"); + } + + let title = if unseen > 0 { + format!("&{} ({unseen})", self.name()) + } else { + format!("&{}", self.name()) + }; + + Text::new(info) + .padding() + .with_horizontal(1) + .border() + .title(title) + } + + async fn handle_chat_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { + let can_compose = self.room_state_joined().is_some(); + + let reaction = self.chat.handle_input_event(event, keys, can_compose).await; + let reaction = logging_unwrap!(reaction); + + match reaction { + Reaction::NotHandled => {} + Reaction::Handled => return true, + Reaction::Composed { parent, content } => { + if let Some(room) = &self.room { + match room.send(parent, content) { + Ok(id_rx) => self.last_msg_sent = Some(id_rx), + Err(_) => self.chat.send_failed(), + } + return true; + } + } + } + + false + } + + async fn handle_room_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { + match self.room_state() { + // Authenticating + Some(euph::State::Connected( + _, + conn::State::Joining(Joining { + bounce: Some(_), .. + }), + )) => { + if event.matches(&keys.room.action.authenticate) { + self.state = State::Auth(auth::new()); + return true; + } + } + + // Joined + Some(euph::State::Connected(_, conn::State::Joined(joined))) => { + if event.matches(&keys.room.action.nick) { + self.state = State::Nick(nick::new(joined.clone())); + return true; + } + if event.matches(&keys.room.action.more_messages) { + if let Some(room) = &self.room { + let _ = room.log(); + } + return true; + } + if event.matches(&keys.room.action.account) { + self.state = State::Account(AccountUiState::new()); + return true; + } + } + + // Otherwise + _ => {} + } + + false + } + + async fn handle_chat_focus_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + ) -> bool { + // We need to handle chat input first, otherwise the other + // key bindings will shadow characters in the editor. + if self.handle_chat_input_event(event, keys).await { + return true; + } + + if self.handle_room_input_event(event, keys).await { + return true; + } + + if event.matches(&keys.tree.action.inspect) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) { + self.state = State::InspectMessage(msg); + } + } + return true; + } + + if event.matches(&keys.tree.action.links) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) { + self.state = State::Links(LinksState::new(self.config, &msg.content)); + } + } + return true; + } + + false + } + + fn handle_nick_list_focus_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + ) -> bool { + if util::handle_list_input_event(&mut self.nick_list, event, keys) { + return true; + } + + if event.matches(&keys.tree.action.inspect) { + if let Some(joined) = self.room_state_joined() { + if let Some(id) = self.nick_list.selected() { + if *id == joined.session.session_id { + self.state = + State::InspectSession(SessionInfo::Full(joined.session.clone())); + } else if let Some(session) = joined.listing.get(id) { + self.state = State::InspectSession(session.clone()); + } + } + } + return true; + } + + false + } + + async fn handle_normal_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { + match self.focus { + Focus::Chat => { + if self.handle_chat_focus_input_event(event, keys).await { + return true; + } + + if self.room_state_joined().is_some() && event.matches(&keys.general.focus) { + self.focus = Focus::NickList; + return true; + } + } + Focus::NickList => { + if event.matches(&keys.general.abort) || event.matches(&keys.general.focus) { + self.focus = Focus::Chat; + return true; + } + + if self.handle_nick_list_focus_input_event(event, keys) { + return true; + } + } + } + + false + } + + pub async fn handle_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + ) -> RoomResult { + if !self.popups.is_empty() { + if event.matches(&keys.general.abort) { + self.popups.pop_back(); + return RoomResult::Handled; + } + // Prevent event from reaching anything below the popup + return RoomResult::NotHandled; + } + + let result = match &mut self.state { + State::Normal => return self.handle_normal_input_event(event, keys).await.into(), + State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor), + State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor), + State::Account(account) => account.handle_input_event(event, keys, &self.room), + State::Links(links) => links.handle_input_event(event, keys), + State::InspectMessage(_) | State::InspectSession(_) => { + inspect::handle_input_event(event, keys) + } + }; + + match result { + PopupResult::NotHandled => RoomResult::NotHandled, + PopupResult::Handled => RoomResult::Handled, + PopupResult::Close => { + self.state = State::Normal; + RoomResult::Handled + } + PopupResult::SwitchToRoom { name } => RoomResult::SwitchToRoom { + room: RoomIdentifier { + domain: self.vault().room().domain.clone(), + name, + }, + }, + PopupResult::ErrorOpeningLink { link, error } => { + self.popups.push_front(RoomPopup::Error { + description: format!("Failed to open link: {link}"), + reason: format!("{error}"), + }); + RoomResult::Handled + } + } + } + + pub async fn handle_event(&mut self, event: Event) -> bool { + let Some(room) = &self.room else { return false }; + + if event.config().name != room.instance().config().name { + // If we allowed names other than the current one, old instances + // that haven't yet shut down properly could mess up our state. + return false; + } + + if let Event::Packet( + _, + ParsedPacket { + content: Ok(Data::SendEvent(send)), + .. + }, + ConnSnapshot { + state: conn::State::Joined(joined), + .. + }, + ) = &event + { + let normalized_name = euphoxide::nick::normalize(&joined.session.name); + let content = &*send.0.content; + for (rtype, rspan) in euph::find_spans(content) { + if rtype != SpanType::Mention { + continue; + } + let Some(mention) = content[rspan].strip_prefix('@') else { + continue; + }; + let normalized_mention = euphoxide::nick::normalize(mention); + if normalized_name == normalized_mention { + self.mentioned = true; + break; + } + } + } + + // We handle the packet internally first because the room event handling + // will consume it while we only need a reference. + let handled = if let Event::Packet(_, packet, _) = &event { + match &packet.content { + Ok(data) => self.handle_euph_data(data), + Err(reason) => self.handle_euph_error(packet.r#type, reason), + } + } else { + // The room state changes, which always means a redraw. + true + }; + + self.room + .as_mut() + // See check at the beginning of the function. + .expect("no room even though we checked earlier") + .handle_event(event) + .await; + + handled + } + + fn handle_euph_data(&mut self, data: &Data) -> bool { + // These packets don't result in any noticeable change in the UI. + #[allow(clippy::match_like_matches_macro)] + let handled = match data { + Data::PingEvent(_) | Data::PingReply(_) => { + // Pings are displayed nowhere in the room UI. + false + } + Data::DisconnectEvent(_) => { + // Followed by the server closing the connection, meaning that + // we'll get an `EuphRoomEvent::Disconnected` soon after this. + false + } + _ => true, + }; + + // Because the euphoria API is very carefully designed with emphasis on + // consistency, some failures are not normal errors but instead + // error-free replies that encode their own error. + let error = match data { + Data::AuthReply(reply) if !reply.success => { + Some(("authenticate", reply.reason.clone())) + } + Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason.clone())), + _ => None, + }; + if let Some((action, reason)) = error { + let description = format!("Failed to {action}."); + let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string()); + self.popups.push_front(RoomPopup::Error { + description, + reason, + }); + } + + handled + } + + fn handle_euph_error(&mut self, r#type: PacketType, reason: &str) -> bool { + let action = match r#type { + PacketType::AuthReply => "authenticate", + PacketType::NickReply => "set nick", + PacketType::PmInitiateReply => "initiate pm", + PacketType::SendReply => "send message", + PacketType::ChangeEmailReply => "change account email", + PacketType::ChangeNameReply => "change account name", + PacketType::ChangePasswordReply => "change account password", + PacketType::LoginReply => "log in", + PacketType::LogoutReply => "log out", + PacketType::RegisterAccountReply => "register account", + PacketType::ResendVerificationEmailReply => "resend verification email", + PacketType::ResetPasswordReply => "reset account password", + PacketType::BanReply => "ban", + PacketType::EditMessageReply => "edit message", + PacketType::GrantAccessReply => "grant room access", + PacketType::GrantManagerReply => "grant manager permissions", + PacketType::RevokeAccessReply => "revoke room access", + PacketType::RevokeManagerReply => "revoke manager permissions", + PacketType::UnbanReply => "unban", + _ => return false, + }; + let description = format!("Failed to {action}."); + self.popups.push_front(RoomPopup::Error { + description, + reason: reason.to_string(), + }); + true + } +} + +pub enum RoomResult { + NotHandled, + Handled, + SwitchToRoom { room: RoomIdentifier }, +} + +impl From for RoomResult { + fn from(value: bool) -> Self { + match value { + true => Self::Handled, + false => Self::NotHandled, + } + } +} diff --git a/cove/src/ui/key_bindings.rs b/cove/src/ui/key_bindings.rs new file mode 100644 index 0000000..daedc16 --- /dev/null +++ b/cove/src/ui/key_bindings.rs @@ -0,0 +1,126 @@ +//! A scrollable popup showing the current key bindings. + +use std::convert::Infallible; + +use cove_config::{Config, Keys}; +use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo}; +use crossterm::style::Stylize; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{Either2, Join2, Padding, Text}, +}; + +use super::{ + UiError, util, + widgets::{ListBuilder, ListState, Popup}, +}; + +type Line = Either2, Text>>; +type Builder = ListBuilder<'static, Infallible, Line>; + +pub fn format_binding(binding: &KeyBinding) -> Styled { + let style = Style::new().cyan(); + let mut keys = Styled::default(); + + for key in binding.keys() { + if !keys.text().is_empty() { + keys = keys.then_plain(", "); + } + keys = keys.then(key.to_string(), style); + } + + if keys.text().is_empty() { + keys = keys.then("unbound", style); + } + + keys +} + +fn render_empty(builder: &mut Builder) { + builder.add_unsel(Text::new("").first2()); +} + +fn render_title(builder: &mut Builder, title: &str) { + let style = Style::new().bold().magenta(); + builder.add_unsel(Text::new(Styled::new(title, style)).first2()); +} + +fn render_binding_info(builder: &mut Builder, binding_info: KeyBindingInfo<'_>) { + builder.add_unsel( + Join2::horizontal( + Text::new(binding_info.description) + .with_wrap(false) + .padding() + .with_right(2) + .with_stretch(true) + .segment(), + Text::new(format_binding(binding_info.binding)) + .with_wrap(false) + .segment() + .with_fixed(true), + ) + .second2(), + ) +} + +fn render_group_info(builder: &mut Builder, group_info: KeyGroupInfo<'_>) { + render_title(builder, group_info.description); + for binding_info in group_info.bindings { + render_binding_info(builder, binding_info); + } +} + +pub fn widget<'a>( + list: &'a mut ListState, + config: &Config, +) -> impl Widget + use<'a> { + let mut list_builder = ListBuilder::new(); + + for group_info in config.keys.groups() { + if !list_builder.is_empty() { + render_empty(&mut list_builder); + } + render_group_info(&mut list_builder, group_info); + } + + let scroll_info_style = Style::new().grey().italic(); + let scroll_info = Styled::new("(Scroll with ", scroll_info_style) + .and_then(format_binding(&config.keys.cursor.down)) + .then(" and ", scroll_info_style) + .and_then(format_binding(&config.keys.cursor.up)) + .then(")", scroll_info_style); + + let inner = Join2::vertical( + list_builder.build(list).segment(), + Text::new(scroll_info) + .float() + .with_center_h() + .segment() + .with_growing(false), + ); + + Popup::new(inner, "Key bindings") +} + +pub fn handle_input_event( + list: &mut ListState, + event: &mut InputEvent<'_>, + keys: &Keys, +) -> bool { + // To make scrolling with the mouse wheel work as expected + if event.matches(&keys.cursor.up) { + list.scroll_up(1); + return true; + } + if event.matches(&keys.cursor.down) { + list.scroll_down(1); + return true; + } + + // List movement must come later, or it shadows the cursor movement keys + if util::handle_list_input_event(list, event, keys) { + return true; + } + + false +} diff --git a/cove/src/ui/rooms.rs b/cove/src/ui/rooms.rs new file mode 100644 index 0000000..c3d6a40 --- /dev/null +++ b/cove/src/ui/rooms.rs @@ -0,0 +1,658 @@ +use std::{ + collections::{HashMap, HashSet, hash_map::Entry}, + iter, + sync::{Arc, Mutex}, + time::Duration, +}; + +use cove_config::{Config, Keys, RoomsSortOrder}; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use euphoxide::{ + api::SessionType, + bot::instance::{Event, ServerConfig}, + conn::{self, Joined}, +}; +use jiff::tz::TimeZone; +use tokio::sync::mpsc; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{BellState, BoxedAsync, Empty, Join2, Text}, +}; + +use crate::{ + euph, + macros::logging_unwrap, + vault::{EuphVault, RoomIdentifier, Vault}, + version::{NAME, VERSION}, +}; + +use super::{ + UiError, UiEvent, + euph::room::{EuphRoom, RoomResult}, + key_bindings, util, + widgets::{ListBuilder, ListState}, +}; + +use self::{ + connect::{ConnectResult, ConnectState}, + delete::{DeleteResult, DeleteState}, +}; + +mod connect; +mod delete; + +enum State { + ShowList, + ShowRoom(RoomIdentifier), + Connect(ConnectState), + Delete(DeleteState), +} + +#[derive(Clone, Copy)] +enum Order { + Alphabet, + Importance, +} + +impl Order { + fn from_rooms_sort_order(order: RoomsSortOrder) -> Self { + match order { + RoomsSortOrder::Alphabet => Self::Alphabet, + RoomsSortOrder::Importance => Self::Importance, + } + } +} + +struct EuphServer { + config: ServerConfig, + next_instance_id: usize, +} + +impl EuphServer { + async fn new(vault: &EuphVault, domain: String) -> Self { + let cookies = logging_unwrap!(vault.cookies(domain.clone()).await); + let config = ServerConfig::default() + .domain(domain) + .cookies(Arc::new(Mutex::new(cookies))) + .timeout(Duration::from_secs(10)); + + Self { + config, + next_instance_id: 0, + } + } +} + +pub struct Rooms { + config: &'static Config, + tz: TimeZone, + + vault: Vault, + ui_event_tx: mpsc::UnboundedSender, + + state: State, + + list: ListState, + order: Order, + bell: BellState, + + euph_servers: HashMap, + euph_rooms: HashMap, +} + +impl Rooms { + pub async fn new( + config: &'static Config, + tz: TimeZone, + vault: Vault, + ui_event_tx: mpsc::UnboundedSender, + ) -> Self { + let mut result = Self { + config, + tz, + vault, + ui_event_tx, + state: State::ShowList, + list: ListState::new(), + order: Order::from_rooms_sort_order(config.rooms_sort_order), + bell: BellState::new(), + euph_servers: HashMap::new(), + euph_rooms: HashMap::new(), + }; + + if !config.offline { + for (domain, server) in &config.euph.servers { + for (name, room) in &server.rooms { + if room.autojoin { + let id = RoomIdentifier::new(domain.clone(), name.clone()); + result.connect_to_room(id).await; + } + } + } + } + + result + } + + async fn get_or_insert_server<'a>( + vault: &Vault, + euph_servers: &'a mut HashMap, + domain: String, + ) -> &'a mut EuphServer { + match euph_servers.entry(domain.clone()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let server = EuphServer::new(&vault.euph(), domain).await; + entry.insert(server) + } + } + } + + async fn get_or_insert_room(&mut self, room: RoomIdentifier) -> &mut EuphRoom { + let server = + Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone()) + .await; + + self.euph_rooms.entry(room.clone()).or_insert_with(|| { + EuphRoom::new( + self.config, + server.config.clone(), + self.config.euph_room(&room.domain, &room.name), + self.vault.euph().room(room), + self.tz.clone(), + self.ui_event_tx.clone(), + ) + }) + } + + async fn connect_to_room(&mut self, room: RoomIdentifier) { + let server = + Self::get_or_insert_server(&self.vault, &mut self.euph_servers, room.domain.clone()) + .await; + + let room = self.euph_rooms.entry(room.clone()).or_insert_with(|| { + EuphRoom::new( + self.config, + server.config.clone(), + self.config.euph_room(&room.domain, &room.name), + self.vault.euph().room(room), + self.tz.clone(), + self.ui_event_tx.clone(), + ) + }); + + room.connect(&mut server.next_instance_id); + } + + async fn connect_to_all_rooms(&mut self) { + for (id, room) in &mut self.euph_rooms { + let server = + Self::get_or_insert_server(&self.vault, &mut self.euph_servers, id.domain.clone()) + .await; + + room.connect(&mut server.next_instance_id); + } + } + + fn disconnect_from_room(&mut self, room: &RoomIdentifier) { + if let Some(room) = self.euph_rooms.get_mut(room) { + room.disconnect(); + } + } + + fn disconnect_from_all_rooms(&mut self) { + for room in self.euph_rooms.values_mut() { + room.disconnect(); + } + } + + /// Remove rooms that are not running any more and can't be found in the db + /// or config. Insert rooms that are in the db or config but not yet in in + /// the hash map. + /// + /// These kinds of rooms are either + /// - failed connection attempts, or + /// - rooms that were deleted from the db. + async fn stabilize_rooms(&mut self) { + // Collect all rooms from the db and config file + let rooms_from_db = logging_unwrap!(self.vault.euph().rooms().await); + let rooms_from_config = self + .config + .euph + .servers + .iter() + .flat_map(|(domain, server)| { + server + .rooms + .keys() + .map(|name| RoomIdentifier::new(domain.clone(), name.clone())) + }); + let mut rooms_set = rooms_from_db + .into_iter() + .chain(rooms_from_config) + .collect::>(); + + // Prevent room that is currently being shown from being removed. This + // could otherwise happen after connecting to a room that doesn't exist. + if let State::ShowRoom(name) = &self.state { + rooms_set.insert(name.clone()); + } + + // Now `rooms_set` contains all rooms that must exist. Other rooms may + // also exist, for example rooms that are connecting for the first time. + + self.euph_rooms + .retain(|n, r| !r.stopped() || rooms_set.contains(n)); + + for room in rooms_set { + let room = self.get_or_insert_room(room).await; + room.retain(); + self.bell.ring |= room.retrieve_mentioned(); + } + } + + pub async fn widget(&mut self) -> BoxedAsync<'_, UiError> { + match &self.state { + State::ShowRoom(_) => {} + _ => self.stabilize_rooms().await, + } + + let widget = match &mut self.state { + State::ShowList => Self::rooms_widget( + &self.vault, + self.config, + &mut self.list, + self.order, + &self.euph_rooms, + ) + .await + .desync() + .boxed_async(), + + State::ShowRoom(id) => { + self.euph_rooms + .get_mut(id) + .expect("room exists after stabilization") + .widget() + .await + } + + State::Connect(connect) => Self::rooms_widget( + &self.vault, + self.config, + &mut self.list, + self.order, + &self.euph_rooms, + ) + .await + .below(connect.widget()) + .desync() + .boxed_async(), + + State::Delete(delete) => Self::rooms_widget( + &self.vault, + self.config, + &mut self.list, + self.order, + &self.euph_rooms, + ) + .await + .below(delete.widget()) + .desync() + .boxed_async(), + }; + + if self.config.bell_on_mention { + widget.above(self.bell.widget().desync()).boxed_async() + } else { + widget + } + } + + fn format_pbln(joined: &Joined) -> String { + let mut p = 0_usize; + let mut b = 0_usize; + let mut l = 0_usize; + let mut n = 0_usize; + + let sessions = joined + .listing + .values() + .map(|s| (s.id(), s.name())) + .chain(iter::once(( + &joined.session.id, + &joined.session.name as &str, + ))); + for (user_id, name) in sessions { + match user_id.session_type() { + Some(SessionType::Bot) if name.is_empty() => n += 1, + Some(SessionType::Bot) => b += 1, + _ if name.is_empty() => l += 1, + _ => p += 1, + } + } + + // There must always be either one p, b, l or n since we're including + // ourselves. + let mut result = vec![]; + if p > 0 { + result.push(format!("{p}p")); + } + if b > 0 { + result.push(format!("{b}b")); + } + if l > 0 { + result.push(format!("{l}l")); + } + if n > 0 { + result.push(format!("{n}n")); + } + result.join(" ") + } + + fn format_room_state(state: Option<&euph::State>) -> Option { + match state { + None | Some(euph::State::Stopped) => None, + Some(euph::State::Disconnected) => Some("waiting".to_string()), + Some(euph::State::Connecting) => Some("connecting".to_string()), + Some(euph::State::Connected(_, connected)) => match connected { + conn::State::Joining(joining) if joining.bounce.is_some() => { + Some("auth required".to_string()) + } + conn::State::Joining(_) => Some("joining".to_string()), + conn::State::Joined(joined) => Some(Self::format_pbln(joined)), + }, + } + } + + fn format_unseen_msgs(unseen: usize) -> Option { + if unseen == 0 { + None + } else { + Some(format!("{unseen}")) + } + } + + fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled { + let unseen_style = Style::new().bold().green(); + + let state = Self::format_room_state(state); + let unseen = Self::format_unseen_msgs(unseen); + + match (state, unseen) { + (None, None) => Styled::default(), + (None, Some(u)) => Styled::new_plain(" (") + .then(u, unseen_style) + .then_plain(")"), + (Some(s), None) => Styled::new_plain(" (").then_plain(s).then_plain(")"), + (Some(s), Some(u)) => Styled::new_plain(" (") + .then_plain(s) + .then_plain(", ") + .then(u, unseen_style) + .then_plain(")"), + } + } + + fn sort_rooms(rooms: &mut [(&RoomIdentifier, Option<&euph::State>, usize)], order: Order) { + match order { + Order::Alphabet => rooms.sort_unstable_by_key(|(id, _, _)| *id), + Order::Importance => rooms + .sort_unstable_by_key(|(id, state, unseen)| (state.is_none(), *unseen == 0, *id)), + } + } + + async fn render_rows( + list_builder: &mut ListBuilder<'_, RoomIdentifier, Text>, + order: Order, + euph_rooms: &HashMap, + ) { + let mut rooms = vec![]; + for (id, room) in euph_rooms { + let state = room.room_state(); + let unseen = room.unseen_msgs_count().await; + rooms.push((id, state, unseen)); + } + Self::sort_rooms(&mut rooms, order); + for (id, state, unseen) in rooms { + let id = id.clone(); + let info = Self::format_room_info(state, unseen); + list_builder.add_sel(id.clone(), move |selected| { + let domain_style = if selected { + Style::new().black().on_white() + } else { + Style::new().grey() + }; + + let room_style = if selected { + Style::new().bold().black().on_white() + } else { + Style::new().bold().blue() + }; + + let text = Styled::new(format!("{} ", id.domain), domain_style) + .then(format!("&{}", id.name), room_style) + .and_then(info); + + Text::new(text) + }); + } + } + + async fn rooms_widget<'a>( + vault: &Vault, + config: &Config, + list: &'a mut ListState, + order: Order, + euph_rooms: &HashMap, + ) -> impl Widget + use<'a> { + let version_info = Styled::new_plain("Welcome to ") + .then(format!("{NAME} {VERSION}"), Style::new().yellow().bold()) + .then_plain("!"); + let help_info = Styled::new("Press ", Style::new().grey()) + .and_then(key_bindings::format_binding(&config.keys.general.help)) + .then(" for key bindings.", Style::new().grey()); + let info = Join2::vertical( + Text::new(version_info).float().with_center_h().segment(), + Text::new(help_info).segment(), + ) + .padding() + .with_horizontal(1) + .border(); + + let mut heading = Styled::new("Rooms", Style::new().bold()); + let mut title = "Rooms".to_string(); + + let total_rooms = euph_rooms.len(); + let connected_rooms = euph_rooms + .iter() + .filter(|r| r.1.room_state().is_some()) + .count(); + let total_unseen = logging_unwrap!(vault.euph().total_unseen_msgs_count().await); + if total_unseen > 0 { + heading = heading + .then_plain(format!(" ({connected_rooms}/{total_rooms}, ")) + .then(format!("{total_unseen}"), Style::new().bold().green()) + .then_plain(")"); + title.push_str(&format!(" ({total_unseen})")); + } else { + heading = heading.then_plain(format!(" ({connected_rooms}/{total_rooms})")) + } + + let mut list_builder = ListBuilder::new(); + Self::render_rows(&mut list_builder, order, euph_rooms).await; + + Join2::horizontal( + Join2::vertical( + Text::new(heading).segment().with_fixed(true), + list_builder.build(list).segment(), + ) + .segment(), + Join2::vertical(info.segment().with_growing(false), Empty::new().segment()) + .segment() + .with_growing(false), + ) + .title(title) + } + + async fn handle_showlist_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + ) -> bool { + // Open room + if event.matches(&keys.general.confirm) { + if let Some(name) = self.list.selected() { + self.state = State::ShowRoom(name.clone()); + } + return true; + } + + // Move cursor and scroll + if util::handle_list_input_event(&mut self.list, event, keys) { + return true; + } + + // Room actions + if event.matches(&keys.rooms.action.connect) { + if let Some(name) = self.list.selected() { + self.connect_to_room(name.clone()).await; + } + return true; + } + if event.matches(&keys.rooms.action.connect_all) { + self.connect_to_all_rooms().await; + return true; + } + if event.matches(&keys.rooms.action.disconnect) { + if let Some(room) = self.list.selected() { + self.disconnect_from_room(&room.clone()); + } + return true; + } + if event.matches(&keys.rooms.action.disconnect_all) { + self.disconnect_from_all_rooms(); + return true; + } + if event.matches(&keys.rooms.action.connect_autojoin) { + for (domain, server) in &self.config.euph.servers { + for (name, room) in &server.rooms { + if !room.autojoin { + continue; + } + let id = RoomIdentifier::new(domain.clone(), name.clone()); + self.connect_to_room(id).await; + } + } + return true; + } + if event.matches(&keys.rooms.action.disconnect_non_autojoin) { + for (id, room) in &mut self.euph_rooms { + let autojoin = self.config.euph_room(&id.domain, &id.name).autojoin; + if !autojoin { + room.disconnect(); + } + } + return true; + } + if event.matches(&keys.rooms.action.new) { + self.state = State::Connect(ConnectState::new()); + return true; + } + if event.matches(&keys.rooms.action.delete) { + if let Some(room) = self.list.selected() { + self.state = State::Delete(DeleteState::new(room.clone())); + } + return true; + } + if event.matches(&keys.rooms.action.change_sort_order) { + self.order = match self.order { + Order::Alphabet => Order::Importance, + Order::Importance => Order::Alphabet, + }; + return true; + } + + false + } + + pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { + self.stabilize_rooms().await; + + match &mut self.state { + State::ShowList => { + if self.handle_showlist_input_event(event, keys).await { + return true; + } + } + State::ShowRoom(name) => { + if let Some(room) = self.euph_rooms.get_mut(name) { + match room.handle_input_event(event, keys).await { + RoomResult::NotHandled => {} + RoomResult::Handled => return true, + RoomResult::SwitchToRoom { room } => { + self.list.move_cursor_to_id(&room); + self.connect_to_room(room.clone()).await; + self.state = State::ShowRoom(room); + return true; + } + } + if event.matches(&keys.general.abort) { + self.state = State::ShowList; + return true; + } + } + } + State::Connect(connect) => match connect.handle_input_event(event, keys) { + ConnectResult::Close => { + self.state = State::ShowList; + return true; + } + ConnectResult::Connect(room) => { + self.list.move_cursor_to_id(&room); + self.connect_to_room(room.clone()).await; + self.state = State::ShowRoom(room); + return true; + } + ConnectResult::Handled => { + return true; + } + ConnectResult::Unhandled => {} + }, + State::Delete(delete) => match delete.handle_input_event(event, keys) { + DeleteResult::Close => { + self.state = State::ShowList; + return true; + } + DeleteResult::Delete(room) => { + self.euph_rooms.remove(&room); + logging_unwrap!(self.vault.euph().room(room).delete().await); + self.state = State::ShowList; + return true; + } + DeleteResult::Handled => { + return true; + } + DeleteResult::Unhandled => {} + }, + } + + false + } + + pub async fn handle_euph_event(&mut self, event: Event) -> bool { + let config = event.config(); + let room_id = RoomIdentifier::new(config.server.domain.clone(), config.room.clone()); + let Some(room) = self.euph_rooms.get_mut(&room_id) else { + return false; + }; + + let handled = room.handle_event(event).await; + + let room_visible = match &self.state { + State::ShowRoom(id) => *id == room_id, + _ => true, + }; + handled && room_visible + } +} diff --git a/cove/src/ui/rooms/connect.rs b/cove/src/ui/rooms/connect.rs new file mode 100644 index 0000000..83a359e --- /dev/null +++ b/cove/src/ui/rooms/connect.rs @@ -0,0 +1,123 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{EditorState, Empty, Join2, Join3, Text}, +}; + +use crate::{ + ui::{UiError, util, widgets::Popup}, + vault::RoomIdentifier, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Focus { + Name, + Domain, +} + +impl Focus { + fn advance(self) -> Self { + match self { + Self::Name => Self::Domain, + Self::Domain => Self::Name, + } + } +} + +pub struct ConnectState { + focus: Focus, + name: EditorState, + domain: EditorState, +} + +pub enum ConnectResult { + Close, + Connect(RoomIdentifier), + Handled, + Unhandled, +} + +impl ConnectState { + pub fn new() -> Self { + Self { + focus: Focus::Name, + name: EditorState::new(), + domain: EditorState::with_initial_text("euphoria.leet.nu".to_string()), + } + } + + pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> ConnectResult { + if event.matches(&keys.general.abort) { + return ConnectResult::Close; + } + + if event.matches(&keys.general.focus) { + self.focus = self.focus.advance(); + return ConnectResult::Handled; + } + + if event.matches(&keys.general.confirm) { + let id = RoomIdentifier { + domain: self.domain.text().to_string(), + name: self.name.text().to_string(), + }; + if !id.domain.is_empty() && !id.name.is_empty() { + return ConnectResult::Connect(id); + } + } + + let handled = match self.focus { + Focus::Name => { + util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char) + } + Focus::Domain => { + util::handle_editor_input_event(&mut self.domain, event, keys, |c| c != '\n') + } + }; + + if handled { + return ConnectResult::Handled; + } + + ConnectResult::Unhandled + } + + pub fn widget(&mut self) -> impl Widget { + let room_style = Style::new().bold().blue(); + let domain_style = Style::new().grey(); + + let name = Join2::horizontal( + Text::new(Styled::new_plain("Room: ").then("&", room_style)) + .with_wrap(false) + .segment() + .with_fixed(true), + self.name + .widget() + .with_highlight(|s| Styled::new(s, room_style)) + .with_focus(self.focus == Focus::Name) + .segment(), + ); + + let domain = Join3::horizontal( + Text::new("Domain:") + .with_wrap(false) + .segment() + .with_fixed(true), + Empty::new().with_width(1).segment().with_fixed(true), + self.domain + .widget() + .with_highlight(|s| Styled::new(s, domain_style)) + .with_focus(self.focus == Focus::Domain) + .segment(), + ); + + let inner = Join2::vertical( + name.segment().with_fixed(true), + domain.segment().with_fixed(true), + ); + + Popup::new(inner, "Connect to") + } +} diff --git a/cove/src/ui/rooms/delete.rs b/cove/src/ui/rooms/delete.rs new file mode 100644 index 0000000..baa96b1 --- /dev/null +++ b/cove/src/ui/rooms/delete.rs @@ -0,0 +1,90 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::style::Stylize; +use toss::{ + Style, Styled, Widget, WidgetExt, + widgets::{EditorState, Empty, Join2, Text}, +}; + +use crate::{ + ui::{UiError, util, widgets::Popup}, + vault::RoomIdentifier, +}; + +pub struct DeleteState { + id: RoomIdentifier, + name: EditorState, +} + +pub enum DeleteResult { + Close, + Delete(RoomIdentifier), + Handled, + Unhandled, +} + +impl DeleteState { + pub fn new(id: RoomIdentifier) -> Self { + Self { + id, + name: EditorState::new(), + } + } + + pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> DeleteResult { + if event.matches(&keys.general.abort) { + return DeleteResult::Close; + } + + if event.matches(&keys.general.confirm) && self.name.text() == self.id.name { + return DeleteResult::Delete(self.id.clone()); + } + + if util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char) { + return DeleteResult::Handled; + } + + DeleteResult::Unhandled + } + + pub fn widget(&mut self) -> impl Widget { + let warn_style = Style::new().bold().red(); + let room_style = Style::new().bold().blue(); + let text = Styled::new_plain("Are you sure you want to delete ") + .then("&", room_style) + .then(&self.id.name, room_style) + .then_plain(" on the ") + .then(&self.id.domain, Style::new().grey()) + .then_plain(" server?\n\n") + .then_plain("This will delete the entire room history from your vault. ") + .then_plain("To shrink your vault afterwards, run ") + .then("cove gc", Style::new().italic().grey()) + .then_plain(".\n\n") + .then_plain("To confirm the deletion, ") + .then_plain("enter the full name of the room and press enter:"); + + let inner = Join2::vertical( + // The Join prevents the text from filling up the entire available + // space if the editor is wider than the text. + Join2::horizontal( + Text::new(text) + .resize() + .with_max_width(54) + .segment() + .with_growing(false), + Empty::new().segment(), + ) + .segment(), + Join2::horizontal( + Text::new(("&", room_style)).segment().with_fixed(true), + self.name + .widget() + .with_highlight(|s| Styled::new(s, room_style)) + .segment(), + ) + .segment(), + ); + + Popup::new(inner, "Delete room").with_border_style(warn_style) + } +} diff --git a/cove/src/ui/util.rs b/cove/src/ui/util.rs new file mode 100644 index 0000000..b358588 --- /dev/null +++ b/cove/src/ui/util.rs @@ -0,0 +1,196 @@ +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::event::{KeyCode, KeyModifiers}; +use toss::widgets::EditorState; + +use super::widgets::ListState; + +/// Test if a character is allowed to be typed in a room name. +pub fn is_room_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +////////// +// List // +////////// + +pub fn handle_list_input_event( + list: &mut ListState, + event: &InputEvent<'_>, + keys: &Keys, +) -> bool { + // Cursor movement + if event.matches(&keys.cursor.up) { + list.move_cursor_up(); + return true; + } + if event.matches(&keys.cursor.down) { + list.move_cursor_down(); + return true; + } + if event.matches(&keys.cursor.to_top) { + list.move_cursor_to_top(); + return true; + } + if event.matches(&keys.cursor.to_bottom) { + list.move_cursor_to_bottom(); + return true; + } + + // Scrolling + if event.matches(&keys.scroll.up_line) { + list.scroll_up(1); + return true; + } + if event.matches(&keys.scroll.down_line) { + list.scroll_down(1); + return true; + } + if event.matches(&keys.scroll.up_half) { + list.scroll_up_half(); + return true; + } + if event.matches(&keys.scroll.down_half) { + list.scroll_down_half(); + return true; + } + if event.matches(&keys.scroll.up_full) { + list.scroll_up_full(); + return true; + } + if event.matches(&keys.scroll.down_full) { + list.scroll_down_full(); + return true; + } + if event.matches(&keys.scroll.center_cursor) { + list.center_cursor(); + return true; + } + + false +} + +//////////// +// Editor // +//////////// + +fn edit_externally( + editor: &mut EditorState, + event: &mut InputEvent<'_>, + char_filter: impl Fn(char) -> bool, +) { + let Ok(text) = event.prompt(editor.text()) else { + // Something went wrong during editing, let's abort the edit. + return; + }; + + if text.trim().is_empty() { + // The user likely wanted to abort the edit and has deleted the + // entire text (bar whitespace left over by some editors). + return; + } + + let text = text + .strip_suffix('\n') + .unwrap_or(&text) + .chars() + .filter(|c| char_filter(*c)) + .collect::(); + + editor.set_text(event.widthdb(), text); +} + +fn char_modifier(modifiers: KeyModifiers) -> bool { + modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT +} + +pub fn handle_editor_input_event( + editor: &mut EditorState, + event: &mut InputEvent<'_>, + keys: &Keys, + char_filter: impl Fn(char) -> bool, +) -> bool { + // Cursor movement + if event.matches(&keys.editor.cursor.left) { + editor.move_cursor_left(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.right) { + editor.move_cursor_right(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.left_word) { + editor.move_cursor_left_a_word(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.right_word) { + editor.move_cursor_right_a_word(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.start) { + editor.move_cursor_to_start_of_line(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.end) { + editor.move_cursor_to_end_of_line(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.up) { + editor.move_cursor_up(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.down) { + editor.move_cursor_down(event.widthdb()); + return true; + } + + // Editing + if event.matches(&keys.editor.action.backspace) { + editor.backspace(event.widthdb()); + return true; + } + if event.matches(&keys.editor.action.delete) { + editor.delete(); + return true; + } + if event.matches(&keys.editor.action.clear) { + editor.clear(); + return true; + } + if event.matches(&keys.editor.action.external) { + edit_externally(editor, event, char_filter); + return true; + } + + // Inserting individual characters + if let Some(key_event) = event.key_event() { + match key_event.code { + KeyCode::Enter if char_filter('\n') => { + editor.insert_char(event.widthdb(), '\n'); + return true; + } + KeyCode::Char(c) if char_modifier(key_event.modifiers) && char_filter(c) => { + editor.insert_char(event.widthdb(), c); + return true; + } + _ => {} + } + } + + // Pasting text + if let Some(text) = event.paste_event() { + // It seems that when pasting, '\n' are converted into '\r' for some + // reason. I don't really know why, or at what point this happens. Vim + // converts any '\r' pasted via the terminal into '\n', so I decided to + // mirror that behaviour. + let text = text + .chars() + .map(|c| if c == '\r' { '\n' } else { c }) + .filter(|c| char_filter(*c)) + .collect::(); + editor.insert_str(event.widthdb(), &text); + return true; + } + + false +} diff --git a/cove/src/ui/widgets.rs b/cove/src/ui/widgets.rs new file mode 100644 index 0000000..c00d26e --- /dev/null +++ b/cove/src/ui/widgets.rs @@ -0,0 +1,5 @@ +pub use self::list::*; +pub use self::popup::*; + +mod list; +mod popup; diff --git a/cove/src/ui/widgets/list.rs b/cove/src/ui/widgets/list.rs new file mode 100644 index 0000000..3d7c6c8 --- /dev/null +++ b/cove/src/ui/widgets/list.rs @@ -0,0 +1,361 @@ +use std::vec; + +use toss::{Frame, Pos, Size, Widget, WidthDb}; + +#[derive(Debug, Clone)] +struct Cursor { + /// Id of the element the cursor is pointing to. + /// + /// If the rows change (e.g. reorder) but there is still a row with this id, + /// the cursor is moved to this row. + id: Id, + + /// Index of the row the cursor is pointing to. + /// + /// If the rows change and there is no longer a row with the cursor's id, + /// the cursor is moved up or down to the next selectable row. This way, it + /// stays close to its previous position. + idx: usize, +} + +impl Cursor { + pub fn new(id: Id, idx: usize) -> Self { + Self { id, idx } + } +} + +#[derive(Debug)] +pub struct ListState { + /// Amount of lines that the list is scrolled, i.e. offset from the top. + offset: usize, + + /// A cursor within the list. + /// + /// Set to `None` if the list contains no selectable rows. + cursor: Option>, + + /// Height of the list when it was last rendered. + last_height: u16, + + /// Rows when the list was last rendered. + last_rows: Vec>, +} + +impl ListState { + pub fn new() -> Self { + Self { + offset: 0, + cursor: None, + last_height: 0, + last_rows: vec![], + } + } + + pub fn selected(&self) -> Option<&Id> { + self.cursor.as_ref().map(|cursor| &cursor.id) + } +} + +impl ListState { + fn first_selectable(&self) -> Option> { + self.last_rows + .iter() + .enumerate() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn last_selectable(&self) -> Option> { + self.last_rows + .iter() + .enumerate() + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_at_or_before_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .take(i + 1) + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_at_or_after_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .skip(i) + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_before_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .take(i) + .rev() + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn selectable_after_index(&self, i: usize) -> Option> { + self.last_rows + .iter() + .enumerate() + .skip(i + 1) + .find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i))) + } + + fn move_cursor_to_make_it_visible(&mut self) { + if let Some(cursor) = &self.cursor { + let first_visible_line_idx = self.offset; + let last_visible_line_idx = self + .offset + .saturating_add(self.last_height.into()) + .saturating_sub(1); + + let new_cursor = if cursor.idx < first_visible_line_idx { + self.selectable_at_or_after_index(first_visible_line_idx) + } else if cursor.idx > last_visible_line_idx { + self.selectable_at_or_before_index(last_visible_line_idx) + } else { + return; + }; + + if let Some(new_cursor) = new_cursor { + self.cursor = Some(new_cursor); + } + } + } + + fn scroll_so_cursor_is_visible(&mut self) { + if self.last_height == 0 { + // Cursor can't be visible because nothing is visible + return; + } + + if let Some(cursor) = &self.cursor { + // As long as height > 0, min <= max is true + let min = (cursor.idx + 1).saturating_sub(self.last_height.into()); + let max = cursor.idx; // Rows have a height of 1 + self.offset = self.offset.clamp(min, max); + } + } + + fn clamp_scrolling(&mut self) { + let min = 0; + let max = self.last_rows.len().saturating_sub(self.last_height.into()); + self.offset = self.offset.clamp(min, max); + } + + fn scroll_to(&mut self, new_offset: usize) { + self.offset = new_offset; + self.clamp_scrolling(); + self.move_cursor_to_make_it_visible(); + } + + fn move_cursor_to(&mut self, new_cursor: Cursor) { + self.cursor = Some(new_cursor); + self.scroll_so_cursor_is_visible(); + self.clamp_scrolling(); + } + + /// Scroll the list up by an amount of lines. + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_to(self.offset.saturating_sub(lines)); + } + + /// Scroll the list down by an amount of lines. + pub fn scroll_down(&mut self, lines: usize) { + self.scroll_to(self.offset.saturating_add(lines)); + } + + pub fn scroll_up_half(&mut self) { + self.scroll_up((self.last_height / 2).into()); + } + + pub fn scroll_down_half(&mut self) { + self.scroll_down((self.last_height / 2).into()); + } + + pub fn scroll_up_full(&mut self) { + self.scroll_up(self.last_height.saturating_sub(1).into()); + } + + pub fn scroll_down_full(&mut self) { + self.scroll_down(self.last_height.saturating_sub(1).into()); + } + + /// Scroll so that the cursor is in the center of the widget, or at least as + /// close as possible. + pub fn center_cursor(&mut self) { + if let Some(cursor) = &self.cursor { + let height: usize = self.last_height.into(); + self.scroll_to(cursor.idx.saturating_sub(height / 2)); + } + } + + /// Move the cursor up to the next selectable row. + pub fn move_cursor_up(&mut self) { + if let Some(cursor) = &self.cursor { + if let Some(new_cursor) = self.selectable_before_index(cursor.idx) { + self.move_cursor_to(new_cursor); + } + } + } + + /// Move the cursor down to the next selectable row. + pub fn move_cursor_down(&mut self) { + if let Some(cursor) = &self.cursor { + if let Some(new_cursor) = self.selectable_after_index(cursor.idx) { + self.move_cursor_to(new_cursor); + } + } + } + + /// Move the cursor to the first selectable row. + pub fn move_cursor_to_top(&mut self) { + if let Some(new_cursor) = self.first_selectable() { + self.move_cursor_to(new_cursor); + } + } + + /// Move the cursor to the last selectable row. + pub fn move_cursor_to_bottom(&mut self) { + if let Some(new_cursor) = self.last_selectable() { + self.move_cursor_to(new_cursor); + } + } +} + +impl ListState { + fn selectable_of_id(&self, id: &Id) -> Option> { + self.last_rows + .iter() + .enumerate() + .find_map(|(i, row)| match row { + Some(rid) if rid == id => Some(Cursor::new(rid.clone(), i)), + _ => None, + }) + } + + pub fn move_cursor_to_id(&mut self, id: &Id) { + if let Some(new_cursor) = self.selectable_of_id(id) { + self.move_cursor_to(new_cursor); + } + } + + fn fix_cursor(&mut self) { + let new_cursor = if let Some(cursor) = &self.cursor { + self.selectable_of_id(&cursor.id) + .or_else(|| self.selectable_at_or_before_index(cursor.idx)) + .or_else(|| self.selectable_at_or_after_index(cursor.idx)) + } else { + self.first_selectable() + }; + + if let Some(new_cursor) = new_cursor { + self.move_cursor_to(new_cursor); + } else { + self.cursor = None; + } + } +} + +struct UnrenderedRow<'a, Id, W> { + id: Option, + widget: Box W + 'a>, +} + +pub struct ListBuilder<'a, Id, W> { + rows: Vec>, +} + +impl<'a, Id, W> ListBuilder<'a, Id, W> { + pub fn new() -> Self { + Self { rows: vec![] } + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn add_unsel(&mut self, widget: W) + where + W: 'a, + { + self.rows.push(UnrenderedRow { + id: None, + widget: Box::new(|_| widget), + }); + } + + pub fn add_sel(&mut self, id: Id, widget: impl FnOnce(bool) -> W + 'a) { + self.rows.push(UnrenderedRow { + id: Some(id), + widget: Box::new(widget), + }); + } + + pub fn build(self, state: &mut ListState) -> List<'_, Id, W> + where + Id: Clone + Eq, + { + state.last_rows = self.rows.iter().map(|row| row.id.clone()).collect(); + state.fix_cursor(); + + let selected = state.selected(); + let rows = self + .rows + .into_iter() + .map(|row| (row.widget)(row.id.as_ref() == selected)) + .collect(); + List { state, rows } + } +} + +pub struct List<'a, Id, W> { + state: &'a mut ListState, + rows: Vec, +} + +impl Widget for List<'_, Id, W> +where + Id: Clone + Eq, + W: Widget, +{ + fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + _max_height: Option, + ) -> Result { + let mut width = 0; + for row in &self.rows { + let size = row.size(widthdb, max_width, Some(1))?; + width = width.max(size.width); + } + let height = self.rows.len().try_into().unwrap_or(u16::MAX); + Ok(Size::new(width, height)) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + let size = frame.size(); + + self.state.last_height = size.height; + + for (y, row) in self + .rows + .into_iter() + .skip(self.state.offset) + .take(size.height.into()) + .enumerate() + { + frame.push(Pos::new(0, y as i32), Size::new(size.width, 1)); + row.draw(frame)?; + frame.pop(); + } + + Ok(()) + } +} diff --git a/cove/src/ui/widgets/popup.rs b/cove/src/ui/widgets/popup.rs new file mode 100644 index 0000000..559e283 --- /dev/null +++ b/cove/src/ui/widgets/popup.rs @@ -0,0 +1,54 @@ +use toss::{ + Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb, + widgets::{Background, Border, Desync, Float, Layer2, Padding, Text}, +}; + +type Body = Background>>; +type Title = Float>>>; + +pub struct Popup(Float, Desync>>); + +impl<I> Popup<I> { + pub fn new<S: Into<Styled>>(inner: I, title: S) -> Self { + let title = Text::new(title) + .padding() + .with_horizontal(1) + // The background displaces the border without affecting the style + .background() + .with_style(Style::new()) + .padding() + .with_horizontal(2) + .float() + .with_top() + .with_left() + .desync(); + + let body = inner.padding().with_horizontal(1).border().background(); + + Self(title.above(body).float().with_center()) + } + + pub fn with_border_style(mut self, style: Style) -> Self { + let border = &mut self.0.inner.first.inner; + border.style = style; + self + } +} + +impl<E, I> Widget<E> for Popup<I> +where + I: Widget<E>, +{ + fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option<u16>, + max_height: Option<u16>, + ) -> Result<Size, E> { + self.0.size(widthdb, max_width, max_height) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + self.0.draw(frame) + } +} diff --git a/cove/src/util.rs b/cove/src/util.rs new file mode 100644 index 0000000..c6a572c --- /dev/null +++ b/cove/src/util.rs @@ -0,0 +1,70 @@ +use std::{convert::Infallible, env}; + +use jiff::tz::TimeZone; + +pub trait InfallibleExt { + type Inner; + + fn infallible(self) -> Self::Inner; +} + +impl<T> InfallibleExt for Result<T, Infallible> { + type Inner = T; + + fn infallible(self) -> T { + self.expect("infallible") + } +} + +/// Load a [`TimeZone`] specified by the `TZ` environment varible, or by the +/// provided string if the environment variable does not exist. +/// +/// If a string is provided, it is interpreted in the same format that the `TZ` +/// environment variable uses. +/// +/// If no `TZ` environment variable could be found and no string is provided, +/// the system local time (or UTC on Windows) is used. +pub fn load_time_zone(tz_string: Option<&str>) -> Result<TimeZone, jiff::Error> { + let env_string = env::var("TZ").ok(); + let tz_string = env_string.as_ref().map(|s| s as &str).or(tz_string); + + let Some(tz_string) = tz_string else { + return Ok(TimeZone::system()); + }; + + if tz_string == "localtime" { + return Ok(TimeZone::system()); + } + + if let Some(tz_string) = tz_string.strip_prefix(':') { + return TimeZone::get(tz_string); + } + + // The time zone is either a manually specified string or a file in the tz + // database. We'll try to parse it as a manually specified string first + // because that doesn't require a fs lookup. + if let Ok(tz) = TimeZone::posix(tz_string) { + return Ok(tz); + } + + TimeZone::get(tz_string) +} + +pub fn caesar(text: &str, by: i8) -> String { + let by = by.rem_euclid(26) as u8; + text.chars() + .map(|c| { + if c.is_ascii_lowercase() { + let c = c as u8 - b'a'; + let c = (c + by) % 26; + (c + b'a') as char + } else if c.is_ascii_uppercase() { + let c = c as u8 - b'A'; + let c = (c + by) % 26; + (c + b'A') as char + } else { + c + } + }) + .collect() +} diff --git a/src/vault.rs b/cove/src/vault.rs similarity index 84% rename from src/vault.rs rename to cove/src/vault.rs index 4f49e45..05bd1a5 100644 --- a/src/vault.rs +++ b/cove/src/vault.rs @@ -1,16 +1,14 @@ +use std::{fs, path::Path}; + +use rusqlite::Connection; +use vault::{Action, tokio::TokioVault}; + +pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier}; + mod euph; mod migrate; mod prepare; -use std::fs; -use std::path::Path; - -use rusqlite::Connection; -use vault::tokio::TokioVault; -use vault::Action; - -pub use self::euph::{EuphRoomVault, EuphVault}; - #[derive(Debug, Clone)] pub struct Vault { tokio_vault: TokioVault, @@ -20,9 +18,10 @@ pub struct Vault { struct GcAction; impl Action for GcAction { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.execute_batch("ANALYZE; VACUUM;") } } @@ -36,7 +35,7 @@ impl Vault { self.tokio_vault.stop().await; } - pub async fn gc(&self) -> vault::tokio::Result<()> { + pub async fn gc(&self) -> Result<(), vault::tokio::Error<rusqlite::Error>> { self.tokio_vault.execute(GcAction).await } @@ -49,8 +48,6 @@ fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result conn.pragma_update(None, "foreign_keys", true)?; conn.pragma_update(None, "trusted_schema", false)?; - eprintln!("Opening vault"); - let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?; Ok(Vault { tokio_vault, diff --git a/src/vault/euph.rs b/cove/src/vault/euph.rs similarity index 55% rename from src/vault/euph.rs rename to cove/src/vault/euph.rs index ff32754..4a4109e 100644 --- a/src/vault/euph.rs +++ b/cove/src/vault/euph.rs @@ -1,27 +1,25 @@ -use std::mem; -use std::str::FromStr; +use std::{fmt, mem, str::FromStr}; use async_trait::async_trait; use cookie::{Cookie, CookieJar}; use euphoxide::api::{Message, MessageId, SessionId, SessionView, Snowflake, Time, UserId}; -use rusqlite::types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef}; -use rusqlite::{named_params, params, Connection, OptionalExtension, ToSql, Transaction}; -use time::OffsetDateTime; +use rusqlite::{ + Connection, OptionalExtension, Row, ToSql, Transaction, named_params, params, + types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef}, +}; use vault::Action; -use crate::euph::SmallMessage; -use crate::store::{MsgStore, Path, Tree}; - -/////////////////// -// Wrapper types // -/////////////////// +use crate::{ + euph::SmallMessage, + store::{MsgStore, Path, Tree}, +}; /// Wrapper for [`Snowflake`] that implements useful rusqlite traits. struct WSnowflake(Snowflake); impl ToSql for WSnowflake { fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> { - self.0 .0.to_sql() + self.0.0.to_sql() } } @@ -36,7 +34,7 @@ struct WTime(Time); impl ToSql for WTime { fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> { - let timestamp = self.0 .0.unix_timestamp(); + let timestamp = self.0.0; Ok(ToSqlOutput::Owned(Value::Integer(timestamp))) } } @@ -44,9 +42,25 @@ impl ToSql for WTime { impl FromSql for WTime { fn column_result(value: ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> { let timestamp = i64::column_result(value)?; - Ok(Self(Time( - OffsetDateTime::from_unix_timestamp(timestamp).expect("timestamp in range"), - ))) + Ok(Self(Time(timestamp))) + } +} + +#[derive(Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct RoomIdentifier { + pub domain: String, + pub name: String, +} + +impl fmt::Debug for RoomIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "&{}@{}", self.name, self.domain) + } +} + +impl RoomIdentifier { + pub fn new(domain: String, name: String) -> Self { + Self { domain, name } } } @@ -68,10 +82,10 @@ impl EuphVault { &self.vault } - pub fn room(&self, name: String) -> EuphRoomVault { + pub fn room(&self, room: RoomIdentifier) -> EuphRoomVault { EuphRoomVault { vault: self.clone(), - room: name, + room, } } } @@ -88,7 +102,7 @@ macro_rules! euph_vault_actions { impl EuphVault { $( - pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> vault::tokio::Result<$res> { + pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> Result<$res, vault::tokio::Error<rusqlite::Error>> { self.vault.tokio_vault.execute($struct { $( $arg, )* }).await } )* @@ -97,23 +111,27 @@ macro_rules! euph_vault_actions { } euph_vault_actions! { - GetCookies : cookies() -> CookieJar; - SetCookies : set_cookies(cookies: CookieJar) -> (); - GetRooms : rooms() -> Vec<String>; + GetCookies : cookies(domain: String) -> CookieJar; + SetCookies : set_cookies(domain: String, cookies: CookieJar) -> (); + ClearCookies : clear_cookies(domain: Option<String>) -> (); + GetRooms : rooms() -> Vec<RoomIdentifier>; + GetTotalUnseenMsgsCount : total_unseen_msgs_count() -> usize; } impl Action for GetCookies { - type Result = CookieJar; + type Output = CookieJar; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let cookies = conn .prepare( " SELECT cookie FROM euph_cookies + WHERE domain = ? ", )? - .query_map([], |row| { + .query_map([self.domain], |row| { let cookie_str: String = row.get(0)?; Ok(Cookie::from_str(&cookie_str).expect("cookie in db is valid")) })? @@ -128,23 +146,29 @@ impl Action for GetCookies { } impl Action for SetCookies { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let tx = conn.transaction()?; // Since euphoria sets all cookies on every response, we can just delete // all previous cookies. - tx.execute_batch("DELETE FROM euph_cookies")?; + tx.execute( + " + DELETE FROM euph_cookies + WHERE domain = ?", + [&self.domain], + )?; let mut insert_cookie = tx.prepare( " - INSERT INTO euph_cookies (cookie) - VALUES (?) + INSERT INTO euph_cookies (domain, cookie) + VALUES (?, ?) ", )?; for cookie in self.cookies.iter() { - insert_cookie.execute([format!("{cookie}")])?; + insert_cookie.execute(params![self.domain, format!("{cookie}")])?; } drop(insert_cookie); @@ -153,21 +177,57 @@ impl Action for SetCookies { } } -impl Action for GetRooms { - type Result = Vec<String>; +impl Action for ClearCookies { + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + if let Some(domain) = self.domain { + conn.execute("DELETE FROM euph_cookies WHERE domain = ?", [domain])?; + } else { + conn.execute_batch("DELETE FROM euph_cookies")?; + } + + Ok(()) + } +} + +impl Action for GetRooms { + type Output = Vec<RoomIdentifier>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.prepare( " - SELECT room + SELECT domain, room FROM euph_rooms ", )? - .query_map([], |row| row.get(0))? + .query_map([], |row| { + Ok(RoomIdentifier { + domain: row.get(0)?, + name: row.get(1)?, + }) + })? .collect::<rusqlite::Result<_>>() } } +impl Action for GetTotalUnseenMsgsCount { + type Output = usize; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.prepare( + " + SELECT COALESCE(SUM(amount), 0) + FROM euph_unseen_counts + ", + )? + .query_row([], |row| row.get(0)) + } +} + /////////////////// // EuphRoomVault // /////////////////// @@ -175,7 +235,7 @@ impl Action for GetRooms { #[derive(Debug, Clone)] pub struct EuphRoomVault { vault: EuphVault, - room: String, + room: RoomIdentifier, } impl EuphRoomVault { @@ -183,7 +243,7 @@ impl EuphRoomVault { &self.vault } - pub fn room(&self) -> &str { + pub fn room(&self) -> &RoomIdentifier { &self.room } } @@ -194,14 +254,14 @@ macro_rules! euph_room_vault_actions { )* ) => { $( struct $struct { - room: String, + room: RoomIdentifier, $( $arg: $arg_ty, )* } )* impl EuphRoomVault { $( - pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> vault::tokio::Result<$res> { + pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> Result<$res, vault::tokio::Error<rusqlite::Error>> { self.vault.vault.tokio_vault.execute($struct { room: self.room.clone(), $( $arg, )* @@ -240,36 +300,43 @@ euph_room_vault_actions! { GetUnseenMsgsCount : unseen_msgs_count() -> usize; SetSeen : set_seen(id: MessageId, seen: bool) -> (); SetOlderSeen : set_older_seen(id: MessageId, seen: bool) -> (); - GetChunkAtOffset : chunk_at_offset(amount: usize, offset: usize) -> Vec<Message>; + GetChunkAfter : chunk_after(id: Option<MessageId>, amount: usize) -> Vec<Message>; } impl Action for Join { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.execute( " - INSERT INTO euph_rooms (room, first_joined, last_joined) - VALUES (:room, :time, :time) - ON CONFLICT (room) DO UPDATE + INSERT INTO euph_rooms (domain, room, first_joined, last_joined) + VALUES (:domain, :room, :time, :time) + ON CONFLICT (domain, room) DO UPDATE SET last_joined = :time ", - named_params! {":room": self.room, ":time": WTime(self.time)}, + named_params! { + ":domain": self.room.domain, + ":room": self.room.name, + ":time": WTime(self.time), + }, )?; Ok(()) } } impl Action for Delete { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.execute( " DELETE FROM euph_rooms - WHERE room = ? + WHERE domain = ? + AND room = ? ", - [&self.room], + [&self.room.domain, &self.room.name], )?; Ok(()) } @@ -277,29 +344,33 @@ impl Action for Delete { fn insert_msgs( tx: &Transaction<'_>, - room: &str, + room: &RoomIdentifier, own_user_id: &Option<UserId>, msgs: Vec<Message>, ) -> rusqlite::Result<()> { let mut insert_msg = tx.prepare( " INSERT INTO euph_msgs ( - room, id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, + domain, room, + id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address, seen ) VALUES ( - :room, :id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated, + :domain, :room, + :id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated, :user_id, :name, :server_id, :server_era, :session_id, :is_staff, :is_manager, :client_address, :real_client_address, (:user_id == :own_user_id OR EXISTS( SELECT 1 FROM euph_rooms - WHERE room = :room + WHERE domain = :domain + AND room = :room AND :time < first_joined )) ) - ON CONFLICT (room, id) DO UPDATE + ON CONFLICT (domain, room, id) DO UPDATE SET + domain = :domain, room = :room, id = :id, parent = :parent, @@ -326,7 +397,8 @@ fn insert_msgs( let own_user_id = own_user_id.as_ref().map(|u| &u.0); for msg in msgs { insert_msg.execute(named_params! { - ":room": room, + ":domain": room.domain, + ":room": room.name, ":id": WSnowflake(msg.id.0), ":parent": msg.parent.map(|id| WSnowflake(id.0)), ":previous_edit_id": msg.previous_edit_id.map(WSnowflake), @@ -354,7 +426,7 @@ fn insert_msgs( fn add_span( tx: &Transaction<'_>, - room: &str, + room: &RoomIdentifier, start: Option<MessageId>, end: Option<MessageId>, ) -> rusqlite::Result<()> { @@ -364,10 +436,11 @@ fn add_span( " SELECT start, end FROM euph_spans - WHERE room = ? + WHERE domain = ? + AND room = ? ", )? - .query_map([room], |row| { + .query_map([&room.domain, &room.name], |row| { let start = row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0)); let end = row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)); Ok((start, end)) @@ -407,21 +480,23 @@ fn add_span( tx.execute( " DELETE FROM euph_spans - WHERE room = ? + WHERE domain = ? + AND room = ? ", - [room], + [&room.domain, &room.name], )?; // Re-insert combined spans for the room let mut stmt = tx.prepare( " - INSERT INTO euph_spans (room, start, end) - VALUES (?, ?, ?) + INSERT INTO euph_spans (domain, room, start, end) + VALUES (?, ?, ?, ?) ", )?; for (start, end) in result { stmt.execute(params![ - room, + room.domain, + room.name, start.map(|id| WSnowflake(id.0)), end.map(|id| WSnowflake(id.0)) ])?; @@ -431,9 +506,10 @@ fn add_span( } impl Action for AddMsg { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let tx = conn.transaction()?; let end = self.msg.id; @@ -446,9 +522,10 @@ impl Action for AddMsg { } impl Action for AddMsgs { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let tx = conn.transaction()?; if self.msgs.is_empty() { @@ -469,20 +546,22 @@ impl Action for AddMsgs { } impl Action for GetLastSpan { - type Result = Option<(Option<MessageId>, Option<MessageId>)>; + type Output = Option<(Option<MessageId>, Option<MessageId>)>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let span = conn .prepare( " SELECT start, end FROM euph_spans - WHERE room = ? + WHERE domain = ? + AND room = ? ORDER BY start DESC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { Ok(( row.get::<_, Option<WSnowflake>>(0)?.map(|s| MessageId(s.0)), row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), @@ -494,19 +573,20 @@ impl Action for GetLastSpan { } impl Action for GetPath { - type Result = Path<MessageId>; + type Output = Path<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let path = conn .prepare( " WITH RECURSIVE - path (room, id) AS ( - VALUES (?, ?) + path (domain, room, id) AS ( + VALUES (?, ?, ?) UNION - SELECT room, parent + SELECT domain, room, parent FROM euph_msgs - JOIN path USING (room, id) + JOIN path USING (domain, room, id) ) SELECT id FROM path @@ -514,35 +594,39 @@ impl Action for GetPath { ORDER BY id ASC ", )? - .query_map(params![self.room, WSnowflake(self.id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - })? + .query_map( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + )? .collect::<rusqlite::Result<_>>()?; Ok(Path::new(path)) } } impl Action for GetMsg { - type Result = Option<SmallMessage>; + type Output = Option<SmallMessage>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg = conn .query_row( " - SELECT id, parent, time, name, content, seen + SELECT id, parent, time, user_id, name, content, seen FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND id = ? ", - params![self.room, WSnowflake(self.id.0)], + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], |row| { Ok(SmallMessage { id: MessageId(row.get::<_, WSnowflake>(0)?.0), parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), time: row.get::<_, WTime>(2)?.0, - nick: row.get(3)?, - content: row.get(4)?, - seen: row.get(5)?, + user_id: UserId(row.get(3)?), + nick: row.get(4)?, + content: row.get(5)?, + seen: row.get(6)?, }) }, ) @@ -552,103 +636,116 @@ impl Action for GetMsg { } impl Action for GetFullMsg { - type Result = Option<Message>; + type Output = Option<Message>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let mut query = conn.prepare( " SELECT id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND id = ? " )?; let msg = query - .query_row(params![self.room, WSnowflake(self.id.0)], |row| { - Ok(Message { - id: MessageId(row.get::<_, WSnowflake>(0)?.0), - parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), - previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0), - time: row.get::<_, WTime>(3)?.0, - content: row.get(4)?, - encryption_key_id: row.get(5)?, - edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0), - deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0), - truncated: row.get(8)?, - sender: SessionView { - id: UserId(row.get(9)?), - name: row.get(10)?, - server_id: row.get(11)?, - server_era: row.get(12)?, - session_id: SessionId(row.get(13)?), - is_staff: row.get(14)?, - is_manager: row.get(15)?, - client_address: row.get(16)?, - real_client_address: row.get(17)?, - }, - }) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| { + Ok(Message { + id: MessageId(row.get::<_, WSnowflake>(0)?.0), + parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), + previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0), + time: row.get::<_, WTime>(3)?.0, + content: row.get(4)?, + encryption_key_id: row.get(5)?, + edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0), + deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0), + truncated: row.get(8)?, + sender: SessionView { + id: UserId(row.get(9)?), + name: row.get(10)?, + server_id: row.get(11)?, + server_era: row.get(12)?, + session_id: SessionId(row.get(13)?), + is_staff: row.get(14)?, + is_manager: row.get(15)?, + client_address: row.get(16)?, + real_client_address: row.get(17)?, + }, + }) + }, + ) .optional()?; Ok(msg) } } impl Action for GetTree { - type Result = Tree<SmallMessage>; + type Output = Tree<SmallMessage>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msgs = conn .prepare( " WITH RECURSIVE - tree (room, id) AS ( - VALUES (?, ?) + tree (domain, room, id) AS ( + VALUES (?, ?, ?) UNION - SELECT euph_msgs.room, euph_msgs.id + SELECT euph_msgs.domain, euph_msgs.room, euph_msgs.id FROM euph_msgs JOIN tree - ON tree.room = euph_msgs.room + ON tree.domain = euph_msgs.domain + AND tree.room = euph_msgs.room AND tree.id = euph_msgs.parent ) - SELECT id, parent, time, name, content, seen + SELECT id, parent, time, user_id, name, content, seen FROM euph_msgs - JOIN tree USING (room, id) + JOIN tree USING (domain, room, id) ORDER BY id ASC ", )? - .query_map(params![self.room, WSnowflake(self.root_id.0)], |row| { - Ok(SmallMessage { - id: MessageId(row.get::<_, WSnowflake>(0)?.0), - parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), - time: row.get::<_, WTime>(2)?.0, - nick: row.get(3)?, - content: row.get(4)?, - seen: row.get(5)?, - }) - })? + .query_map( + params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)], + |row| { + Ok(SmallMessage { + id: MessageId(row.get::<_, WSnowflake>(0)?.0), + parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), + time: row.get::<_, WTime>(2)?.0, + user_id: UserId(row.get(3)?), + nick: row.get(4)?, + content: row.get(5)?, + seen: row.get(6)?, + }) + }, + )? .collect::<rusqlite::Result<_>>()?; Ok(Tree::new(self.root_id, msgs)) } } impl Action for GetFirstRootId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let root_id = conn .prepare( " SELECT id FROM euph_trees - WHERE room = ? + WHERE domain = ? + AND room = ? ORDER BY id ASC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -657,20 +754,22 @@ impl Action for GetFirstRootId { } impl Action for GetLastRootId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let root_id = conn .prepare( " SELECT id FROM euph_trees - WHERE room = ? + WHERE domain = ? + AND room = ? ORDER BY id DESC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -679,66 +778,74 @@ impl Action for GetLastRootId { } impl Action for GetPrevRootId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let root_id = conn .prepare( " SELECT id FROM euph_trees - WHERE room = ? + WHERE domain = ? + AND room = ? AND id < ? ORDER BY id DESC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.root_id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(root_id) } } impl Action for GetNextRootId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let root_id = conn .prepare( " SELECT id FROM euph_trees - WHERE room = ? + WHERE domain = ? + AND room = ? AND id > ? ORDER BY id ASC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.root_id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.root_id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(root_id) } } impl Action for GetOldestMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? ORDER BY id ASC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -747,20 +854,22 @@ impl Action for GetOldestMsgId { } impl Action for GetNewestMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? ORDER BY id DESC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -769,66 +878,74 @@ impl Action for GetNewestMsgId { } impl Action for GetOlderMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND id < ? ORDER BY id DESC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(msg_id) } } impl Action for GetNewerMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND id > ? ORDER BY id ASC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(msg_id) } } impl Action for GetOldestUnseenMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND NOT seen ORDER BY id ASC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -837,21 +954,23 @@ impl Action for GetOldestUnseenMsgId { } impl Action for GetNewestUnseenMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND NOT seen ORDER BY id DESC LIMIT 1 ", )? - .query_row([self.room], |row| { + .query_row([&self.room.domain, &self.room.name], |row| { row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) }) .optional()?; @@ -860,66 +979,74 @@ impl Action for GetNewestUnseenMsgId { } impl Action for GetOlderUnseenMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND NOT seen AND id < ? ORDER BY id DESC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(msg_id) } } impl Action for GetNewerUnseenMsgId { - type Result = Option<MessageId>; + type Output = Option<MessageId>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let msg_id = conn .prepare( " SELECT id FROM euph_msgs - WHERE room = ? + WHERE domain = ? + AND room = ? AND NOT seen AND id > ? ORDER BY id ASC LIMIT 1 ", )? - .query_row(params![self.room, WSnowflake(self.id.0)], |row| { - row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) - }) + .query_row( + params![self.room.domain, self.room.name, WSnowflake(self.id.0)], + |row| row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)), + ) .optional()?; Ok(msg_id) } } impl Action for GetUnseenMsgsCount { - type Result = usize; + type Output = usize; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { let amount = conn .prepare( " SELECT amount FROM euph_unseen_counts - WHERE room = ? + WHERE domain = ? + AND room = ? ", )? - .query_row(params![self.room], |row| row.get(0)) + .query_row(params![self.room.domain, self.room.name], |row| row.get(0)) .optional()? .unwrap_or(0); Ok(amount) @@ -927,90 +1054,120 @@ impl Action for GetUnseenMsgsCount { } impl Action for SetSeen { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.execute( " UPDATE euph_msgs SET seen = :seen - WHERE room = :room + WHERE domain = :domain + AND room = :room AND id = :id ", - named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen }, + named_params! { + ":domain": self.room.domain, + ":room": self.room.name, + ":id": WSnowflake(self.id.0), + ":seen": self.seen, + }, )?; Ok(()) } } impl Action for SetOlderSeen { - type Result = (); + type Output = (); + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { conn.execute( " UPDATE euph_msgs SET seen = :seen - WHERE room = :room + WHERE domain = :domain + AND room = :room AND id <= :id AND seen != :seen ", - named_params! { ":room": self.room, ":id": WSnowflake(self.id.0), ":seen": self.seen }, + named_params! { + ":domain": self.room.domain, + ":room": self.room.name, + ":id": WSnowflake(self.id.0), + ":seen": self.seen, + }, )?; Ok(()) } } -impl Action for GetChunkAtOffset { - type Result = Vec<Message>; +impl Action for GetChunkAfter { + type Output = Vec<Message>; + type Error = rusqlite::Error; - fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> { - let mut query = conn.prepare( - " - SELECT - id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, - user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address - FROM euph_msgs - WHERE room = ? - ORDER BY id ASC - LIMIT ? - OFFSET ? - ", - )?; + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + fn row2msg(row: &Row<'_>) -> rusqlite::Result<Message> { + Ok(Message { + id: MessageId(row.get::<_, WSnowflake>(0)?.0), + parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), + previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0), + time: row.get::<_, WTime>(3)?.0, + content: row.get(4)?, + encryption_key_id: row.get(5)?, + edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0), + deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0), + truncated: row.get(8)?, + sender: SessionView { + id: UserId(row.get(9)?), + name: row.get(10)?, + server_id: row.get(11)?, + server_era: row.get(12)?, + session_id: SessionId(row.get(13)?), + is_staff: row.get(14)?, + is_manager: row.get(15)?, + client_address: row.get(16)?, + real_client_address: row.get(17)?, + }, + }) + } + + let messages = if let Some(id) = self.id { + conn.prepare(" + SELECT + id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, + user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND id > ? + ORDER BY id ASC + LIMIT ? + ")? + .query_map(params![self.room.domain, self.room.name, WSnowflake(id.0), self.amount], row2msg)? + .collect::<rusqlite::Result<_>>()? + } else { + conn.prepare(" + SELECT + id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, + user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address + FROM euph_msgs + WHERE domain = ? + AND room = ? + ORDER BY id ASC + LIMIT ? + ")? + .query_map(params![self.room.domain, self.room.name, self.amount], row2msg)? + .collect::<rusqlite::Result<_>>()? + }; - let messages = query - .query_map(params![self.room, self.amount, self.offset], |row| { - Ok(Message { - id: MessageId(row.get::<_, WSnowflake>(0)?.0), - parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), - previous_edit_id: row.get::<_, Option<WSnowflake>>(2)?.map(|s| s.0), - time: row.get::<_, WTime>(3)?.0, - content: row.get(4)?, - encryption_key_id: row.get(5)?, - edited: row.get::<_, Option<WTime>>(6)?.map(|t| t.0), - deleted: row.get::<_, Option<WTime>>(7)?.map(|t| t.0), - truncated: row.get(8)?, - sender: SessionView { - id: UserId(row.get(9)?), - name: row.get(10)?, - server_id: row.get(11)?, - server_era: row.get(12)?, - session_id: SessionId(row.get(13)?), - is_staff: row.get(14)?, - is_manager: row.get(15)?, - client_address: row.get(16)?, - real_client_address: row.get(17)?, - }, - }) - })? - .collect::<rusqlite::Result<_>>()?; Ok(messages) } } #[async_trait] impl MsgStore<SmallMessage> for EuphRoomVault { - type Error = vault::tokio::Error; + type Error = vault::tokio::Error<rusqlite::Error>; async fn path(&self, id: &MessageId) -> Result<Path<MessageId>, Self::Error> { self.path(*id).await diff --git a/cove/src/vault/migrate.rs b/cove/src/vault/migrate.rs new file mode 100644 index 0000000..cc85c2c --- /dev/null +++ b/cove/src/vault/migrate.rs @@ -0,0 +1,224 @@ +use rusqlite::Transaction; +use vault::Migration; + +pub const MIGRATIONS: [Migration; 3] = [m1, m2, m3]; + +fn eprint_status(nr: usize, total: usize) { + eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1); +} + +fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> { + eprint_status(nr, total); + tx.execute_batch( + " + CREATE TABLE euph_rooms ( + room TEXT NOT NULL PRIMARY KEY, + first_joined INT NOT NULL, + last_joined INT NOT NULL + ) STRICT; + + CREATE TABLE euph_msgs ( + -- Message + room TEXT NOT NULL, + id INT NOT NULL, + parent INT, + previous_edit_id INT, + time INT NOT NULL, + content TEXT NOT NULL, + encryption_key_id TEXT, + edited INT, + deleted INT, + truncated INT NOT NULL, + + -- SessionView + user_id TEXT NOT NULL, + name TEXT, + server_id TEXT NOT NULL, + server_era TEXT NOT NULL, + session_id TEXT NOT NULL, + is_staff INT NOT NULL, + is_manager INT NOT NULL, + client_address TEXT, + real_client_address TEXT, + + PRIMARY KEY (room, id), + FOREIGN KEY (room) REFERENCES euph_rooms (room) + ON DELETE CASCADE + ) STRICT; + + CREATE TABLE euph_spans ( + room TEXT NOT NULL, + start INT, + end INT, + + UNIQUE (room, start, end), + FOREIGN KEY (room) REFERENCES euph_rooms (room) + ON DELETE CASCADE, + CHECK (start IS NULL OR end IS NOT NULL) + ) STRICT; + + CREATE TABLE euph_cookies ( + cookie TEXT NOT NULL + ) STRICT; + + CREATE INDEX euph_idx_msgs_room_id_parent + ON euph_msgs (room, id, parent); + + CREATE INDEX euph_idx_msgs_room_parent_id + ON euph_msgs (room, parent, id); + ", + ) +} + +fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> { + eprint_status(nr, total); + tx.execute_batch( + " + ALTER TABLE euph_msgs + ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE; + + CREATE INDEX euph_idx_msgs_room_id_seen + ON euph_msgs (room, id, seen); + ", + ) +} + +fn m3(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> { + eprint_status(nr, total); + println!(" This migration might take quite a while."); + println!(" Aborting it will not corrupt your vault."); + + // Rooms should be identified not just via their name but also their domain. + // The domain should be required but there should be no default value. + // + // To accomplish this, we need to recreate and repopulate all euph related + // tables because SQLite's ALTER TABLE is not powerful enough. + + eprintln!(" Preparing tables..."); + tx.execute_batch( + " + DROP INDEX euph_idx_msgs_room_id_parent; + DROP INDEX euph_idx_msgs_room_parent_id; + DROP INDEX euph_idx_msgs_room_id_seen; + + ALTER TABLE euph_rooms RENAME TO old_euph_rooms; + ALTER TABLE euph_msgs RENAME TO old_euph_msgs; + ALTER TABLE euph_spans RENAME TO old_euph_spans; + ALTER TABLE euph_cookies RENAME TO old_euph_cookies; + + CREATE TABLE euph_rooms ( + domain TEXT NOT NULL, + room TEXT NOT NULL, + first_joined INT NOT NULL, + last_joined INT NOT NULL, + + PRIMARY KEY (domain, room) + ) STRICT; + + CREATE TABLE euph_msgs ( + domain TEXT NOT NULL, + room TEXT NOT NULL, + seen INT NOT NULL, + + -- Message + id INT NOT NULL, + parent INT, + previous_edit_id INT, + time INT NOT NULL, + content TEXT NOT NULL, + encryption_key_id TEXT, + edited INT, + deleted INT, + truncated INT NOT NULL, + + -- SessionView + user_id TEXT NOT NULL, + name TEXT, + server_id TEXT NOT NULL, + server_era TEXT NOT NULL, + session_id TEXT NOT NULL, + is_staff INT NOT NULL, + is_manager INT NOT NULL, + client_address TEXT, + real_client_address TEXT, + + PRIMARY KEY (domain, room, id), + FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room) + ON DELETE CASCADE + ) STRICT; + + CREATE TABLE euph_spans ( + domain TEXT NOT NULL, + room TEXT NOT NULL, + start INT, + end INT, + + UNIQUE (domain, room, start, end), + FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room) + ON DELETE CASCADE, + CHECK (start IS NULL OR end IS NOT NULL) + ) STRICT; + + CREATE TABLE euph_cookies ( + domain TEXT NOT NULL, + cookie TEXT NOT NULL + ) STRICT; + ", + )?; + + eprintln!(" Migrating data..."); + tx.execute_batch( + " + INSERT INTO euph_rooms (domain, room, first_joined, last_joined) + SELECT 'euphoria.io', room, first_joined, last_joined + FROM old_euph_rooms; + + INSERT INTO euph_msgs ( + domain, room, seen, + id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, + user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address + ) + SELECT + 'euphoria.io', room, seen, + id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, + user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address + FROM old_euph_msgs; + + INSERT INTO euph_spans (domain, room, start, end) + SELECT 'euphoria.io', room, start, end + FROM old_euph_spans; + + INSERT INTO euph_cookies (domain, cookie) + SELECT 'euphoria.io', cookie + FROM old_euph_cookies; + ", + )?; + + eprintln!(" Recreating indexes..."); + tx.execute_batch( + " + CREATE INDEX euph_idx_msgs_domain_room_id_parent + ON euph_msgs (domain, room, id, parent); + + CREATE INDEX euph_idx_msgs_domain_room_parent_id + ON euph_msgs (domain, room, parent, id); + + CREATE INDEX euph_idx_msgs_domain_room_id_seen + ON euph_msgs (domain, room, id, seen); + ", + )?; + + eprintln!(" Cleaning up loose ends..."); + tx.execute_batch( + " + DROP TABLE old_euph_rooms; + DROP TABLE old_euph_msgs; + DROP TABLE old_euph_spans; + DROP TABLE old_euph_cookies; + + ANALYZE; + ", + )?; + + Ok(()) +} diff --git a/src/vault/prepare.rs b/cove/src/vault/prepare.rs similarity index 63% rename from src/vault/prepare.rs rename to cove/src/vault/prepare.rs index c990e26..8bbcb2b 100644 --- a/src/vault/prepare.rs +++ b/cove/src/vault/prepare.rs @@ -1,28 +1,32 @@ use rusqlite::Connection; pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { + eprintln!("Preparing vault"); + // Cache ids of tree roots. conn.execute_batch( " CREATE TEMPORARY TABLE euph_trees ( + domain TEXT NOT NULL, room TEXT NOT NULL, id INT NOT NULL, - PRIMARY KEY (room, id) + PRIMARY KEY (domain, room, id) ) STRICT; - INSERT INTO euph_trees (room, id) - SELECT room, id + INSERT INTO euph_trees (domain, room, id) + SELECT domain, room, id FROM euph_msgs WHERE parent IS NULL UNION - SELECT room, parent + SELECT domain, room, parent FROM euph_msgs WHERE parent IS NOT NULL AND NOT EXISTS( SELECT * FROM euph_msgs AS parents - WHERE parents.room = euph_msgs.room + WHERE parents.domain = euph_msgs.domain + AND parents.room = euph_msgs.room AND parents.id = euph_msgs.parent ); @@ -30,15 +34,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { AFTER DELETE ON main.euph_rooms BEGIN DELETE FROM euph_trees - WHERE room = old.room; + WHERE domain = old.domain + AND room = old.room; END; CREATE TEMPORARY TRIGGER et_insert_msg_without_parent AFTER INSERT ON main.euph_msgs WHEN new.parent IS NULL BEGIN - INSERT OR IGNORE INTO euph_trees (room, id) - VALUES (new.room, new.id); + INSERT OR IGNORE INTO euph_trees (domain, room, id) + VALUES (new.domain, new.room, new.id); END; CREATE TEMPORARY TRIGGER et_insert_msg_with_parent @@ -46,16 +51,18 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { WHEN new.parent IS NOT NULL BEGIN DELETE FROM euph_trees - WHERE room = new.room + WHERE domain = new.domain + AND room = new.room AND id = new.id; - INSERT OR IGNORE INTO euph_trees (room, id) + INSERT OR IGNORE INTO euph_trees (domain, room, id) SELECT * - FROM (VALUES (new.room, new.parent)) + FROM (VALUES (new.domain, new.room, new.parent)) WHERE NOT EXISTS( SELECT * FROM euph_msgs - WHERE room = new.room + WHERE domain = new.domain + AND room = new.room AND id = new.parent AND parent IS NOT NULL ); @@ -67,35 +74,37 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { conn.execute_batch( " CREATE TEMPORARY TABLE euph_unseen_counts ( + domain TEXT NOT NULL, room TEXT NOT NULL, amount INTEGER NOT NULL, - PRIMARY KEY (room) + PRIMARY KEY (domain, room) ) STRICT; -- There must be an entry for every existing room. - INSERT INTO euph_unseen_counts (room, amount) - SELECT room, 0 + INSERT INTO euph_unseen_counts (domain, room, amount) + SELECT domain, room, 0 FROM euph_rooms; - INSERT OR REPLACE INTO euph_unseen_counts (room, amount) - SELECT room, COUNT(*) + INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount) + SELECT domain, room, COUNT(*) FROM euph_msgs WHERE NOT seen - GROUP BY room; + GROUP BY domain, room; CREATE TEMPORARY TRIGGER euc_insert_room AFTER INSERT ON main.euph_rooms BEGIN - INSERT INTO euph_unseen_counts (room, amount) - VALUES (new.room, 0); + INSERT INTO euph_unseen_counts (domain, room, amount) + VALUES (new.domain, new.room, 0); END; CREATE TEMPORARY TRIGGER euc_delete_room AFTER DELETE ON main.euph_rooms BEGIN DELETE FROM euph_unseen_counts - WHERE room = old.room; + WHERE domain = old.domain + AND room = old.room; END; CREATE TEMPORARY TRIGGER euc_insert_msg @@ -104,7 +113,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { BEGIN UPDATE euph_unseen_counts SET amount = amount + 1 - WHERE room = new.room; + WHERE domain = new.domain + AND room = new.room; END; CREATE TEMPORARY TRIGGER euc_update_msg @@ -113,7 +123,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { BEGIN UPDATE euph_unseen_counts SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END - WHERE room = new.room; + WHERE domain = new.domain + AND room = new.room; END; ", )?; diff --git a/cove/src/version.rs b/cove/src/version.rs new file mode 100644 index 0000000..2a4c731 --- /dev/null +++ b/cove/src/version.rs @@ -0,0 +1,2 @@ +pub const NAME: &str = env!("CARGO_PKG_NAME"); +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 7196112..0000000 --- a/flake.lock +++ /dev/null @@ -1,63 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "locked": { - "lastModified": 1678901627, - "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "naersk": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1679567394, - "narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=", - "owner": "nix-community", - "repo": "naersk", - "rev": "88cd22380154a2c36799fe8098888f0f59861a15", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "naersk", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1680643271, - "narHash": "sha256-m76rYcvqs+NzTyETfxh1o/9gKdBuJ/Hl+PI/kp73mDw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "246567a3ad88e3119c2001e2fe78be233474cde0", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "naersk": "naersk", - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 17b2241..0000000 --- a/flake.nix +++ /dev/null @@ -1,21 +0,0 @@ -{ - description = "TUI client for euphoria.io, a threaded real-time chat platform"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs"; - flake-utils.url = "github:numtide/flake-utils"; - - naersk.url = "github:nix-community/naersk"; - naersk.inputs.nixpkgs.follows = "nixpkgs"; - }; - - outputs = { self, nixpkgs, flake-utils, naersk }: flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { inherit system; }; - naersk' = pkgs.callPackage naersk { }; - in - rec { - packages.default = naersk'.buildPackage { src = ./.; }; - } - ); -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 2a8d13f..0000000 --- a/src/config.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -use crate::macros::ok_or_return; - -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RoomsSortOrder { - #[default] - Alphabet, - Importance, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct EuphRoom { - // TODO Mark favourite rooms via printable ascii characters - #[serde(default)] - pub autojoin: bool, - pub username: Option<String>, - #[serde(default)] - pub force_username: bool, - pub password: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -pub struct Euph { - pub rooms: HashMap<String, EuphRoom>, -} - -#[derive(Debug, Default, Deserialize)] -pub struct Config { - pub data_dir: Option<PathBuf>, - #[serde(default)] - pub ephemeral: bool, - #[serde(default)] - pub offline: bool, - #[serde(default)] - pub rooms_sort_order: RoomsSortOrder, - // TODO Invoke external notification command? - pub euph: Euph, -} - -impl Config { - pub fn load(path: &Path) -> Self { - let content = ok_or_return!(fs::read_to_string(path), Self::default()); - match toml::from_str(&content) { - Ok(config) => config, - Err(err) => { - eprintln!("Error loading config file: {err}"); - Self::default() - } - } - } - - pub fn euph_room(&self, name: &str) -> EuphRoom { - self.euph.rooms.get(name).cloned().unwrap_or_default() - } -} diff --git a/src/euph/small_message.rs b/src/euph/small_message.rs deleted file mode 100644 index 4d2f4f4..0000000 --- a/src/euph/small_message.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::mem; - -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::{MessageId, Snowflake, Time}; -use time::OffsetDateTime; -use toss::styled::Styled; - -use crate::store::Msg; -use crate::ui::ChatMsg; - -use super::util; - -fn nick_char(ch: char) -> bool { - // Closely following the heim mention regex: - // https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15 - // `>` has been experimentally confirmed to delimit mentions as well. - match ch { - ',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false, - _ => !ch.is_whitespace(), - } -} - -fn room_char(ch: char) -> bool { - // Basically just \w, see also - // https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66 - ch.is_ascii_alphanumeric() || ch == '_' -} - -enum Span { - Nothing, - Mention, - Room, - Emoji, -} - -struct Highlighter<'a> { - content: &'a str, - base_style: ContentStyle, - exact: bool, - - span: Span, - span_start: usize, - room_or_mention_possible: bool, - - result: Styled, -} - -impl<'a> Highlighter<'a> { - /// Does *not* guarantee `self.span_start == idx` after running! - fn close_mention(&mut self, idx: usize) { - let span_length = idx.saturating_sub(self.span_start); - if span_length <= 1 { - // We can repurpose the current span - self.span = Span::Nothing; - return; - } - - let text = &self.content[self.span_start..idx]; // Includes @ - self.result = mem::take(&mut self.result).and_then(if self.exact { - util::style_nick_exact(text, self.base_style) - } else { - util::style_nick(text, self.base_style) - }); - - self.span = Span::Nothing; - self.span_start = idx; - } - - /// Does *not* guarantee `self.span_start == idx` after running! - fn close_room(&mut self, idx: usize) { - let span_length = idx.saturating_sub(self.span_start); - if span_length <= 1 { - // We can repurpose the current span - self.span = Span::Nothing; - return; - } - - self.result = mem::take(&mut self.result).then( - &self.content[self.span_start..idx], - self.base_style.blue().bold(), - ); - - self.span = Span::Nothing; - self.span_start = idx; - } - - // Warning: `idx` is the index of the closing colon. - fn close_emoji(&mut self, idx: usize) { - let name = &self.content[self.span_start + 1..idx]; - if let Some(replace) = util::EMOJI.get(name) { - match replace { - Some(replace) if !self.exact => { - self.result = mem::take(&mut self.result).then(replace, self.base_style); - } - _ => { - let text = &self.content[self.span_start..=idx]; - let style = self.base_style.magenta(); - self.result = mem::take(&mut self.result).then(text, style); - } - } - - self.span = Span::Nothing; - self.span_start = idx + 1; - } else { - self.close_plain(idx); - self.span = Span::Emoji; - } - } - - /// Guarantees `self.span_start == idx` after running. - fn close_plain(&mut self, idx: usize) { - if self.span_start == idx { - // Span has length 0 - return; - } - - self.result = - mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style); - - self.span = Span::Nothing; - self.span_start = idx; - } - - fn close_span_before_current_char(&mut self, idx: usize, char: char) { - match self.span { - Span::Mention if !nick_char(char) => self.close_mention(idx), - Span::Room if !room_char(char) => self.close_room(idx), - Span::Emoji if char == '&' || char == '@' => { - self.span = Span::Nothing; - } - _ => {} - } - } - - fn update_span_with_current_char(&mut self, idx: usize, char: char) { - match self.span { - Span::Nothing if char == '@' && self.room_or_mention_possible => { - self.close_plain(idx); - self.span = Span::Mention; - } - Span::Nothing if char == '&' && self.room_or_mention_possible => { - self.close_plain(idx); - self.span = Span::Room; - } - Span::Nothing if char == ':' => { - self.close_plain(idx); - self.span = Span::Emoji; - } - Span::Emoji if char == ':' => self.close_emoji(idx), - _ => {} - } - } - - fn close_final_span(&mut self) { - let idx = self.content.len(); - if self.span_start >= idx { - return; // Span has no contents - } - - match self.span { - Span::Mention => self.close_mention(idx), - Span::Room => self.close_room(idx), - _ => {} - } - - self.close_plain(idx); - } - - fn step(&mut self, idx: usize, char: char) { - if self.span_start < idx { - self.close_span_before_current_char(idx, char); - } - - self.update_span_with_current_char(idx, char); - - // More permissive than the heim web client - self.room_or_mention_possible = !char.is_alphanumeric(); - } - - fn highlight(content: &'a str, base_style: ContentStyle, exact: bool) -> Styled { - let mut this = Self { - content: if exact { content } else { content.trim() }, - base_style, - exact, - span: Span::Nothing, - span_start: 0, - room_or_mention_possible: true, - result: Styled::default(), - }; - - for (idx, char) in (if exact { content } else { content.trim() }).char_indices() { - this.step(idx, char); - } - - this.close_final_span(); - - this.result - } -} - -fn highlight_content(content: &str, base_style: ContentStyle, exact: bool) -> Styled { - Highlighter::highlight(content, base_style, exact) -} - -#[derive(Debug, Clone)] -pub struct SmallMessage { - pub id: MessageId, - pub parent: Option<MessageId>, - pub time: Time, - pub nick: String, - pub content: String, - pub seen: bool, -} - -fn as_me(content: &str) -> Option<&str> { - content.strip_prefix("/me") -} - -fn style_me() -> ContentStyle { - ContentStyle::default().grey().italic() -} - -fn styled_nick(nick: &str) -> Styled { - Styled::new_plain("[") - .and_then(util::style_nick(nick, ContentStyle::default())) - .then_plain("]") -} - -fn styled_nick_me(nick: &str) -> Styled { - let style = style_me(); - Styled::new("*", style).and_then(util::style_nick(nick, style)) -} - -fn styled_content(content: &str) -> Styled { - highlight_content(content.trim(), ContentStyle::default(), false) -} - -fn styled_content_me(content: &str) -> Styled { - let style = style_me(); - highlight_content(content.trim(), style, false).then("*", style) -} - -fn styled_editor_content(content: &str) -> Styled { - let style = if as_me(content).is_some() { - style_me() - } else { - ContentStyle::default() - }; - highlight_content(content, style, true) -} - -impl Msg for SmallMessage { - type Id = MessageId; - - fn id(&self) -> Self::Id { - self.id - } - - fn parent(&self) -> Option<Self::Id> { - self.parent - } - - fn seen(&self) -> bool { - self.seen - } - - fn last_possible_id() -> Self::Id { - MessageId(Snowflake::MAX) - } -} - -impl ChatMsg for SmallMessage { - fn time(&self) -> OffsetDateTime { - self.time.0 - } - - fn styled(&self) -> (Styled, Styled) { - Self::pseudo(&self.nick, &self.content) - } - - fn edit(nick: &str, content: &str) -> (Styled, Styled) { - (styled_nick(nick), styled_editor_content(content)) - } - - fn pseudo(nick: &str, content: &str) -> (Styled, Styled) { - if let Some(content) = as_me(content) { - (styled_nick_me(nick), styled_content_me(content)) - } else { - (styled_nick(nick), styled_content(content)) - } - } -} diff --git a/src/macros.rs b/src/macros.rs deleted file mode 100644 index 3e03b07..0000000 --- a/src/macros.rs +++ /dev/null @@ -1,45 +0,0 @@ -macro_rules! some_or_return { - ($e:expr) => { - match $e { - Some(result) => result, - None => return, - } - }; - ($e:expr, $ret:expr) => { - match $e { - Some(result) => result, - None => return $ret, - } - }; -} -pub(crate) use some_or_return; - -macro_rules! ok_or_return { - ($e:expr) => { - match $e { - Ok(result) => result, - Err(_) => return, - } - }; - ($e:expr, $ret:expr) => { - match $e { - Ok(result) => result, - Err(_) => return $ret, - } - }; -} -pub(crate) use ok_or_return; - -// TODO Get rid of this macro as much as possible -macro_rules! logging_unwrap { - ($e:expr) => { - match $e { - Ok(value) => value, - Err(err) => { - log::error!("{err}"); - panic!("{err}"); - } - } - }; -} -pub(crate) use logging_unwrap; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 945ba7b..0000000 --- a/src/main.rs +++ /dev/null @@ -1,191 +0,0 @@ -#![forbid(unsafe_code)] -// Rustc lint groups -#![warn(future_incompatible)] -#![warn(rust_2018_idioms)] -#![warn(unused)] -// Rustc lints -#![warn(noop_method_call)] -#![warn(single_use_lifetimes)] -// Clippy lints -#![warn(clippy::use_self)] - -// TODO Enable warn(unreachable_pub)? -// TODO Remove unnecessary Debug impls and compare compile times -// TODO Time zones other than UTC -// TODO Fix password room auth - -mod config; -mod euph; -mod export; -mod logger; -mod macros; -mod store; -mod ui; -mod vault; - -use std::path::PathBuf; - -use clap::Parser; -use cookie::CookieJar; -use directories::{BaseDirs, ProjectDirs}; -use log::info; -use tokio::sync::mpsc; -use toss::terminal::Terminal; - -use crate::config::Config; -use crate::logger::Logger; -use crate::ui::Ui; -use crate::vault::Vault; - -#[derive(Debug, clap::Parser)] -enum Command { - /// Run the client interactively (default). - Run, - /// Export room logs as plain text files. - Export(export::Args), - /// Compact and clean up vault. - Gc, - /// Clear euphoria session cookies. - ClearCookies, -} - -impl Default for Command { - fn default() -> Self { - Self::Run - } -} - -#[derive(Debug, clap::Parser)] -#[command(version)] -struct Args { - /// Show more detailed log messages. - #[arg(long, short)] - verbose: bool, - - /// Path to the config file. - /// - /// Relative paths are interpreted relative to the current directory. - #[arg(long, short)] - config: Option<PathBuf>, - - /// Path to a directory for cove to store its data in. - /// - /// Relative paths are interpreted relative to the current directory. - #[arg(long, short)] - data_dir: Option<PathBuf>, - - /// If set, cove won't store data permanently. - #[arg(long, short)] - ephemeral: bool, - - /// If set, cove will ignore the autojoin config option. - #[arg(long, short)] - offline: bool, - - /// Measure the width of characters as displayed by the terminal emulator - /// instead of guessing the width. - #[arg(long, short)] - measure_widths: bool, - - #[command(subcommand)] - command: Option<Command>, -} - -fn set_data_dir(config: &mut Config, args_data_dir: Option<PathBuf>) { - if let Some(data_dir) = args_data_dir { - // The data dir specified via args_data_dir is relative to the current - // directory and needs no resolving. - config.data_dir = Some(data_dir); - } else if let Some(data_dir) = &config.data_dir { - // Resolve the data dir specified in the config file relative to the - // user's home directory, if possible. - if let Some(base_dirs) = BaseDirs::new() { - config.data_dir = Some(base_dirs.home_dir().join(data_dir)); - } - } -} - -fn set_ephemeral(config: &mut Config, args_ephemeral: bool) { - if args_ephemeral { - config.ephemeral = true; - } -} - -fn set_offline(config: &mut Config, args_offline: bool) { - if args_offline { - config.offline = true; - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - let (logger, logger_guard, logger_rx) = Logger::init(args.verbose); - let dirs = ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories"); - - let config_path = args - .config - .unwrap_or_else(|| dirs.config_dir().join("config.toml")); - eprintln!("Config file: {}", config_path.to_string_lossy()); - let mut config = Config::load(&config_path); - set_data_dir(&mut config, args.data_dir); - set_ephemeral(&mut config, args.ephemeral); - set_offline(&mut config, args.offline); - let config = Box::leak(Box::new(config)); - - let vault = if config.ephemeral { - vault::launch_in_memory()? - } else { - let data_dir = config - .data_dir - .clone() - .unwrap_or_else(|| dirs.data_dir().to_path_buf()); - eprintln!("Data dir: {}", data_dir.to_string_lossy()); - vault::launch(&data_dir.join("vault.db"))? - }; - - match args.command.unwrap_or_default() { - Command::Run => run(logger, logger_rx, config, &vault, args.measure_widths).await?, - Command::Export(args) => export::export(&vault.euph(), args).await?, - Command::Gc => { - eprintln!("Cleaning up and compacting vault"); - eprintln!("This may take a while..."); - vault.gc().await?; - } - Command::ClearCookies => { - eprintln!("Clearing cookies"); - vault.euph().set_cookies(CookieJar::new()).await?; - } - } - - vault.close().await; - - // Print all logged errors. This should always happen, even if cove panics, - // because the errors may be key in diagnosing what happened. Because of - // this, it is not implemented via a normal function call. - drop(logger_guard); - - eprintln!("Goodbye!"); - Ok(()) -} - -async fn run( - logger: Logger, - logger_rx: mpsc::UnboundedReceiver<()>, - config: &'static Config, - vault: &Vault, - measure_widths: bool, -) -> anyhow::Result<()> { - info!( - "Welcome to {} {}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - ); - - let mut terminal = Terminal::new()?; - terminal.set_measuring(measure_widths); - Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?; - drop(terminal); // So other things can print again - - Ok(()) -} diff --git a/src/ui/chat.rs b/src/ui/chat.rs deleted file mode 100644 index 124d95d..0000000 --- a/src/ui/chat.rs +++ /dev/null @@ -1,160 +0,0 @@ -// TODO Implement thread view -// TODO Implement flat (chronological?) view -// TODO Implement message search? - -mod blocks; -mod tree; - -use std::sync::Arc; -use std::{fmt, io}; - -use async_trait::async_trait; -use parking_lot::FairMutex; -use time::OffsetDateTime; -use toss::frame::{Frame, Size}; -use toss::styled::Styled; -use toss::terminal::Terminal; - -use crate::store::{Msg, MsgStore}; - -use self::tree::{TreeView, TreeViewState}; - -use super::input::{InputEvent, KeyBindingsList}; -use super::widgets::Widget; - -/////////// -// Trait // -/////////// - -pub trait ChatMsg { - fn time(&self) -> OffsetDateTime; - fn styled(&self) -> (Styled, Styled); - fn edit(nick: &str, content: &str) -> (Styled, Styled); - fn pseudo(nick: &str, content: &str) -> (Styled, Styled); -} - -/////////// -// State // -/////////// - -pub enum Mode { - Tree, - // Thread, - // Flat, -} - -pub struct ChatState<M: Msg, S: MsgStore<M>> { - store: S, - mode: Mode, - tree: TreeViewState<M, S>, - // thread: ThreadView, - // flat: FlatView, -} - -impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> { - pub fn new(store: S) -> Self { - Self { - mode: Mode::Tree, - tree: TreeViewState::new(store.clone()), - store, - } - } -} - -impl<M: Msg, S: MsgStore<M>> ChatState<M, S> { - pub fn store(&self) -> &S { - &self.store - } - - pub fn widget(&self, nick: String, focused: bool) -> Chat<M, S> { - match self.mode { - Mode::Tree => Chat::Tree(self.tree.widget(nick, focused)), - } - } -} - -pub enum Reaction<M: Msg> { - NotHandled, - Handled, - Composed { - parent: Option<M::Id>, - content: String, - }, - ComposeError(io::Error), -} - -impl<M: Msg> Reaction<M> { - pub fn handled(&self) -> bool { - !matches!(self, Self::NotHandled) - } -} - -impl<M: Msg, S: MsgStore<M>> ChatState<M, S> { - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - match self.mode { - Mode::Tree => self.tree.list_key_bindings(bindings, can_compose).await, - } - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - can_compose: bool, - ) -> Result<Reaction<M>, S::Error> { - match self.mode { - Mode::Tree => { - self.tree - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await - } - } - } - - pub async fn cursor(&self) -> Option<M::Id> { - match self.mode { - Mode::Tree => self.tree.cursor().await, - } - } - - /// A [`Reaction::Composed`] message was sent, either successfully or - /// unsuccessfully. - /// - /// If successful, include the message's id as an argument. If unsuccessful, - /// instead pass a `None`. - pub async fn sent(&mut self, id: Option<M::Id>) { - match self.mode { - Mode::Tree => self.tree.sent(id).await, - } - } -} - -//////////// -// Widget // -//////////// - -pub enum Chat<M: Msg, S: MsgStore<M>> { - Tree(TreeView<M, S>), -} - -#[async_trait] -impl<M, S> Widget for Chat<M, S> -where - M: Msg + ChatMsg, - M::Id: Send + Sync, - S: MsgStore<M> + Send + Sync, - S::Error: fmt::Display, -{ - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - match self { - Self::Tree(tree) => tree.size(frame, max_width, max_height), - } - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - match *self { - Self::Tree(tree) => Box::new(tree).render(frame).await, - } - } -} diff --git a/src/ui/chat/blocks.rs b/src/ui/chat/blocks.rs deleted file mode 100644 index 1389d43..0000000 --- a/src/ui/chat/blocks.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::collections::{vec_deque, VecDeque}; -use std::ops::Range; - -use toss::frame::Frame; - -use crate::macros::some_or_return; -use crate::ui::widgets::BoxedWidget; - -pub struct Block<I> { - pub id: I, - pub top_line: i32, - pub height: i32, - /// The lines of the block that should be made visible if the block is - /// focused on. By default, the focus encompasses the entire block. - /// - /// If not all of these lines can be made visible, the top of the range - /// should be preferred over the bottom. - pub focus: Range<i32>, - pub widget: BoxedWidget, -} - -impl<I> Block<I> { - pub fn new<W: Into<BoxedWidget>>(frame: &mut Frame, id: I, widget: W) -> Self { - // Interestingly, rust-analyzer fails to deduce the type of `widget` - // here but rustc knows it's a `BoxedWidget`. - let widget = widget.into(); - let size = widget.size(frame, Some(frame.size().width), None); - let height = size.height.into(); - Self { - id, - top_line: 0, - height, - focus: 0..height, - widget, - } - } - - pub fn focus(mut self, focus: Range<i32>) -> Self { - self.focus = focus; - self - } -} - -pub struct Blocks<I> { - pub blocks: VecDeque<Block<I>>, - /// The top line of the first block. Useful for prepending blocks, - /// especially to empty [`Blocks`]s. - pub top_line: i32, - /// The bottom line of the last block. Useful for appending blocks, - /// especially to empty [`Blocks`]s. - pub bottom_line: i32, -} - -impl<I> Blocks<I> { - pub fn new() -> Self { - Self::new_below(0) - } - - /// Create a new [`Blocks`] such that the first prepended line will be on - /// `line`. - pub fn new_below(line: i32) -> Self { - Self { - blocks: VecDeque::new(), - top_line: line + 1, - bottom_line: line, - } - } - - pub fn iter(&self) -> vec_deque::Iter<'_, Block<I>> { - self.blocks.iter() - } - - pub fn offset(&mut self, delta: i32) { - self.top_line += delta; - self.bottom_line += delta; - for block in &mut self.blocks { - block.top_line += delta; - } - } - - pub fn push_front(&mut self, mut block: Block<I>) { - self.top_line -= block.height; - block.top_line = self.top_line; - self.blocks.push_front(block); - } - - pub fn push_back(&mut self, mut block: Block<I>) { - block.top_line = self.bottom_line + 1; - self.bottom_line += block.height; - self.blocks.push_back(block); - } - - pub fn prepend(&mut self, mut layout: Self) { - while let Some(block) = layout.blocks.pop_back() { - self.push_front(block); - } - } - - pub fn append(&mut self, mut layout: Self) { - while let Some(block) = layout.blocks.pop_front() { - self.push_back(block); - } - } - - pub fn set_top_line(&mut self, line: i32) { - self.top_line = line; - - if let Some(first_block) = self.blocks.front_mut() { - first_block.top_line = self.top_line; - } - - for i in 1..self.blocks.len() { - self.blocks[i].top_line = self.blocks[i - 1].top_line + self.blocks[i - 1].height; - } - - self.bottom_line = self - .blocks - .back() - .map(|b| b.top_line + b.height - 1) - .unwrap_or(self.top_line - 1); - } - - pub fn set_bottom_line(&mut self, line: i32) { - self.bottom_line = line; - - if let Some(last_block) = self.blocks.back_mut() { - last_block.top_line = self.bottom_line + 1 - last_block.height; - } - - for i in (1..self.blocks.len()).rev() { - self.blocks[i - 1].top_line = self.blocks[i].top_line - self.blocks[i - 1].height; - } - - self.top_line = self - .blocks - .front() - .map(|b| b.top_line) - .unwrap_or(self.bottom_line + 1) - } -} - -impl<I: Eq> Blocks<I> { - pub fn find(&self, id: &I) -> Option<&Block<I>> { - self.blocks.iter().find(|b| b.id == *id) - } - - pub fn recalculate_offsets(&mut self, id: &I, top_line: i32) { - let idx = some_or_return!(self - .blocks - .iter() - .enumerate() - .find(|(_, b)| b.id == *id) - .map(|(i, _)| i)); - - self.blocks[idx].top_line = top_line; - - // Propagate changes to top - for i in (0..idx).rev() { - self.blocks[i].top_line = self.blocks[i + 1].top_line - self.blocks[i].height; - } - self.top_line = self.blocks.front().expect("blocks nonempty").top_line; - - // Propagate changes to bottom - for i in (idx + 1)..self.blocks.len() { - self.blocks[i].top_line = self.blocks[i - 1].top_line + self.blocks[i - 1].height; - } - let bottom = self.blocks.back().expect("blocks nonempty"); - self.bottom_line = bottom.top_line + bottom.height - 1; - } -} diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs deleted file mode 100644 index ad8e6ab..0000000 --- a/src/ui/chat/tree.rs +++ /dev/null @@ -1,454 +0,0 @@ -// TODO Focusing on sub-trees - -mod cursor; -mod layout; -mod tree_blocks; -mod widgets; - -use std::collections::HashSet; -use std::fmt; -use std::sync::Arc; - -use async_trait::async_trait; -use parking_lot::FairMutex; -use tokio::sync::Mutex; -use toss::frame::{Frame, Pos, Size}; -use toss::terminal::Terminal; - -use crate::macros::logging_unwrap; -use crate::store::{Msg, MsgStore}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::util; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::Widget; - -use self::cursor::Cursor; - -use super::{ChatMsg, Reaction}; - -/////////// -// State // -/////////// - -enum Correction { - MakeCursorVisible, - MoveCursorToVisibleArea, - CenterCursor, -} - -struct InnerTreeViewState<M: Msg, S: MsgStore<M>> { - store: S, - - last_cursor: Cursor<M::Id>, - last_cursor_line: i32, - last_visible_msgs: Vec<M::Id>, - - cursor: Cursor<M::Id>, - editor: EditorState, - - /// Scroll the view on the next render. Positive values scroll up and - /// negative values scroll down. - scroll: i32, - correction: Option<Correction>, - - folded: HashSet<M::Id>, -} - -impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> { - fn new(store: S) -> Self { - Self { - store, - last_cursor: Cursor::Bottom, - last_cursor_line: 0, - last_visible_msgs: vec![], - cursor: Cursor::Bottom, - editor: EditorState::new(), - scroll: 0, - correction: None, - folded: HashSet::new(), - } - } - - pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling"); - bindings.binding("p/P", "move cursor to parent/root"); - bindings.binding("h/l, ←/→", "move cursor chronologically"); - bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down a line"); - bindings.binding("ctrl+u/d", "scroll up/down half a screen"); - bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen"); - bindings.binding("z", "center cursor on screen"); - // TODO Bindings inspired by vim's ()/[]/{} bindings? - } - - async fn handle_movement_input_event( - &mut self, - frame: &mut Frame, - event: &InputEvent, - ) -> Result<bool, S::Error> { - let chat_height = frame.size().height - 3; - - match event { - key!('k') | key!(Up) => self.move_cursor_up().await?, - key!('j') | key!(Down) => self.move_cursor_down().await?, - key!('K') | key!(Ctrl + Up) => self.move_cursor_up_sibling().await?, - key!('J') | key!(Ctrl + Down) => self.move_cursor_down_sibling().await?, - key!('p') => self.move_cursor_to_parent().await?, - key!('P') => self.move_cursor_to_root().await?, - key!('h') | key!(Left) => self.move_cursor_older().await?, - key!('l') | key!(Right) => self.move_cursor_newer().await?, - key!('H') | key!(Ctrl + Left) => self.move_cursor_older_unseen().await?, - key!('L') | key!(Ctrl + Right) => self.move_cursor_newer_unseen().await?, - key!('g') | key!(Home) => self.move_cursor_to_top().await?, - key!('G') | key!(End) => self.move_cursor_to_bottom().await, - key!(Ctrl + 'y') => self.scroll_up(1), - key!(Ctrl + 'e') => self.scroll_down(1), - key!(Ctrl + 'u') => self.scroll_up((chat_height / 2).into()), - key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()), - key!(Ctrl + 'b') | key!(PageUp) => self.scroll_up(chat_height.saturating_sub(1).into()), - key!(Ctrl + 'f') | key!(PageDown) => { - self.scroll_down(chat_height.saturating_sub(1).into()) - } - key!('z') => self.center_cursor(), - _ => return Ok(false), - } - - Ok(true) - } - - pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("space", "fold current message's subtree"); - bindings.binding("s", "toggle current message's seen status"); - bindings.binding("S", "mark all visible messages as seen"); - bindings.binding("ctrl+s", "mark all older messages as seen"); - } - - async fn handle_action_input_event( - &mut self, - event: &InputEvent, - id: Option<&M::Id>, - ) -> Result<bool, S::Error> { - match event { - key!(' ') => { - if let Some(id) = id { - if !self.folded.remove(id) { - self.folded.insert(id.clone()); - } - return Ok(true); - } - } - key!('s') => { - if let Some(id) = id { - if let Some(msg) = self.store.tree(id).await?.msg(id) { - self.store.set_seen(id, !msg.seen()).await?; - } - return Ok(true); - } - } - key!('S') => { - for id in &self.last_visible_msgs { - self.store.set_seen(id, true).await?; - } - return Ok(true); - } - key!(Ctrl + 's') => { - if let Some(id) = id { - self.store.set_older_seen(id, true).await?; - } else { - self.store - .set_older_seen(&M::last_possible_id(), true) - .await?; - } - return Ok(true); - } - _ => {} - } - Ok(false) - } - - pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("r", "reply to message (inline if possible, else directly)"); - bindings.binding("R", "reply to message (opposite of R)"); - bindings.binding("t", "start a new thread"); - } - - async fn handle_edit_initiating_input_event( - &mut self, - event: &InputEvent, - id: Option<M::Id>, - ) -> Result<bool, S::Error> { - match event { - key!('r') => { - if let Some(parent) = self.parent_for_normal_reply().await? { - self.cursor = Cursor::editor(id, parent); - self.correction = Some(Correction::MakeCursorVisible); - } - } - key!('R') => { - if let Some(parent) = self.parent_for_alternate_reply().await? { - self.cursor = Cursor::editor(id, parent); - self.correction = Some(Correction::MakeCursorVisible); - } - } - key!('t') | key!('T') => { - self.cursor = Cursor::editor(id, None); - self.correction = Some(Correction::MakeCursorVisible); - } - _ => return Ok(false), - } - - Ok(true) - } - - pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - self.list_movement_key_bindings(bindings); - bindings.empty(); - self.list_action_key_bindings(bindings); - if can_compose { - bindings.empty(); - self.list_edit_initiating_key_bindings(bindings); - } - } - - async fn handle_normal_input_event( - &mut self, - frame: &mut Frame, - event: &InputEvent, - can_compose: bool, - id: Option<M::Id>, - ) -> Result<bool, S::Error> { - #[allow(clippy::if_same_then_else)] - Ok(if self.handle_movement_input_event(frame, event).await? { - true - } else if self.handle_action_input_event(event, id.as_ref()).await? { - true - } else if can_compose { - self.handle_edit_initiating_input_event(event, id).await? - } else { - false - }) - } - - fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close editor"); - bindings.binding("enter", "send message"); - util::list_editor_key_bindings_allowing_external_editing(bindings, |_| true); - } - - fn handle_editor_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - coming_from: Option<M::Id>, - parent: Option<M::Id>, - ) -> Reaction<M> { - // TODO Tab-completion - match event { - key!(Esc) => { - self.cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); - self.correction = Some(Correction::MakeCursorVisible); - return Reaction::Handled; - } - - key!(Enter) => { - let content = self.editor.text(); - if !content.trim().is_empty() { - self.cursor = Cursor::Pseudo { - coming_from, - parent: parent.clone(), - }; - return Reaction::Composed { parent, content }; - } - } - - _ => { - let handled = util::handle_editor_input_event_allowing_external_editing( - &self.editor, - terminal, - crossterm_lock, - event, - |_| true, - ); - match handled { - Ok(true) => {} - Ok(false) => return Reaction::NotHandled, - Err(e) => return Reaction::ComposeError(e), - } - } - } - - self.correction = Some(Correction::MakeCursorVisible); - Reaction::Handled - } - - pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - bindings.heading("Chat"); - match &self.cursor { - Cursor::Bottom | Cursor::Msg(_) => { - self.list_normal_key_bindings(bindings, can_compose); - } - Cursor::Editor { .. } => self.list_editor_key_bindings(bindings), - Cursor::Pseudo { .. } => { - self.list_normal_key_bindings(bindings, false); - } - } - } - - async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - can_compose: bool, - ) -> Result<Reaction<M>, S::Error> { - Ok(match &self.cursor { - Cursor::Bottom => { - if self - .handle_normal_input_event(terminal.frame(), event, can_compose, None) - .await? - { - Reaction::Handled - } else { - Reaction::NotHandled - } - } - Cursor::Msg(id) => { - let id = id.clone(); - if self - .handle_normal_input_event(terminal.frame(), event, can_compose, Some(id)) - .await? - { - Reaction::Handled - } else { - Reaction::NotHandled - } - } - Cursor::Editor { - coming_from, - parent, - } => self.handle_editor_input_event( - terminal, - crossterm_lock, - event, - coming_from.clone(), - parent.clone(), - ), - Cursor::Pseudo { .. } => { - if self - .handle_movement_input_event(terminal.frame(), event) - .await? - { - Reaction::Handled - } else { - Reaction::NotHandled - } - } - }) - } - - fn cursor(&self) -> Option<M::Id> { - match &self.cursor { - Cursor::Msg(id) => Some(id.clone()), - Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None, - } - } - - fn sent(&mut self, id: Option<M::Id>) { - if let Cursor::Pseudo { coming_from, .. } = &self.cursor { - if let Some(id) = id { - self.last_cursor = Cursor::Msg(id.clone()); - self.cursor = Cursor::Msg(id); - self.editor.clear(); - } else { - self.cursor = match coming_from { - Some(id) => Cursor::Msg(id.clone()), - None => Cursor::Bottom, - }; - }; - } - } -} - -pub struct TreeViewState<M: Msg, S: MsgStore<M>>(Arc<Mutex<InnerTreeViewState<M, S>>>); - -impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> { - pub fn new(store: S) -> Self { - Self(Arc::new(Mutex::new(InnerTreeViewState::new(store)))) - } - - pub fn widget(&self, nick: String, focused: bool) -> TreeView<M, S> { - TreeView { - inner: self.0.clone(), - nick, - focused, - } - } - - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - self.0.lock().await.list_key_bindings(bindings, can_compose); - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - can_compose: bool, - ) -> Result<Reaction<M>, S::Error> { - self.0 - .lock() - .await - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await - } - - pub async fn cursor(&self) -> Option<M::Id> { - self.0.lock().await.cursor() - } - - pub async fn sent(&mut self, id: Option<M::Id>) { - self.0.lock().await.sent(id) - } -} - -//////////// -// Widget // -//////////// - -pub struct TreeView<M: Msg, S: MsgStore<M>> { - inner: Arc<Mutex<InnerTreeViewState<M, S>>>, - nick: String, - focused: bool, -} - -#[async_trait] -impl<M, S> Widget for TreeView<M, S> -where - M: Msg + ChatMsg, - M::Id: Send + Sync, - S: MsgStore<M> + Send + Sync, - S::Error: fmt::Display, -{ - fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size { - Size::ZERO - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let mut guard = self.inner.lock().await; - let blocks = logging_unwrap!(guard.relayout(self.nick, self.focused, frame).await); - - let size = frame.size(); - for block in blocks.into_blocks().blocks { - frame.push( - Pos::new(0, block.top_line), - Size::new(size.width, block.height as u16), - ); - block.widget.render(frame).await; - frame.pop(); - } - } -} diff --git a/src/ui/chat/tree/cursor.rs b/src/ui/chat/tree/cursor.rs deleted file mode 100644 index e154708..0000000 --- a/src/ui/chat/tree/cursor.rs +++ /dev/null @@ -1,498 +0,0 @@ -//! Moving the cursor around. - -use std::collections::HashSet; - -use crate::store::{Msg, MsgStore, Tree}; - -use super::{Correction, InnerTreeViewState}; - -#[derive(Debug, Clone, Copy)] -pub enum Cursor<I> { - Bottom, - Msg(I), - Editor { - coming_from: Option<I>, - parent: Option<I>, - }, - Pseudo { - coming_from: Option<I>, - parent: Option<I>, - }, -} - -impl<I> Cursor<I> { - pub fn editor(coming_from: Option<I>, parent: Option<I>) -> Self { - Self::Editor { - coming_from, - parent, - } - } -} - -impl<I: Eq> Cursor<I> { - pub fn refers_to(&self, id: &I) -> bool { - if let Self::Msg(own_id) = self { - own_id == id - } else { - false - } - } - - pub fn refers_to_last_child_of(&self, id: &I) -> bool { - if let Self::Editor { - parent: Some(parent), - .. - } - | Self::Pseudo { - parent: Some(parent), - .. - } = self - { - parent == id - } else { - false - } - } -} - -impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> { - fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool { - if let Some(parent) = tree.parent(id) { - *id = parent; - true - } else { - false - } - } - - fn find_first_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool { - if folded.contains(id) { - return false; - } - - if let Some(child) = tree.children(id).and_then(|c| c.first()) { - *id = child.clone(); - true - } else { - false - } - } - - fn find_last_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool { - if folded.contains(id) { - return false; - } - - if let Some(child) = tree.children(id).and_then(|c| c.last()) { - *id = child.clone(); - true - } else { - false - } - } - - /// Move to the previous sibling, or don't move if this is not possible. - /// - /// Always stays at the same level of indentation. - async fn find_prev_sibling( - store: &S, - tree: &mut Tree<M>, - id: &mut M::Id, - ) -> Result<bool, S::Error> { - let moved = if let Some(prev_sibling) = tree.prev_sibling(id) { - *id = prev_sibling; - true - } else if tree.parent(id).is_none() { - // We're at the root of our tree, so we need to move to the root of - // the previous tree. - if let Some(prev_root_id) = store.prev_root_id(tree.root()).await? { - *tree = store.tree(&prev_root_id).await?; - *id = prev_root_id; - true - } else { - false - } - } else { - false - }; - Ok(moved) - } - - /// Move to the next sibling, or don't move if this is not possible. - /// - /// Always stays at the same level of indentation. - async fn find_next_sibling( - store: &S, - tree: &mut Tree<M>, - id: &mut M::Id, - ) -> Result<bool, S::Error> { - let moved = if let Some(next_sibling) = tree.next_sibling(id) { - *id = next_sibling; - true - } else if tree.parent(id).is_none() { - // We're at the root of our tree, so we need to move to the root of - // the next tree. - if let Some(next_root_id) = store.next_root_id(tree.root()).await? { - *tree = store.tree(&next_root_id).await?; - *id = next_root_id; - true - } else { - false - } - } else { - false - }; - Ok(moved) - } - - /// Move to the previous message, or don't move if this is not possible. - async fn find_prev_msg( - store: &S, - folded: &HashSet<M::Id>, - tree: &mut Tree<M>, - id: &mut M::Id, - ) -> Result<bool, S::Error> { - // Move to previous sibling, then to its last child - // If not possible, move to parent - let moved = if Self::find_prev_sibling(store, tree, id).await? { - while Self::find_last_child(folded, tree, id) {} - true - } else { - Self::find_parent(tree, id) - }; - Ok(moved) - } - - /// Move to the next message, or don't move if this is not possible. - async fn find_next_msg( - store: &S, - folded: &HashSet<M::Id>, - tree: &mut Tree<M>, - id: &mut M::Id, - ) -> Result<bool, S::Error> { - if Self::find_first_child(folded, tree, id) { - return Ok(true); - } - - if Self::find_next_sibling(store, tree, id).await? { - return Ok(true); - } - - // Temporary id to avoid modifying the original one if no parent-sibling - // can be found. - let mut tmp_id = id.clone(); - while Self::find_parent(tree, &mut tmp_id) { - if Self::find_next_sibling(store, tree, &mut tmp_id).await? { - *id = tmp_id; - return Ok(true); - } - } - - Ok(false) - } - - pub async fn move_cursor_up(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => { - if let Some(last_root_id) = self.store.last_root_id().await? { - let tree = self.store.tree(&last_root_id).await?; - let mut id = last_root_id; - while Self::find_last_child(&self.folded, &tree, &mut id) {} - self.cursor = Cursor::Msg(id); - } - } - Cursor::Msg(msg) => { - let path = self.store.path(msg).await?; - let mut tree = self.store.tree(path.first()).await?; - Self::find_prev_msg(&self.store, &self.folded, &mut tree, msg).await?; - } - Cursor::Editor { .. } => {} - Cursor::Pseudo { - parent: Some(parent), - .. - } => { - let tree = self.store.tree(parent).await?; - let mut id = parent.clone(); - while Self::find_last_child(&self.folded, &tree, &mut id) {} - self.cursor = Cursor::Msg(id); - } - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_down(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(msg) => { - let path = self.store.path(msg).await?; - let mut tree = self.store.tree(path.first()).await?; - if !Self::find_next_msg(&self.store, &self.folded, &mut tree, msg).await? { - self.cursor = Cursor::Bottom; - } - } - Cursor::Pseudo { parent: None, .. } => { - self.cursor = Cursor::Bottom; - } - Cursor::Pseudo { - parent: Some(parent), - .. - } => { - let mut tree = self.store.tree(parent).await?; - let mut id = parent.clone(); - while Self::find_last_child(&self.folded, &tree, &mut id) {} - // Now we're at the previous message - if Self::find_next_msg(&self.store, &self.folded, &mut tree, &mut id).await? { - self.cursor = Cursor::Msg(id); - } else { - self.cursor = Cursor::Bottom; - } - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_up_sibling(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => { - if let Some(last_root_id) = self.store.last_root_id().await? { - self.cursor = Cursor::Msg(last_root_id); - } - } - Cursor::Msg(msg) => { - let path = self.store.path(msg).await?; - let mut tree = self.store.tree(path.first()).await?; - Self::find_prev_sibling(&self.store, &mut tree, msg).await?; - } - Cursor::Editor { .. } => {} - Cursor::Pseudo { - parent: Some(parent), - .. - } => { - let path = self.store.path(parent).await?; - let tree = self.store.tree(path.first()).await?; - if let Some(children) = tree.children(parent) { - if let Some(last_child) = children.last() { - self.cursor = Cursor::Msg(last_child.clone()); - } - } - } - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_down_sibling(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(msg) => { - let path = self.store.path(msg).await?; - let mut tree = self.store.tree(path.first()).await?; - if !Self::find_next_sibling(&self.store, &mut tree, msg).await? - && tree.parent(msg).is_none() - { - self.cursor = Cursor::Bottom; - } - } - Cursor::Pseudo { parent: None, .. } => { - self.cursor = Cursor::Bottom; - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_to_parent(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Pseudo { - parent: Some(parent), - .. - } => self.cursor = Cursor::Msg(parent.clone()), - Cursor::Msg(id) => { - // Could also be done via retrieving the path, but it doesn't - // really matter here - let tree = self.store.tree(id).await?; - Self::find_parent(&tree, id); - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_to_root(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Pseudo { - parent: Some(parent), - .. - } => { - let path = self.store.path(parent).await?; - self.cursor = Cursor::Msg(path.first().clone()); - } - Cursor::Msg(msg) => { - let path = self.store.path(msg).await?; - *msg = path.first().clone(); - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_older(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(id) => { - if let Some(prev_id) = self.store.older_msg_id(id).await? { - *id = prev_id; - } - } - Cursor::Bottom | Cursor::Pseudo { .. } => { - if let Some(id) = self.store.newest_msg_id().await? { - self.cursor = Cursor::Msg(id); - } - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_newer(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(id) => { - if let Some(prev_id) = self.store.newer_msg_id(id).await? { - *id = prev_id; - } else { - self.cursor = Cursor::Bottom; - } - } - Cursor::Pseudo { .. } => { - self.cursor = Cursor::Bottom; - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_older_unseen(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(id) => { - if let Some(prev_id) = self.store.older_unseen_msg_id(id).await? { - *id = prev_id; - } - } - Cursor::Bottom | Cursor::Pseudo { .. } => { - if let Some(id) = self.store.newest_unseen_msg_id().await? { - self.cursor = Cursor::Msg(id); - } - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_newer_unseen(&mut self) -> Result<(), S::Error> { - match &mut self.cursor { - Cursor::Msg(id) => { - if let Some(prev_id) = self.store.newer_unseen_msg_id(id).await? { - *id = prev_id; - } else { - self.cursor = Cursor::Bottom; - } - } - Cursor::Pseudo { .. } => { - self.cursor = Cursor::Bottom; - } - _ => {} - } - self.correction = Some(Correction::MakeCursorVisible); - Ok(()) - } - - pub async fn move_cursor_to_top(&mut self) -> Result<(), S::Error> { - if let Some(first_root_id) = self.store.first_root_id().await? { - self.cursor = Cursor::Msg(first_root_id); - self.correction = Some(Correction::MakeCursorVisible); - } - Ok(()) - } - - pub async fn move_cursor_to_bottom(&mut self) { - self.cursor = Cursor::Bottom; - // Not really necessary; only here for consistency with other methods - self.correction = Some(Correction::MakeCursorVisible); - } - - pub fn scroll_up(&mut self, amount: i32) { - self.scroll += amount; - self.correction = Some(Correction::MoveCursorToVisibleArea); - } - - pub fn scroll_down(&mut self, amount: i32) { - self.scroll -= amount; - self.correction = Some(Correction::MoveCursorToVisibleArea); - } - - pub fn center_cursor(&mut self) { - self.correction = Some(Correction::CenterCursor); - } - - /// The outer `Option` shows whether a parent exists or not. The inner - /// `Option` shows if that parent has an id. - pub async fn parent_for_normal_reply(&self) -> Result<Option<Option<M::Id>>, S::Error> { - Ok(match &self.cursor { - Cursor::Bottom => Some(None), - Cursor::Msg(id) => { - let path = self.store.path(id).await?; - let tree = self.store.tree(path.first()).await?; - - Some(Some(if tree.next_sibling(id).is_some() { - // A reply to a message that has further siblings should be a - // direct reply. An indirect reply might end up a lot further - // down in the current conversation. - id.clone() - } else if let Some(parent) = tree.parent(id) { - // A reply to a message without younger siblings should be - // an indirect reply so as not to create unnecessarily deep - // threads. In the case that our message has children, this - // might get a bit confusing. I'm not sure yet how well this - // "smart" reply actually works in practice. - parent - } else { - // When replying to a top-level message, it makes sense to avoid - // creating unnecessary new threads. - id.clone() - })) - } - _ => None, - }) - } - - /// The outer `Option` shows whether a parent exists or not. The inner - /// `Option` shows if that parent has an id. - pub async fn parent_for_alternate_reply(&self) -> Result<Option<Option<M::Id>>, S::Error> { - Ok(match &self.cursor { - Cursor::Bottom => Some(None), - Cursor::Msg(id) => { - let path = self.store.path(id).await?; - let tree = self.store.tree(path.first()).await?; - - Some(Some(if tree.next_sibling(id).is_none() { - // The opposite of replying normally - id.clone() - } else if let Some(parent) = tree.parent(id) { - // The opposite of replying normally - parent - } else { - // The same as replying normally, still to avoid creating - // unnecessary new threads - id.clone() - })) - } - _ => None, - }) - } -} diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs deleted file mode 100644 index baccfbe..0000000 --- a/src/ui/chat/tree/layout.rs +++ /dev/null @@ -1,605 +0,0 @@ -use toss::frame::Frame; - -use crate::store::{Msg, MsgStore, Path, Tree}; -use crate::ui::chat::blocks::Block; -use crate::ui::widgets::empty::Empty; -use crate::ui::ChatMsg; - -use super::tree_blocks::{BlockId, Root, TreeBlocks}; -use super::{widgets, Correction, Cursor, InnerTreeViewState}; - -const SCROLLOFF: i32 = 2; -const MIN_CONTENT_HEIGHT: i32 = 10; - -fn scrolloff(height: i32) -> i32 { - let scrolloff = (height - MIN_CONTENT_HEIGHT).max(0) / 2; - scrolloff.min(SCROLLOFF) -} - -struct Context { - nick: String, - focused: bool, -} - -impl<M: Msg + ChatMsg, S: MsgStore<M>> InnerTreeViewState<M, S> { - async fn cursor_path(&self, cursor: &Cursor<M::Id>) -> Result<Path<M::Id>, S::Error> { - Ok(match cursor { - Cursor::Msg(id) => self.store.path(id).await?, - Cursor::Bottom - | Cursor::Editor { parent: None, .. } - | Cursor::Pseudo { parent: None, .. } => Path::new(vec![M::last_possible_id()]), - Cursor::Editor { - parent: Some(parent), - .. - } - | Cursor::Pseudo { - parent: Some(parent), - .. - } => { - let mut path = self.store.path(parent).await?; - path.push(M::last_possible_id()); - path - } - }) - } - - fn make_path_visible(&mut self, path: &Path<M::Id>) { - for segment in path.parent_segments() { - self.folded.remove(segment); - } - } - - fn cursor_line(&self, blocks: &TreeBlocks<M::Id>) -> i32 { - if let Cursor::Bottom = self.cursor { - // The value doesn't matter as it will always be ignored. - 0 - } else { - blocks - .blocks() - .find(&BlockId::from_cursor(&self.cursor)) - .expect("no cursor found") - .top_line - } - } - - fn contains_cursor(&self, blocks: &TreeBlocks<M::Id>) -> bool { - blocks - .blocks() - .find(&BlockId::from_cursor(&self.cursor)) - .is_some() - } - - fn editor_block( - &self, - context: &Context, - frame: &mut Frame, - indent: usize, - ) -> Block<BlockId<M::Id>> { - let (widget, cursor_row) = - widgets::editor::<M>(frame.widthdb(), indent, &context.nick, &self.editor); - let cursor_row = cursor_row as i32; - Block::new(frame, BlockId::Cursor, widget).focus(cursor_row..cursor_row + 1) - } - - fn pseudo_block( - &self, - context: &Context, - frame: &mut Frame, - indent: usize, - ) -> Block<BlockId<M::Id>> { - let widget = widgets::pseudo::<M>(indent, &context.nick, &self.editor); - Block::new(frame, BlockId::Cursor, widget) - } - - fn layout_subtree( - &self, - context: &Context, - frame: &mut Frame, - tree: &Tree<M>, - indent: usize, - id: &M::Id, - blocks: &mut TreeBlocks<M::Id>, - ) { - // Ghost cursor in front, for positioning according to last cursor line - if self.last_cursor.refers_to(id) { - let block = Block::new(frame, BlockId::LastCursor, Empty::new()); - blocks.blocks_mut().push_back(block); - } - - // Last part of message body if message is folded - let folded = self.folded.contains(id); - let folded_info = if folded { - Some(tree.subtree_size(id)).filter(|s| *s > 0) - } else { - None - }; - - // Main message body - let highlighted = context.focused && self.cursor.refers_to(id); - let widget = if let Some(msg) = tree.msg(id) { - widgets::msg(highlighted, indent, msg, folded_info) - } else { - widgets::msg_placeholder(highlighted, indent, folded_info) - }; - let block = Block::new(frame, BlockId::Msg(id.clone()), widget); - blocks.blocks_mut().push_back(block); - - // Children, recursively - if !folded { - if let Some(children) = tree.children(id) { - for child in children { - self.layout_subtree(context, frame, tree, indent + 1, child, blocks); - } - } - } - - // Trailing ghost cursor, for positioning according to last cursor line - if self.last_cursor.refers_to_last_child_of(id) { - let block = Block::new(frame, BlockId::LastCursor, Empty::new()); - blocks.blocks_mut().push_back(block); - } - - // Trailing editor or pseudomessage - if self.cursor.refers_to_last_child_of(id) { - match self.cursor { - Cursor::Editor { .. } => { - blocks - .blocks_mut() - .push_back(self.editor_block(context, frame, indent + 1)) - } - Cursor::Pseudo { .. } => { - blocks - .blocks_mut() - .push_back(self.pseudo_block(context, frame, indent + 1)) - } - _ => {} - } - } - } - - fn layout_tree( - &self, - context: &Context, - frame: &mut Frame, - tree: Tree<M>, - ) -> TreeBlocks<M::Id> { - let root = Root::Tree(tree.root().clone()); - let mut blocks = TreeBlocks::new(root.clone(), root); - self.layout_subtree(context, frame, &tree, 0, tree.root(), &mut blocks); - blocks - } - - fn layout_bottom(&self, context: &Context, frame: &mut Frame) -> TreeBlocks<M::Id> { - let mut blocks = TreeBlocks::new(Root::Bottom, Root::Bottom); - - // Ghost cursor, for positioning according to last cursor line - if let Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } = - self.last_cursor - { - let block = Block::new(frame, BlockId::LastCursor, Empty::new()); - blocks.blocks_mut().push_back(block); - } - - match self.cursor { - Cursor::Bottom => { - let block = Block::new(frame, BlockId::Cursor, Empty::new()); - blocks.blocks_mut().push_back(block); - } - Cursor::Editor { parent: None, .. } => blocks - .blocks_mut() - .push_back(self.editor_block(context, frame, 0)), - Cursor::Pseudo { parent: None, .. } => blocks - .blocks_mut() - .push_back(self.pseudo_block(context, frame, 0)), - _ => {} - } - - blocks - } - - async fn expand_to_top( - &self, - context: &Context, - frame: &mut Frame, - blocks: &mut TreeBlocks<M::Id>, - ) -> Result<(), S::Error> { - let top_line = 0; - - while blocks.blocks().top_line > top_line { - let top_root = blocks.top_root(); - let prev_root_id = match top_root { - Root::Bottom => self.store.last_root_id().await?, - Root::Tree(root_id) => self.store.prev_root_id(root_id).await?, - }; - let prev_root_id = match prev_root_id { - Some(id) => id, - None => break, - }; - let prev_tree = self.store.tree(&prev_root_id).await?; - blocks.prepend(self.layout_tree(context, frame, prev_tree)); - } - - Ok(()) - } - - async fn expand_to_bottom( - &self, - context: &Context, - frame: &mut Frame, - blocks: &mut TreeBlocks<M::Id>, - ) -> Result<(), S::Error> { - let bottom_line = frame.size().height as i32 - 1; - - while blocks.blocks().bottom_line < bottom_line { - let bottom_root = blocks.bottom_root(); - let next_root_id = match bottom_root { - Root::Bottom => break, - Root::Tree(root_id) => self.store.next_root_id(root_id).await?, - }; - if let Some(next_root_id) = next_root_id { - let next_tree = self.store.tree(&next_root_id).await?; - blocks.append(self.layout_tree(context, frame, next_tree)); - } else { - blocks.append(self.layout_bottom(context, frame)); - } - } - - Ok(()) - } - - async fn fill_screen_and_clamp_scrolling( - &self, - context: &Context, - frame: &mut Frame, - blocks: &mut TreeBlocks<M::Id>, - ) -> Result<(), S::Error> { - let top_line = 0; - let bottom_line = frame.size().height as i32 - 1; - - self.expand_to_top(context, frame, blocks).await?; - - if blocks.blocks().top_line > top_line { - blocks.blocks_mut().set_top_line(0); - } - - self.expand_to_bottom(context, frame, blocks).await?; - - if blocks.blocks().bottom_line < bottom_line { - blocks.blocks_mut().set_bottom_line(bottom_line); - } - - self.expand_to_top(context, frame, blocks).await?; - - Ok(()) - } - - async fn layout_last_cursor_seed( - &self, - context: &Context, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - ) -> Result<TreeBlocks<M::Id>, S::Error> { - Ok(match &self.last_cursor { - Cursor::Bottom => { - let mut blocks = self.layout_bottom(context, frame); - - let bottom_line = frame.size().height as i32 - 1; - blocks.blocks_mut().set_bottom_line(bottom_line); - - blocks - } - Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } => { - let mut blocks = self.layout_bottom(context, frame); - - blocks - .blocks_mut() - .recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line); - - blocks - } - Cursor::Msg(_) - | Cursor::Editor { - parent: Some(_), .. - } - | Cursor::Pseudo { - parent: Some(_), .. - } => { - let root = last_cursor_path.first(); - let tree = self.store.tree(root).await?; - let mut blocks = self.layout_tree(context, frame, tree); - - blocks - .blocks_mut() - .recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line); - - blocks - } - }) - } - - async fn layout_cursor_seed( - &self, - context: &Context, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - cursor_path: &Path<M::Id>, - ) -> Result<TreeBlocks<M::Id>, S::Error> { - let bottom_line = frame.size().height as i32 - 1; - - Ok(match &self.cursor { - Cursor::Bottom - | Cursor::Editor { parent: None, .. } - | Cursor::Pseudo { parent: None, .. } => { - let mut blocks = self.layout_bottom(context, frame); - - blocks.blocks_mut().set_bottom_line(bottom_line); - - blocks - } - Cursor::Msg(_) - | Cursor::Editor { - parent: Some(_), .. - } - | Cursor::Pseudo { - parent: Some(_), .. - } => { - let root = cursor_path.first(); - let tree = self.store.tree(root).await?; - let mut blocks = self.layout_tree(context, frame, tree); - - let cursor_above_last = cursor_path < last_cursor_path; - let cursor_line = if cursor_above_last { 0 } else { bottom_line }; - blocks - .blocks_mut() - .recalculate_offsets(&BlockId::from_cursor(&self.cursor), cursor_line); - - blocks - } - }) - } - - async fn layout_initial_seed( - &self, - context: &Context, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - cursor_path: &Path<M::Id>, - ) -> Result<TreeBlocks<M::Id>, S::Error> { - if let Cursor::Bottom = self.cursor { - self.layout_cursor_seed(context, frame, last_cursor_path, cursor_path) - .await - } else { - self.layout_last_cursor_seed(context, frame, last_cursor_path) - .await - } - } - - fn scroll_so_cursor_is_visible(&self, frame: &Frame, blocks: &mut TreeBlocks<M::Id>) { - if matches!(self.cursor, Cursor::Bottom) { - return; // Cursor is locked to bottom - } - - let block = blocks - .blocks() - .find(&BlockId::from_cursor(&self.cursor)) - .expect("no cursor found"); - - let height = frame.size().height as i32; - let scrolloff = scrolloff(height); - - let min_line = -block.focus.start + scrolloff; - let max_line = height - block.focus.end - scrolloff; - - // If the message is higher than the available space, the top of the - // message should always be visible. I'm not using top_line.clamp(...) - // because the order of the min and max matters. - let top_line = block.top_line; - #[allow(clippy::manual_clamp)] - let new_top_line = top_line.min(max_line).max(min_line); - if new_top_line != top_line { - blocks.blocks_mut().offset(new_top_line - top_line); - } - } - - fn scroll_so_cursor_is_centered(&self, frame: &Frame, blocks: &mut TreeBlocks<M::Id>) { - if matches!(self.cursor, Cursor::Bottom) { - return; // Cursor is locked to bottom - } - - let block = blocks - .blocks() - .find(&BlockId::from_cursor(&self.cursor)) - .expect("no cursor found"); - - let height = frame.size().height as i32; - let scrolloff = scrolloff(height); - - let min_line = -block.focus.start + scrolloff; - let max_line = height - block.focus.end - scrolloff; - - // If the message is higher than the available space, the top of the - // message should always be visible. I'm not using top_line.clamp(...) - // because the order of the min and max matters. - let top_line = block.top_line; - let new_top_line = (height - block.height) / 2; - #[allow(clippy::manual_clamp)] - let new_top_line = new_top_line.min(max_line).max(min_line); - if new_top_line != top_line { - blocks.blocks_mut().offset(new_top_line - top_line); - } - } - - /// Try to obtain a [`Cursor::Msg`] pointing to the block. - fn msg_id(block: &Block<BlockId<M::Id>>) -> Option<M::Id> { - match &block.id { - BlockId::Msg(id) => Some(id.clone()), - _ => None, - } - } - - fn visible(block: &Block<BlockId<M::Id>>, first_line: i32, last_line: i32) -> bool { - (first_line + 1 - block.height..=last_line).contains(&block.top_line) - } - - fn move_cursor_so_it_is_visible( - &mut self, - frame: &Frame, - blocks: &TreeBlocks<M::Id>, - ) -> Option<M::Id> { - if !matches!(self.cursor, Cursor::Bottom | Cursor::Msg(_)) { - // In all other cases, there is no need to make the cursor visible - // since scrolling behaves differently enough. - return None; - } - - let height = frame.size().height as i32; - let scrolloff = scrolloff(height); - - let first_line = scrolloff; - let last_line = height - 1 - scrolloff; - - let new_cursor = if matches!(self.cursor, Cursor::Bottom) { - blocks - .blocks() - .iter() - .rev() - .filter(|b| Self::visible(b, first_line, last_line)) - .find_map(Self::msg_id) - } else { - let block = blocks - .blocks() - .find(&BlockId::from_cursor(&self.cursor)) - .expect("no cursor found"); - - if Self::visible(block, first_line, last_line) { - return None; - } else if block.top_line < first_line { - blocks - .blocks() - .iter() - .filter(|b| Self::visible(b, first_line, last_line)) - .find_map(Self::msg_id) - } else { - blocks - .blocks() - .iter() - .rev() - .filter(|b| Self::visible(b, first_line, last_line)) - .find_map(Self::msg_id) - } - }; - - if let Some(id) = new_cursor { - self.cursor = Cursor::Msg(id.clone()); - Some(id) - } else { - None - } - } - - fn visible_msgs(frame: &Frame, blocks: &TreeBlocks<M::Id>) -> Vec<M::Id> { - let height: i32 = frame.size().height.into(); - let first_line = 0; - let last_line = first_line + height - 1; - - let mut result = vec![]; - for block in blocks.blocks().iter() { - if Self::visible(block, first_line, last_line) { - if let BlockId::Msg(id) = &block.id { - result.push(id.clone()); - } - } - } - - result - } - - pub async fn relayout( - &mut self, - nick: String, - focused: bool, - frame: &mut Frame, - ) -> Result<TreeBlocks<M::Id>, S::Error> { - // The basic idea is this: - // - // First, layout a full screen of blocks around self.last_cursor, using - // self.last_cursor_line for offset positioning. At this point, any - // outstanding scrolling is performed as well. - // - // Then, check if self.cursor is somewhere in these blocks. If it is, we - // now know the position of our own cursor. If it is not, it has jumped - // too far away from self.last_cursor and we'll need to render a new - // full screen of blocks around self.cursor before proceeding, using the - // cursor paths to determine the position of self.cursor on the screen. - // - // Now that we have a more-or-less accurate screen position of - // self.cursor, we can perform the actual cursor logic, i.e. make the - // cursor visible or move it so it is visible. - // - // This entire process is complicated by the different kinds of cursors. - - let context = Context { nick, focused }; - - let last_cursor_path = self.cursor_path(&self.last_cursor).await?; - let cursor_path = self.cursor_path(&self.cursor).await?; - self.make_path_visible(&cursor_path); - - let mut blocks = self - .layout_initial_seed(&context, frame, &last_cursor_path, &cursor_path) - .await?; - blocks.blocks_mut().offset(self.scroll); - self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks) - .await?; - - if !self.contains_cursor(&blocks) { - blocks = self - .layout_cursor_seed(&context, frame, &last_cursor_path, &cursor_path) - .await?; - self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks) - .await?; - } - - match self.correction { - Some(Correction::MakeCursorVisible) => { - self.scroll_so_cursor_is_visible(frame, &mut blocks); - self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks) - .await?; - } - Some(Correction::MoveCursorToVisibleArea) => { - let new_cursor_msg_id = self.move_cursor_so_it_is_visible(frame, &blocks); - if let Some(cursor_msg_id) = new_cursor_msg_id { - // Moving the cursor invalidates our current blocks, so we sadly - // have to either perform an expensive operation or redraw the - // entire thing. I'm choosing the latter for now. - - self.last_cursor = self.cursor.clone(); - self.last_cursor_line = self.cursor_line(&blocks); - self.last_visible_msgs = Self::visible_msgs(frame, &blocks); - self.scroll = 0; - self.correction = None; - - let last_cursor_path = self.store.path(&cursor_msg_id).await?; - blocks = self - .layout_last_cursor_seed(&context, frame, &last_cursor_path) - .await?; - self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks) - .await?; - } - } - Some(Correction::CenterCursor) => { - self.scroll_so_cursor_is_centered(frame, &mut blocks); - self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks) - .await?; - } - None => {} - } - - self.last_cursor = self.cursor.clone(); - self.last_cursor_line = self.cursor_line(&blocks); - self.last_visible_msgs = Self::visible_msgs(frame, &blocks); - self.scroll = 0; - self.correction = None; - - Ok(blocks) - } -} diff --git a/src/ui/chat/tree/tree_blocks.rs b/src/ui/chat/tree/tree_blocks.rs deleted file mode 100644 index 69b98ec..0000000 --- a/src/ui/chat/tree/tree_blocks.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::ui::chat::blocks::Blocks; - -use super::Cursor; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlockId<I> { - Msg(I), - Cursor, - LastCursor, -} - -impl<I: Clone> BlockId<I> { - pub fn from_cursor(cursor: &Cursor<I>) -> Self { - match cursor { - Cursor::Msg(id) => Self::Msg(id.clone()), - _ => Self::Cursor, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum Root<I> { - Bottom, - Tree(I), -} - -pub struct TreeBlocks<I> { - blocks: Blocks<BlockId<I>>, - top_root: Root<I>, - bottom_root: Root<I>, -} - -impl<I> TreeBlocks<I> { - pub fn new(top_root: Root<I>, bottom_root: Root<I>) -> Self { - Self { - blocks: Blocks::new(), - top_root, - bottom_root, - } - } - - pub fn blocks(&self) -> &Blocks<BlockId<I>> { - &self.blocks - } - - pub fn blocks_mut(&mut self) -> &mut Blocks<BlockId<I>> { - &mut self.blocks - } - - pub fn into_blocks(self) -> Blocks<BlockId<I>> { - self.blocks - } - - pub fn top_root(&self) -> &Root<I> { - &self.top_root - } - - pub fn bottom_root(&self) -> &Root<I> { - &self.bottom_root - } - - pub fn prepend(&mut self, other: Self) { - self.blocks.prepend(other.blocks); - self.top_root = other.top_root; - } - - pub fn append(&mut self, other: Self) { - self.blocks.append(other.blocks); - self.bottom_root = other.bottom_root; - } -} diff --git a/src/ui/chat/tree/widgets.rs b/src/ui/chat/tree/widgets.rs deleted file mode 100644 index 8854dcd..0000000 --- a/src/ui/chat/tree/widgets.rs +++ /dev/null @@ -1,160 +0,0 @@ -mod indent; -mod seen; -mod time; - -use crossterm::style::{ContentStyle, Stylize}; -use toss::styled::Styled; -use toss::widthdb::WidthDb; - -use super::super::ChatMsg; -use crate::store::Msg; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::join::{HJoin, Segment}; -use crate::ui::widgets::layer::Layer; -use crate::ui::widgets::padding::Padding; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -use self::indent::Indent; - -pub const PLACEHOLDER: &str = "[...]"; - -pub fn style_placeholder() -> ContentStyle { - ContentStyle::default().dark_grey() -} - -fn style_time(highlighted: bool) -> ContentStyle { - if highlighted { - ContentStyle::default().black().on_white() - } else { - ContentStyle::default().grey() - } -} - -fn style_indent(highlighted: bool) -> ContentStyle { - if highlighted { - ContentStyle::default().black().on_white() - } else { - ContentStyle::default().dark_grey() - } -} - -fn style_info() -> ContentStyle { - ContentStyle::default().italic().dark_grey() -} - -fn style_editor_highlight() -> ContentStyle { - ContentStyle::default().black().on_cyan() -} - -fn style_pseudo_highlight() -> ContentStyle { - ContentStyle::default().black().on_yellow() -} - -pub fn msg<M: Msg + ChatMsg>( - highlighted: bool, - indent: usize, - msg: &M, - folded_info: Option<usize>, -) -> BoxedWidget { - let (nick, mut content) = msg.styled(); - - if let Some(amount) = folded_info { - content = content - .then_plain("\n") - .then(format!("[{amount} more]"), style_info()); - } - - HJoin::new(vec![ - Segment::new(seen::widget(msg.seen())), - Segment::new( - Padding::new(time::widget(Some(msg.time()), style_time(highlighted))) - .stretch(true) - .right(1), - ), - Segment::new(Indent::new(indent, style_indent(highlighted))), - Segment::new(Layer::new(vec![ - Indent::new(1, style_indent(false)).into(), - Padding::new(Text::new(nick)).right(1).into(), - ])), - // TODO Minimum content width - // TODO Minimizing and maximizing messages - Segment::new(Text::new(content).wrap(true)).priority(1), - ]) - .into() -} - -pub fn msg_placeholder( - highlighted: bool, - indent: usize, - folded_info: Option<usize>, -) -> BoxedWidget { - let mut content = Styled::new(PLACEHOLDER, style_placeholder()); - - if let Some(amount) = folded_info { - content = content - .then_plain("\n") - .then(format!("[{amount} more]"), style_info()); - } - - HJoin::new(vec![ - Segment::new(seen::widget(true)), - Segment::new( - Padding::new(time::widget(None, style_time(highlighted))) - .stretch(true) - .right(1), - ), - Segment::new(Indent::new(indent, style_indent(highlighted))), - Segment::new(Text::new(content)), - ]) - .into() -} - -pub fn editor<M: ChatMsg>( - widthdb: &mut WidthDb, - indent: usize, - nick: &str, - editor: &EditorState, -) -> (BoxedWidget, usize) { - let (nick, content) = M::edit(nick, &editor.text()); - let editor = editor.widget().highlight(|_| content); - let cursor_row = editor.cursor_row(widthdb); - - let widget = HJoin::new(vec![ - Segment::new(seen::widget(true)), - Segment::new( - Padding::new(time::widget(None, style_editor_highlight())) - .stretch(true) - .right(1), - ), - Segment::new(Indent::new(indent, style_editor_highlight())), - Segment::new(Layer::new(vec![ - Indent::new(1, style_indent(false)).into(), - Padding::new(Text::new(nick)).right(1).into(), - ])), - Segment::new(editor).priority(1).expanding(true), - ]) - .into(); - - (widget, cursor_row) -} - -pub fn pseudo<M: ChatMsg>(indent: usize, nick: &str, editor: &EditorState) -> BoxedWidget { - let (nick, content) = M::edit(nick, &editor.text()); - - HJoin::new(vec![ - Segment::new(seen::widget(true)), - Segment::new( - Padding::new(time::widget(None, style_pseudo_highlight())) - .stretch(true) - .right(1), - ), - Segment::new(Indent::new(indent, style_pseudo_highlight())), - Segment::new(Layer::new(vec![ - Indent::new(1, style_indent(false)).into(), - Padding::new(Text::new(nick)).right(1).into(), - ])), - Segment::new(Text::new(content).wrap(true)).priority(1), - ]) - .into() -} diff --git a/src/ui/chat/tree/widgets/indent.rs b/src/ui/chat/tree/widgets/indent.rs deleted file mode 100644 index d512102..0000000 --- a/src/ui/chat/tree/widgets/indent.rs +++ /dev/null @@ -1,37 +0,0 @@ -use async_trait::async_trait; -use crossterm::style::ContentStyle; -use toss::frame::{Frame, Pos, Size}; - -use crate::ui::widgets::Widget; - -pub const INDENT: &str = "│ "; -pub const INDENT_WIDTH: usize = 2; - -pub struct Indent { - level: usize, - style: ContentStyle, -} - -impl Indent { - pub fn new(level: usize, style: ContentStyle) -> Self { - Self { level, style } - } -} - -#[async_trait] -impl Widget for Indent { - fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size { - Size::new((INDENT_WIDTH * self.level) as u16, 0) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - for y in 0..size.height { - frame.write( - Pos::new(0, y.into()), - (INDENT.repeat(self.level), self.style), - ) - } - } -} diff --git a/src/ui/chat/tree/widgets/seen.rs b/src/ui/chat/tree/widgets/seen.rs deleted file mode 100644 index 8197afd..0000000 --- a/src/ui/chat/tree/widgets/seen.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crossterm::style::{ContentStyle, Stylize}; - -use crate::ui::widgets::background::Background; -use crate::ui::widgets::empty::Empty; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -const UNSEEN: &str = "*"; -const WIDTH: u16 = 1; - -fn seen_style() -> ContentStyle { - ContentStyle::default().black().on_green() -} - -pub fn widget(seen: bool) -> BoxedWidget { - if seen { - Empty::new().width(WIDTH).into() - } else { - let style = seen_style(); - Background::new(Text::new((UNSEEN, style))) - .style(style) - .into() - } -} diff --git a/src/ui/chat/tree/widgets/time.rs b/src/ui/chat/tree/widgets/time.rs deleted file mode 100644 index 0976197..0000000 --- a/src/ui/chat/tree/widgets/time.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crossterm::style::ContentStyle; -use time::format_description::FormatItem; -use time::macros::format_description; -use time::OffsetDateTime; - -use crate::ui::widgets::background::Background; -use crate::ui::widgets::empty::Empty; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]"); -const TIME_WIDTH: u16 = 16; - -pub fn widget(time: Option<OffsetDateTime>, style: ContentStyle) -> BoxedWidget { - if let Some(time) = time { - let text = time.format(TIME_FORMAT).expect("could not format time"); - Background::new(Text::new((text, style))) - .style(style) - .into() - } else { - Background::new(Empty::new().width(TIME_WIDTH)) - .style(style) - .into() - } -} diff --git a/src/ui/euph/account.rs b/src/ui/euph/account.rs deleted file mode 100644 index 3112719..0000000 --- a/src/ui/euph/account.rs +++ /dev/null @@ -1,209 +0,0 @@ -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::PersonalAccountView; -use euphoxide::conn; -use toss::terminal::Terminal; - -use crate::euph::{self, Room}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::util; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::empty::Empty; -use crate::ui::widgets::join::{HJoin, Segment, VJoin}; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::resize::Resize; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Focus { - Email, - Password, -} - -pub struct LoggedOut { - focus: Focus, - email: EditorState, - password: EditorState, -} - -impl LoggedOut { - fn new() -> Self { - Self { - focus: Focus::Email, - email: EditorState::new(), - password: EditorState::new(), - } - } - - fn widget(&self) -> BoxedWidget { - let bold = ContentStyle::default().bold(); - VJoin::new(vec![ - Segment::new(Text::new(("Not logged in", bold.yellow()))), - Segment::new(Empty::new().height(1)), - Segment::new(HJoin::new(vec![ - Segment::new(Text::new(("Email address:", bold))), - Segment::new(Empty::new().width(1)), - Segment::new(self.email.widget().focus(self.focus == Focus::Email)), - ])), - Segment::new(HJoin::new(vec![ - Segment::new(Text::new(("Password:", bold))), - Segment::new(Empty::new().width(5 + 1)), - Segment::new( - self.password - .widget() - .focus(self.focus == Focus::Password) - .hidden(), - ), - ])), - ]) - .into() - } -} - -pub struct LoggedIn(PersonalAccountView); - -impl LoggedIn { - fn widget(&self) -> BoxedWidget { - let bold = ContentStyle::default().bold(); - VJoin::new(vec![ - Segment::new(Text::new(("Logged in", bold.green()))), - Segment::new(Empty::new().height(1)), - Segment::new(HJoin::new(vec![ - Segment::new(Text::new(("Email address:", bold))), - Segment::new(Empty::new().width(1)), - Segment::new(Text::new((&self.0.email,))), - ])), - ]) - .into() - } -} - -pub enum AccountUiState { - LoggedOut(LoggedOut), - LoggedIn(LoggedIn), -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - -impl AccountUiState { - pub fn new() -> Self { - Self::LoggedOut(LoggedOut::new()) - } - - /// Returns `false` if the account UI should not be displayed any longer. - pub fn stabilize(&mut self, state: Option<&euph::State>) -> bool { - if let Some(euph::State::Connected(_, conn::State::Joined(state))) = state { - match (&self, &state.account) { - (Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())), - (Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()), - _ => {} - } - true - } else { - false - } - } - - pub fn widget(&self) -> BoxedWidget { - let inner = match self { - Self::LoggedOut(logged_out) => logged_out.widget(), - Self::LoggedIn(logged_in) => logged_in.widget(), - }; - Popup::new(Resize::new(inner).min_width(40)) - .title("Account") - .build() - } - - pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close account ui"); - - match self { - Self::LoggedOut(logged_out) => { - match logged_out.focus { - Focus::Email => bindings.binding("enter", "focus on password"), - Focus::Password => bindings.binding("enter", "log in"), - } - bindings.binding("tab", "switch focus"); - util::list_editor_key_bindings(bindings, |c| c != '\n'); - } - Self::LoggedIn(_) => bindings.binding("L", "log out"), - } - } - - pub fn handle_input_event( - &mut self, - terminal: &mut Terminal, - event: &InputEvent, - room: &Option<Room>, - ) -> EventResult { - if let key!(Esc) = event { - return EventResult::ResetState; - } - - match self { - Self::LoggedOut(logged_out) => { - if let key!(Tab) = event { - logged_out.focus = match logged_out.focus { - Focus::Email => Focus::Password, - Focus::Password => Focus::Email, - }; - return EventResult::Handled; - } - - match logged_out.focus { - Focus::Email => { - if let key!(Enter) = event { - logged_out.focus = Focus::Password; - return EventResult::Handled; - } - - if util::handle_editor_input_event( - &logged_out.email, - terminal, - event, - |c| c != '\n', - ) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } - Focus::Password => { - if let key!(Enter) = event { - if let Some(room) = room { - let _ = - room.login(logged_out.email.text(), logged_out.password.text()); - } - return EventResult::Handled; - } - - if util::handle_editor_input_event( - &logged_out.password, - terminal, - event, - |c| c != '\n', - ) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } - } - } - Self::LoggedIn(_) => { - if let key!('L') = event { - if let Some(room) = room { - let _ = room.logout(); - } - EventResult::Handled - } else { - EventResult::NotHandled - } - } - } - } -} diff --git a/src/ui/euph/auth.rs b/src/ui/euph/auth.rs deleted file mode 100644 index b9b72b9..0000000 --- a/src/ui/euph/auth.rs +++ /dev/null @@ -1,54 +0,0 @@ -use toss::terminal::Terminal; - -use crate::euph::Room; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::util; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::BoxedWidget; - -pub fn new() -> EditorState { - EditorState::new() -} - -pub fn widget(editor: &EditorState) -> BoxedWidget { - Popup::new(editor.widget().hidden()) - .title("Enter password") - .build() -} - -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "abort"); - bindings.binding("enter", "authenticate"); - util::list_editor_key_bindings(bindings, |_| true); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - -pub fn handle_input_event( - terminal: &mut Terminal, - event: &InputEvent, - room: &Option<Room>, - editor: &EditorState, -) -> EventResult { - match event { - key!(Esc) => EventResult::ResetState, - key!(Enter) => { - if let Some(room) = &room { - let _ = room.auth(editor.text()); - } - EventResult::ResetState - } - _ => { - if util::handle_editor_input_event(editor, terminal, event, |_| true) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } - } -} diff --git a/src/ui/euph/links.rs b/src/ui/euph/links.rs deleted file mode 100644 index b1ebcb0..0000000 --- a/src/ui/euph/links.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::io; - -use crossterm::style::{ContentStyle, Stylize}; -use linkify::{LinkFinder, LinkKind}; -use toss::styled::Styled; - -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::widgets::list::ListState; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -pub struct LinksState { - links: Vec<String>, - list: ListState<usize>, -} - -pub enum EventResult { - NotHandled, - Handled, - Close, - ErrorOpeningLink { link: String, error: io::Error }, -} - -const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; - -impl LinksState { - pub fn new(content: &str) -> Self { - let links = LinkFinder::new() - .url_must_have_scheme(false) - .kinds(&[LinkKind::Url]) - .links(content) - .map(|l| l.as_str().to_string()) - .collect(); - - Self { - links, - list: ListState::new(), - } - } - - pub fn widget(&self) -> BoxedWidget { - let style_selected = ContentStyle::default().black().on_white(); - - let mut list = self.list.widget().focus(true); - if self.links.is_empty() { - list.add_unsel(Text::new(( - "No links found", - ContentStyle::default().grey().italic(), - ))) - } - for (id, link) in self.links.iter().enumerate() { - let (line_normal, line_selected) = if let Some(number_key) = NUMBER_KEYS.get(id) { - ( - Styled::new( - format!("[{number_key}]"), - ContentStyle::default().dark_grey().bold(), - ) - .then_plain(" ") - .then_plain(link), - Styled::new(format!("[{number_key}]"), style_selected.bold()) - .then(" ", style_selected) - .then(link, style_selected), - ) - } else { - ( - Styled::new_plain(format!(" {link}")), - Styled::new(format!(" {link}"), style_selected), - ) - }; - - list.add_sel(id, Text::new(line_normal), Text::new(line_selected)); - } - - Popup::new(list).title("Links").build() - } - - fn open_link_by_id(&self, id: usize) -> EventResult { - if let Some(link) = self.links.get(id) { - // The `http://` or `https://` schema is necessary for open::that to - // successfully open the link in the browser. - let link = if link.starts_with("http://") || link.starts_with("https://") { - link.clone() - } else { - format!("https://{link}") - }; - - if let Err(error) = open::that(&link) { - return EventResult::ErrorOpeningLink { link, error }; - } - } - EventResult::Handled - } - - fn open_link(&self) -> EventResult { - if let Some(id) = self.list.cursor() { - self.open_link_by_id(id) - } else { - EventResult::Handled - } - } - - pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close links popup"); - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down"); - bindings.empty(); - bindings.binding("enter", "open selected link"); - bindings.binding("1,2,...", "open link by position"); - } - - pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult { - match event { - key!(Esc) => return EventResult::Close, - key!('k') | key!(Up) => self.list.move_cursor_up(), - key!('j') | key!(Down) => self.list.move_cursor_down(), - key!('g') | key!(Home) => self.list.move_cursor_to_top(), - key!('G') | key!(End) => self.list.move_cursor_to_bottom(), - key!(Ctrl + 'y') => self.list.scroll_up(1), - key!(Ctrl + 'e') => self.list.scroll_down(1), - key!(Enter) => return self.open_link(), - key!('1') => return self.open_link_by_id(0), - key!('2') => return self.open_link_by_id(1), - key!('3') => return self.open_link_by_id(2), - key!('4') => return self.open_link_by_id(3), - key!('5') => return self.open_link_by_id(4), - key!('6') => return self.open_link_by_id(5), - key!('7') => return self.open_link_by_id(6), - key!('8') => return self.open_link_by_id(7), - key!('9') => return self.open_link_by_id(8), - key!('0') => return self.open_link_by_id(9), - _ => return EventResult::NotHandled, - } - EventResult::Handled - } -} diff --git a/src/ui/euph/nick.rs b/src/ui/euph/nick.rs deleted file mode 100644 index e520fef..0000000 --- a/src/ui/euph/nick.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crossterm::style::ContentStyle; -use euphoxide::conn::Joined; -use toss::terminal::Terminal; - -use crate::euph::{self, Room}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::util; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::padding::Padding; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::BoxedWidget; - -pub fn new(joined: Joined) -> EditorState { - EditorState::with_initial_text(joined.session.name) -} - -pub fn widget(editor: &EditorState) -> BoxedWidget { - let editor = editor - .widget() - .highlight(|s| euph::style_nick_exact(s, ContentStyle::default())); - Popup::new(Padding::new(editor).left(1)) - .title("Choose nick") - .inner_padding(false) - .build() -} - -fn nick_char(c: char) -> bool { - c != '\n' -} - -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "abort"); - bindings.binding("enter", "set nick"); - util::list_editor_key_bindings(bindings, nick_char); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - -pub fn handle_input_event( - terminal: &mut Terminal, - event: &InputEvent, - room: &Option<Room>, - editor: &EditorState, -) -> EventResult { - match event { - key!(Esc) => EventResult::ResetState, - key!(Enter) => { - if let Some(room) = &room { - let _ = room.nick(editor.text()); - } - EventResult::ResetState - } - _ => { - if util::handle_editor_input_event(editor, terminal, event, nick_char) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } - } -} diff --git a/src/ui/euph/nick_list.rs b/src/ui/euph/nick_list.rs deleted file mode 100644 index 926ca68..0000000 --- a/src/ui/euph/nick_list.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::borrow::Cow; -use std::iter; - -use crossterm::style::{Color, ContentStyle, Stylize}; -use euphoxide::api::{NickEvent, SessionId, SessionType, SessionView, UserId}; -use euphoxide::conn::{Joined, SessionInfo}; -use toss::styled::Styled; - -use crate::euph; -use crate::ui::widgets::background::Background; -use crate::ui::widgets::empty::Empty; -use crate::ui::widgets::list::{List, ListState}; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -pub fn widget(state: &ListState<SessionId>, joined: &Joined, focused: bool) -> BoxedWidget { - let mut list = state.widget().focus(focused); - render_rows(&mut list, joined); - list.into() -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] -struct HalfSession { - name: String, - id: UserId, - session_id: SessionId, - is_staff: bool, - is_manager: bool, -} - -impl HalfSession { - fn from_session_view(sess: &SessionView) -> Self { - Self { - name: sess.name.clone(), - id: sess.id.clone(), - session_id: sess.session_id.clone(), - is_staff: sess.is_staff, - is_manager: sess.is_manager, - } - } - - fn from_nick_event(nick: &NickEvent) -> Self { - Self { - name: nick.to.clone(), - id: nick.id.clone(), - session_id: nick.session_id.clone(), - is_staff: false, - is_manager: false, - } - } - - fn from_session_info(info: &SessionInfo) -> Self { - match info { - SessionInfo::Full(sess) => Self::from_session_view(sess), - SessionInfo::Partial(nick) => Self::from_nick_event(nick), - } - } -} - -fn render_rows(list: &mut List<SessionId>, joined: &Joined) { - let mut people = vec![]; - let mut bots = vec![]; - let mut lurkers = vec![]; - let mut nurkers = vec![]; - - let sessions = joined - .listing - .values() - .map(HalfSession::from_session_info) - .chain(iter::once(HalfSession::from_session_view(&joined.session))); - for sess in sessions { - match sess.id.session_type() { - Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess), - Some(SessionType::Bot) => bots.push(sess), - _ if sess.name.is_empty() => lurkers.push(sess), - _ => people.push(sess), - } - } - - people.sort_unstable(); - bots.sort_unstable(); - lurkers.sort_unstable(); - nurkers.sort_unstable(); - - render_section(list, "People", &people, &joined.session); - render_section(list, "Bots", &bots, &joined.session); - render_section(list, "Lurkers", &lurkers, &joined.session); - render_section(list, "Nurkers", &nurkers, &joined.session); -} - -fn render_section( - list: &mut List<SessionId>, - name: &str, - sessions: &[HalfSession], - own_session: &SessionView, -) { - if sessions.is_empty() { - return; - } - - let heading_style = ContentStyle::new().bold(); - - if !list.is_empty() { - list.add_unsel(Empty::new()); - } - - let row = Styled::new_plain(" ") - .then(name, heading_style) - .then_plain(format!(" ({})", sessions.len())); - list.add_unsel(Text::new(row)); - - for session in sessions { - render_row(list, session, own_session); - } -} - -fn render_row(list: &mut List<SessionId>, session: &HalfSession, own_session: &SessionView) { - let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() { - let name = "lurk"; - let style = ContentStyle::default().grey(); - let style_inv = ContentStyle::default().black().on_grey(); - (Cow::Borrowed(name), style, style_inv, style_inv) - } else { - let name = &session.name as &str; - let (r, g, b) = euph::nick_color(name); - let color = Color::Rgb { r, g, b }; - let style = ContentStyle::default().bold().with(color); - let style_inv = ContentStyle::default().bold().black().on(color); - let perms_style_inv = ContentStyle::default().black().on(color); - (euph::EMOJI.replace(name), style, style_inv, perms_style_inv) - }; - - let perms = if session.is_staff { - "!" - } else if session.is_manager { - "*" - } else if session.id.session_type() == Some(SessionType::Account) { - "~" - } else { - "" - }; - - let owner = if session.session_id == own_session.session_id { - ">" - } else { - " " - }; - - let normal = Styled::new_plain(owner) - .then(&name, style) - .then_plain(perms); - let selected = Styled::new_plain(owner) - .then(name, style_inv) - .then(perms, perms_style_inv); - list.add_sel( - session.session_id.clone(), - Text::new(normal), - Background::new(Text::new(selected)).style(style_inv), - ); -} diff --git a/src/ui/euph/popup.rs b/src/ui/euph/popup.rs deleted file mode 100644 index 8bc8c6c..0000000 --- a/src/ui/euph/popup.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crossterm::style::{ContentStyle, Stylize}; -use toss::styled::Styled; - -use crate::ui::widgets::float::Float; -use crate::ui::widgets::popup::Popup; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; - -pub enum RoomPopup { - Error { description: String, reason: String }, -} - -impl RoomPopup { - fn server_error_widget(description: &str, reason: &str) -> BoxedWidget { - let border_style = ContentStyle::default().red().bold(); - let text = Styled::new_plain(description) - .then_plain("\n\n") - .then("Reason:", ContentStyle::default().bold()) - .then_plain(" ") - .then_plain(reason); - Popup::new(Text::new(text)) - .title(("Error", border_style)) - .border(border_style) - .build() - } - - pub fn widget(&self) -> BoxedWidget { - let widget = match self { - Self::Error { - description, - reason, - } => Self::server_error_widget(description, reason), - }; - - Float::new(widget).horizontal(0.5).vertical(0.5).into() - } -} diff --git a/src/ui/euph/room.rs b/src/ui/euph/room.rs deleted file mode 100644 index 740d9f7..0000000 --- a/src/ui/euph/room.rs +++ /dev/null @@ -1,782 +0,0 @@ -use std::collections::VecDeque; -use std::sync::Arc; - -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId}; -use euphoxide::bot::instance::{Event, ServerConfig}; -use euphoxide::conn::{self, Joined, Joining, SessionInfo}; -use parking_lot::FairMutex; -use tokio::sync::oneshot::error::TryRecvError; -use tokio::sync::{mpsc, oneshot}; -use toss::styled::Styled; -use toss::terminal::Terminal; - -use crate::config; -use crate::euph; -use crate::macros::logging_unwrap; -use crate::ui::chat::{ChatState, Reaction}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; -use crate::ui::widgets::border::Border; -use crate::ui::widgets::editor::EditorState; -use crate::ui::widgets::join::{HJoin, Segment, VJoin}; -use crate::ui::widgets::layer::Layer; -use crate::ui::widgets::list::ListState; -use crate::ui::widgets::padding::Padding; -use crate::ui::widgets::text::Text; -use crate::ui::widgets::BoxedWidget; -use crate::ui::{util, UiEvent}; -use crate::vault::EuphRoomVault; - -use super::account::{self, AccountUiState}; -use super::links::{self, LinksState}; -use super::popup::RoomPopup; -use super::{auth, inspect, nick, nick_list}; - -#[derive(Debug, PartialEq, Eq)] -enum Focus { - Chat, - NickList, -} - -#[allow(clippy::large_enum_variant)] -enum State { - Normal, - Auth(EditorState), - Nick(EditorState), - Account(AccountUiState), - Links(LinksState), - InspectMessage(Message), - InspectSession(SessionInfo), -} - -pub struct EuphRoom { - server_config: ServerConfig, - config: config::EuphRoom, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - - room: Option<euph::Room>, - - focus: Focus, - state: State, - popups: VecDeque<RoomPopup>, - - chat: ChatState<euph::SmallMessage, EuphRoomVault>, - last_msg_sent: Option<oneshot::Receiver<MessageId>>, - - nick_list: ListState<SessionId>, -} - -impl EuphRoom { - pub fn new( - server_config: ServerConfig, - config: config::EuphRoom, - vault: EuphRoomVault, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - ) -> Self { - Self { - server_config, - config, - ui_event_tx, - room: None, - focus: Focus::Chat, - state: State::Normal, - popups: VecDeque::new(), - chat: ChatState::new(vault), - last_msg_sent: None, - nick_list: ListState::new(), - } - } - - fn vault(&self) -> &EuphRoomVault { - self.chat.store() - } - - fn name(&self) -> &str { - self.vault().room() - } - - pub fn connect(&mut self, next_instance_id: &mut usize) { - if self.room.is_none() { - let room = self.vault().room(); - let instance_config = self - .server_config - .clone() - .room(self.vault().room().to_string()) - .name(format!("{room}-{}", next_instance_id)) - .human(true) - .username(self.config.username.clone()) - .force_username(self.config.force_username) - .password(self.config.password.clone()); - *next_instance_id = next_instance_id.wrapping_add(1); - - let tx = self.ui_event_tx.clone(); - self.room = Some(euph::Room::new( - self.vault().clone(), - instance_config, - move |e| { - let _ = tx.send(UiEvent::Euph(e)); - }, - )); - } - } - - pub fn disconnect(&mut self) { - self.room = None; - } - - pub fn room_state(&self) -> Option<&euph::State> { - if let Some(room) = &self.room { - Some(room.state()) - } else { - None - } - } - - // TODO fn room_state_joined(&self) -> Option<&Joined> {} - - pub fn stopped(&self) -> bool { - self.room.as_ref().map(|r| r.stopped()).unwrap_or(true) - } - - pub fn retain(&mut self) { - if let Some(room) = &self.room { - if room.stopped() { - self.room = None; - } - } - } - - pub async fn unseen_msgs_count(&self) -> usize { - logging_unwrap!(self.vault().unseen_msgs_count().await) - } - - async fn stabilize_pseudo_msg(&mut self) { - if let Some(id_rx) = &mut self.last_msg_sent { - match id_rx.try_recv() { - Ok(id) => { - self.chat.sent(Some(id)).await; - self.last_msg_sent = None; - } - Err(TryRecvError::Empty) => {} // Wait a bit longer - Err(TryRecvError::Closed) => { - self.chat.sent(None).await; - self.last_msg_sent = None; - } - } - } - } - - fn stabilize_focus(&mut self) { - match self.room_state() { - Some(euph::State::Connected(_, conn::State::Joined(_))) => {} - _ => self.focus = Focus::Chat, // There is no nick list to focus on - } - } - - fn stabilize_state(&mut self) { - let room_state = self.room.as_ref().map(|r| r.state()); - match (&mut self.state, room_state) { - ( - State::Auth(_), - Some(euph::State::Connected( - _, - conn::State::Joining(Joining { - bounce: Some(_), .. - }), - )), - ) => {} // Nothing to see here - (State::Auth(_), _) => self.state = State::Normal, - - (State::Nick(_), Some(euph::State::Connected(_, conn::State::Joined(_)))) => {} - (State::Nick(_), _) => self.state = State::Normal, - - (State::Account(account), state) => { - if !account.stabilize(state) { - self.state = State::Normal - } - } - - _ => {} - } - } - - async fn stabilize(&mut self) { - self.stabilize_pseudo_msg().await; - self.stabilize_focus(); - self.stabilize_state(); - } - - pub async fn widget(&mut self) -> BoxedWidget { - self.stabilize().await; - - let room_state = self.room_state(); - let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state - { - self.widget_with_nick_list(room_state, joined).await - } else { - self.widget_without_nick_list(room_state).await - }; - - let mut layers = vec![chat]; - - match &self.state { - State::Normal => {} - State::Auth(editor) => layers.push(auth::widget(editor)), - State::Nick(editor) => layers.push(nick::widget(editor)), - State::Account(account) => layers.push(account.widget()), - State::Links(links) => layers.push(links.widget()), - State::InspectMessage(message) => layers.push(inspect::message_widget(message)), - State::InspectSession(session) => layers.push(inspect::session_widget(session)), - } - - for popup in &self.popups { - layers.push(popup.widget()); - } - - Layer::new(layers).into() - } - - async fn widget_without_nick_list(&self, state: Option<&euph::State>) -> BoxedWidget { - VJoin::new(vec![ - Segment::new(Border::new( - Padding::new(self.status_widget(state).await).horizontal(1), - )), - // TODO Use last known nick? - Segment::new(self.chat.widget(String::new(), true)).expanding(true), - ]) - .into() - } - - async fn widget_with_nick_list( - &self, - state: Option<&euph::State>, - joined: &Joined, - ) -> BoxedWidget { - HJoin::new(vec![ - Segment::new(VJoin::new(vec![ - Segment::new(Border::new( - Padding::new(self.status_widget(state).await).horizontal(1), - )), - Segment::new( - self.chat - .widget(joined.session.name.clone(), self.focus == Focus::Chat), - ) - .expanding(true), - ])) - .expanding(true), - Segment::new(Border::new( - Padding::new(nick_list::widget( - &self.nick_list, - joined, - self.focus == Focus::NickList, - )) - .right(1), - )), - ]) - .into() - } - - async fn status_widget(&self, state: Option<&euph::State>) -> BoxedWidget { - let room_style = ContentStyle::default().bold().blue(); - let mut info = Styled::new(format!("&{}", self.name()), room_style); - - info = match state { - None | Some(euph::State::Stopped) => info.then_plain(", archive"), - Some(euph::State::Disconnected) => info.then_plain(", waiting..."), - Some(euph::State::Connecting) => info.then_plain(", connecting..."), - Some(euph::State::Connected(_, conn::State::Joining(j))) if j.bounce.is_some() => { - info.then_plain(", auth required") - } - Some(euph::State::Connected(_, conn::State::Joining(_))) => { - info.then_plain(", joining...") - } - Some(euph::State::Connected(_, conn::State::Joined(j))) => { - let nick = &j.session.name; - if nick.is_empty() { - info.then_plain(", present without nick") - } else { - info.then_plain(", present as ") - .and_then(euph::style_nick(nick, ContentStyle::default())) - } - } - }; - - let unseen = self.unseen_msgs_count().await; - if unseen > 0 { - info = info - .then_plain(" (") - .then(format!("{unseen}"), ContentStyle::default().bold().green()) - .then_plain(")"); - } - - Text::new(info).into() - } - - async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) { - let can_compose = matches!( - self.room_state(), - Some(euph::State::Connected(_, conn::State::Joined(_))) - ); - self.chat.list_key_bindings(bindings, can_compose).await; - } - - async fn handle_chat_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - let can_compose = matches!( - self.room_state(), - Some(euph::State::Connected(_, conn::State::Joined(_))) - ); - - let reaction = self - .chat - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await; - let reaction = logging_unwrap!(reaction); - - match reaction { - Reaction::NotHandled => {} - Reaction::Handled => return true, - Reaction::Composed { parent, content } => { - if let Some(room) = &self.room { - match room.send(parent, content) { - Ok(id_rx) => self.last_msg_sent = Some(id_rx), - Err(_) => self.chat.sent(None).await, - } - return true; - } - } - Reaction::ComposeError(e) => { - self.popups.push_front(RoomPopup::Error { - description: "Failed to use external editor".to_string(), - reason: format!("{e}"), - }); - return true; - } - } - - false - } - - fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList) { - match self.room_state() { - // Authenticating - Some(euph::State::Connected( - _, - conn::State::Joining(Joining { - bounce: Some(_), .. - }), - )) => { - bindings.binding("a", "authenticate"); - } - - // Connected - Some(euph::State::Connected(_, conn::State::Joined(_))) => { - bindings.binding("n", "change nick"); - bindings.binding("m", "download more messages"); - bindings.binding("A", "show account ui"); - } - - // Otherwise - _ => {} - } - - // Inspecting messages - bindings.binding("i", "inspect message"); - bindings.binding("I", "show message links"); - bindings.binding("ctrl+p", "open room's plugh.de/present page"); - } - - async fn handle_room_input_event(&mut self, event: &InputEvent) -> bool { - match self.room_state() { - // Authenticating - Some(euph::State::Connected( - _, - conn::State::Joining(Joining { - bounce: Some(_), .. - }), - )) => { - if let key!('a') = event { - self.state = State::Auth(auth::new()); - return true; - } - } - - // Joined - Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event { - key!('n') | key!('N') => { - self.state = State::Nick(nick::new(joined.clone())); - return true; - } - key!('m') => { - if let Some(room) = &self.room { - let _ = room.log(); - } - return true; - } - key!('A') => { - self.state = State::Account(AccountUiState::new()); - return true; - } - _ => {} - }, - - // Otherwise - _ => {} - } - - // Always applicable - match event { - key!('i') => { - if let Some(id) = self.chat.cursor().await { - if let Some(msg) = logging_unwrap!(self.vault().full_msg(id).await) { - self.state = State::InspectMessage(msg); - } - } - return true; - } - key!('I') => { - if let Some(id) = self.chat.cursor().await { - if let Some(msg) = logging_unwrap!(self.vault().msg(id).await) { - self.state = State::Links(LinksState::new(&msg.content)); - } - } - return true; - } - key!(Ctrl + 'p') => { - let link = format!("https://plugh.de/present/{}/", self.name()); - if let Err(error) = open::that(&link) { - self.popups.push_front(RoomPopup::Error { - description: format!("Failed to open link: {link}"), - reason: format!("{error}"), - }); - } - return true; - } - _ => {} - } - - false - } - - async fn list_chat_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { - self.list_room_key_bindings(bindings); - bindings.empty(); - self.list_chat_key_bindings(bindings).await; - } - - async fn handle_chat_focus_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - // We need to handle chat input first, otherwise the other - // key bindings will shadow characters in the editor. - if self - .handle_chat_input_event(terminal, crossterm_lock, event) - .await - { - return true; - } - - if self.handle_room_input_event(event).await { - return true; - } - - false - } - - fn list_nick_list_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { - util::list_list_key_bindings(bindings); - - bindings.binding("i", "inspect session"); - } - - fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool { - if util::handle_list_input_event(&mut self.nick_list, event) { - return true; - } - - if let key!('i') = event { - if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state() - { - if let Some(id) = self.nick_list.cursor() { - if id == joined.session.session_id { - self.state = - State::InspectSession(SessionInfo::Full(joined.session.clone())); - } else if let Some(session) = joined.listing.get(&id) { - self.state = State::InspectSession(session.clone()); - } - } - } - return true; - } - - false - } - - pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) { - // Handled in rooms list, not here - bindings.binding("esc", "leave room"); - - match self.focus { - Focus::Chat => { - if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { - bindings.binding("tab", "focus on nick list"); - } - - self.list_chat_focus_key_bindings(bindings).await; - } - Focus::NickList => { - bindings.binding("tab, esc", "focus on chat"); - bindings.empty(); - bindings.heading("Nick list"); - self.list_nick_list_focus_key_bindings(bindings); - } - } - } - - async fn handle_normal_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - match self.focus { - Focus::Chat => { - // Needs to be handled first or the tab key may be shadowed - // during editing. - if self - .handle_chat_focus_input_event(terminal, crossterm_lock, event) - .await - { - return true; - } - - if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { - if let key!(Tab) = event { - self.focus = Focus::NickList; - return true; - } - } - } - Focus::NickList => { - if let key!(Tab) | key!(Esc) = event { - self.focus = Focus::Chat; - return true; - } - - if self.handle_nick_list_focus_input_event(event) { - return true; - } - } - } - - false - } - - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.heading("Room"); - - if !self.popups.is_empty() { - bindings.binding("esc", "close popup"); - return; - } - - match &self.state { - State::Normal => self.list_normal_key_bindings(bindings).await, - State::Auth(_) => auth::list_key_bindings(bindings), - State::Nick(_) => nick::list_key_bindings(bindings), - State::Account(account) => account.list_key_bindings(bindings), - State::Links(links) => links.list_key_bindings(bindings), - State::InspectMessage(_) | State::InspectSession(_) => { - inspect::list_key_bindings(bindings) - } - } - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - if !self.popups.is_empty() { - if matches!(event, key!(Esc)) { - self.popups.pop_back(); - return true; - } - return false; - } - - // TODO Use a common EventResult - - match &mut self.state { - State::Normal => { - self.handle_normal_input_event(terminal, crossterm_lock, event) - .await - } - State::Auth(editor) => { - match auth::handle_input_event(terminal, event, &self.room, editor) { - auth::EventResult::NotHandled => false, - auth::EventResult::Handled => true, - auth::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Nick(editor) => { - match nick::handle_input_event(terminal, event, &self.room, editor) { - nick::EventResult::NotHandled => false, - nick::EventResult::Handled => true, - nick::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Account(account) => { - match account.handle_input_event(terminal, event, &self.room) { - account::EventResult::NotHandled => false, - account::EventResult::Handled => true, - account::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Links(links) => match links.handle_input_event(event) { - links::EventResult::NotHandled => false, - links::EventResult::Handled => true, - links::EventResult::Close => { - self.state = State::Normal; - true - } - links::EventResult::ErrorOpeningLink { link, error } => { - self.popups.push_front(RoomPopup::Error { - description: format!("Failed to open link: {link}"), - reason: format!("{error}"), - }); - true - } - }, - State::InspectMessage(_) | State::InspectSession(_) => { - match inspect::handle_input_event(event) { - inspect::EventResult::NotHandled => false, - inspect::EventResult::Close => { - self.state = State::Normal; - true - } - } - } - } - } - - pub async fn handle_event(&mut self, event: Event) -> bool { - let room = match &self.room { - None => return false, - Some(room) => room, - }; - - if event.config().name != room.instance().config().name { - // If we allowed names other than the current one, old instances - // that haven't yet shut down properly could mess up our state. - return false; - } - - // We handle the packet internally first because the room event handling - // will consume it while we only need a reference. - let handled = if let Event::Packet(_, packet, _) = &event { - match &packet.content { - Ok(data) => self.handle_euph_data(data), - Err(reason) => self.handle_euph_error(packet.r#type, reason), - } - } else { - // The room state changes, which always means a redraw. - true - }; - - self.room - .as_mut() - // See check at the beginning of the function. - .expect("no room even though we checked earlier") - .handle_event(event) - .await; - - handled - } - - fn handle_euph_data(&mut self, data: &Data) -> bool { - // These packets don't result in any noticeable change in the UI. - #[allow(clippy::match_like_matches_macro)] - let handled = match data { - Data::PingEvent(_) | Data::PingReply(_) => { - // Pings are displayed nowhere in the room UI. - false - } - Data::DisconnectEvent(_) => { - // Followed by the server closing the connection, meaning that - // we'll get an `EuphRoomEvent::Disconnected` soon after this. - false - } - _ => true, - }; - - // Because the euphoria API is very carefully designed with emphasis on - // consistency, some failures are not normal errors but instead - // error-free replies that encode their own error. - let error = match data { - Data::AuthReply(reply) if !reply.success => { - Some(("authenticate", reply.reason.clone())) - } - Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason.clone())), - _ => None, - }; - if let Some((action, reason)) = error { - let description = format!("Failed to {action}."); - let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string()); - self.popups.push_front(RoomPopup::Error { - description, - reason, - }); - } - - handled - } - - fn handle_euph_error(&mut self, r#type: PacketType, reason: &str) -> bool { - let action = match r#type { - PacketType::AuthReply => "authenticate", - PacketType::NickReply => "set nick", - PacketType::PmInitiateReply => "initiate pm", - PacketType::SendReply => "send message", - PacketType::ChangeEmailReply => "change account email", - PacketType::ChangeNameReply => "change account name", - PacketType::ChangePasswordReply => "change account password", - PacketType::LoginReply => "log in", - PacketType::LogoutReply => "log out", - PacketType::RegisterAccountReply => "register account", - PacketType::ResendVerificationEmailReply => "resend verification email", - PacketType::ResetPasswordReply => "reset account password", - PacketType::BanReply => "ban", - PacketType::EditMessageReply => "edit message", - PacketType::GrantAccessReply => "grant room access", - PacketType::GrantManagerReply => "grant manager permissions", - PacketType::RevokeAccessReply => "revoke room access", - PacketType::RevokeManagerReply => "revoke manager permissions", - PacketType::UnbanReply => "unban", - _ => return false, - }; - let description = format!("Failed to {action}."); - self.popups.push_front(RoomPopup::Error { - description, - reason: reason.to_string(), - }); - true - } -} diff --git a/src/ui/input.rs b/src/ui/input.rs deleted file mode 100644 index d8cf209..0000000 --- a/src/ui/input.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::convert::Infallible; - -use crossterm::event::{Event, KeyCode, KeyModifiers}; -use crossterm::style::{ContentStyle, Stylize}; -use toss::styled::Styled; - -use super::widgets::background::Background; -use super::widgets::border::Border; -use super::widgets::empty::Empty; -use super::widgets::float::Float; -use super::widgets::join::{HJoin, Segment}; -use super::widgets::layer::Layer; -use super::widgets::list::{List, ListState}; -use super::widgets::padding::Padding; -use super::widgets::resize::Resize; -use super::widgets::text::Text; -use super::widgets::BoxedWidget; - -#[derive(Debug, Clone)] -pub enum InputEvent { - Key(KeyEvent), - Paste(String), -} - -impl InputEvent { - pub fn from_event(event: Event) -> Option<Self> { - match event { - crossterm::event::Event::Key(key) => Some(Self::Key(key.into())), - crossterm::event::Event::Paste(text) => Some(Self::Paste(text)), - _ => None, - } - } -} - -/// A key event data type that is a bit easier to pattern match on than -/// [`crossterm::event::KeyEvent`]. -#[derive(Debug, Clone, Copy)] -pub struct KeyEvent { - pub code: KeyCode, - pub shift: bool, - pub ctrl: bool, - pub alt: bool, -} - -impl From<crossterm::event::KeyEvent> for KeyEvent { - fn from(event: crossterm::event::KeyEvent) -> Self { - Self { - code: event.code, - shift: event.modifiers.contains(KeyModifiers::SHIFT), - ctrl: event.modifiers.contains(KeyModifiers::CONTROL), - alt: event.modifiers.contains(KeyModifiers::ALT), - } - } -} - -#[rustfmt::skip] -#[allow(unused_macro_rules)] -macro_rules! key { - // key!(Paste text) - ( Paste $text:ident ) => { crate::ui::input::InputEvent::Paste($text) }; - - // key!('a') - ( $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(Char c) - ( Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(F n) - ( F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: false, }) }; - ( Shift + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: true, alt: false, }) }; - ( Alt + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: true, }) }; - - // key!(other) - ( $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: false, }) }; - ( Shift + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: true, alt: false, }) }; - ( Alt + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: true, }) }; -} -pub(crate) use key; - -/// Helper wrapper around a list widget for a more consistent key binding style. -pub struct KeyBindingsList(List<Infallible>); - -impl KeyBindingsList { - /// Width of the left column of key bindings. - const BINDING_WIDTH: u16 = 20; - - pub fn new(state: &ListState<Infallible>) -> Self { - Self(state.widget()) - } - - fn binding_style() -> ContentStyle { - ContentStyle::default().cyan() - } - - pub fn widget(self) -> BoxedWidget { - let binding_style = Self::binding_style(); - Float::new(Layer::new(vec![ - Border::new(Background::new(Padding::new(self.0).horizontal(1))).into(), - Float::new( - Padding::new(Text::new( - Styled::new("jk/↓↑", binding_style) - .then_plain(" to scroll, ") - .then("esc", binding_style) - .then_plain(" to close"), - )) - .horizontal(1), - ) - .horizontal(0.5) - .into(), - ])) - .horizontal(0.5) - .vertical(0.5) - .into() - } - - pub fn empty(&mut self) { - self.0.add_unsel(Empty::new()); - } - - pub fn heading(&mut self, name: &str) { - self.0 - .add_unsel(Text::new((name, ContentStyle::default().bold()))); - } - - pub fn binding(&mut self, binding: &str, description: &str) { - let widget = HJoin::new(vec![ - Segment::new( - Resize::new(Padding::new(Text::new((binding, Self::binding_style()))).right(1)) - .min_width(Self::BINDING_WIDTH), - ), - Segment::new(Text::new(description)), - ]); - self.0.add_unsel(widget); - } - - pub fn binding_ctd(&mut self, description: &str) { - let widget = HJoin::new(vec![ - Segment::new(Resize::new(Empty::new()).min_width(Self::BINDING_WIDTH)), - Segment::new(Text::new(description)), - ]); - self.0.add_unsel(widget); - } -} diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs deleted file mode 100644 index eac0a75..0000000 --- a/src/ui/rooms.rs +++ /dev/null @@ -1,582 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::iter; -use std::sync::{Arc, Mutex}; - -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::SessionType; -use euphoxide::bot::instance::{Event, ServerConfig}; -use euphoxide::conn::{self, Joined}; -use parking_lot::FairMutex; -use tokio::sync::mpsc; -use toss::styled::Styled; -use toss::terminal::Terminal; - -use crate::config::{Config, RoomsSortOrder}; -use crate::euph; -use crate::macros::logging_unwrap; -use crate::vault::Vault; - -use super::euph::room::EuphRoom; -use super::input::{key, InputEvent, KeyBindingsList}; -use super::widgets::editor::EditorState; -use super::widgets::join::{HJoin, Segment, VJoin}; -use super::widgets::layer::Layer; -use super::widgets::list::{List, ListState}; -use super::widgets::popup::Popup; -use super::widgets::resize::Resize; -use super::widgets::text::Text; -use super::widgets::BoxedWidget; -use super::{util, UiEvent}; - -enum State { - ShowList, - ShowRoom(String), - Connect(EditorState), - Delete(String, EditorState), -} - -enum Order { - Alphabet, - Importance, -} - -impl Order { - fn from_rooms_sort_order(order: RoomsSortOrder) -> Self { - match order { - RoomsSortOrder::Alphabet => Self::Alphabet, - RoomsSortOrder::Importance => Self::Importance, - } - } -} - -pub struct Rooms { - config: &'static Config, - - vault: Vault, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - - state: State, - - list: ListState<String>, - order: Order, - - euph_server_config: ServerConfig, - euph_next_instance_id: usize, - euph_rooms: HashMap<String, EuphRoom>, -} - -impl Rooms { - pub async fn new( - config: &'static Config, - vault: Vault, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - ) -> Self { - let cookies = logging_unwrap!(vault.euph().cookies().await); - let euph_server_config = ServerConfig::default().cookies(Arc::new(Mutex::new(cookies))); - - let mut result = Self { - config, - vault, - ui_event_tx, - state: State::ShowList, - list: ListState::new(), - order: Order::from_rooms_sort_order(config.rooms_sort_order), - euph_server_config, - euph_next_instance_id: 0, - euph_rooms: HashMap::new(), - }; - - if !config.offline { - for (name, config) in &config.euph.rooms { - if config.autojoin { - result.connect_to_room(name.clone()); - } - } - } - - result - } - - fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom { - self.euph_rooms.entry(name.clone()).or_insert_with(|| { - EuphRoom::new( - self.euph_server_config.clone(), - self.config.euph_room(&name), - self.vault.euph().room(name), - self.ui_event_tx.clone(), - ) - }) - } - - fn connect_to_room(&mut self, name: String) { - let room = self.euph_rooms.entry(name.clone()).or_insert_with(|| { - EuphRoom::new( - self.euph_server_config.clone(), - self.config.euph_room(&name), - self.vault.euph().room(name), - self.ui_event_tx.clone(), - ) - }); - room.connect(&mut self.euph_next_instance_id); - } - - fn connect_to_all_rooms(&mut self) { - for room in self.euph_rooms.values_mut() { - room.connect(&mut self.euph_next_instance_id); - } - } - - fn disconnect_from_room(&mut self, name: &str) { - if let Some(room) = self.euph_rooms.get_mut(name) { - room.disconnect(); - } - } - - fn disconnect_from_all_rooms(&mut self) { - for room in self.euph_rooms.values_mut() { - room.disconnect(); - } - } - - /// Remove rooms that are not running any more and can't be found in the db. - /// Insert rooms that are in the db but not yet in in the hash map. - /// - /// These kinds of rooms are either - /// - failed connection attempts, or - /// - rooms that were deleted from the db. - async fn stabilize_rooms(&mut self) { - let rooms = logging_unwrap!(self.vault.euph().rooms().await); - let mut rooms_set = rooms.into_iter().collect::<HashSet<_>>(); - - // Prevent room that is currently being shown from being removed. This - // could otherwise happen when connecting to a room that doesn't exist. - if let State::ShowRoom(name) = &self.state { - rooms_set.insert(name.clone()); - } - - self.euph_rooms - .retain(|n, r| !r.stopped() || rooms_set.contains(n)); - - for room in rooms_set { - self.get_or_insert_room(room).retain(); - } - } - - pub async fn widget(&mut self) -> BoxedWidget { - match &self.state { - State::ShowRoom(_) => {} - _ => self.stabilize_rooms().await, - } - - match &self.state { - State::ShowList => self.rooms_widget().await, - State::ShowRoom(name) => { - self.euph_rooms - .get_mut(name) - .expect("room exists after stabilization") - .widget() - .await - } - State::Connect(editor) => Layer::new(vec![ - self.rooms_widget().await, - Self::new_room_widget(editor), - ]) - .into(), - State::Delete(name, editor) => Layer::new(vec![ - self.rooms_widget().await, - Self::delete_room_widget(name, editor), - ]) - .into(), - } - } - - fn new_room_widget(editor: &EditorState) -> BoxedWidget { - let room_style = ContentStyle::default().bold().blue(); - let editor = editor.widget().highlight(|s| Styled::new(s, room_style)); - Popup::new(HJoin::new(vec![ - Segment::new(Text::new(("&", room_style))), - Segment::new(editor).priority(0), - ])) - .title("Connect to") - .build() - } - - fn delete_room_widget(name: &str, editor: &EditorState) -> BoxedWidget { - let warn_style = ContentStyle::default().bold().red(); - let room_style = ContentStyle::default().bold().blue(); - let editor = editor.widget().highlight(|s| Styled::new(s, room_style)); - let text = Styled::new_plain("Are you sure you want to delete ") - .then("&", room_style) - .then(name, room_style) - .then_plain("?\n\n") - .then_plain("This will delete the entire room history from your vault. ") - .then_plain("To shrink your vault afterwards, run ") - .then("cove gc", ContentStyle::default().italic().grey()) - .then_plain(".\n\n") - .then_plain("To confirm the deletion, ") - .then_plain("enter the full name of the room and press enter:"); - Popup::new(VJoin::new(vec![ - // The HJoin prevents the text from filling up the entire available - // space if the editor is wider than the text. - Segment::new(HJoin::new(vec![Segment::new( - Resize::new(Text::new(text).wrap(true)).max_width(54), - )])), - Segment::new(HJoin::new(vec![ - Segment::new(Text::new(("&", room_style))), - Segment::new(editor).priority(0), - ])), - ])) - .title(("Delete room", warn_style)) - .border(warn_style) - .build() - } - - fn format_pbln(joined: &Joined) -> String { - let mut p = 0_usize; - let mut b = 0_usize; - let mut l = 0_usize; - let mut n = 0_usize; - - let sessions = joined - .listing - .values() - .map(|s| (s.id(), s.name())) - .chain(iter::once(( - &joined.session.id, - &joined.session.name as &str, - ))); - for (user_id, name) in sessions { - match user_id.session_type() { - Some(SessionType::Bot) if name.is_empty() => n += 1, - Some(SessionType::Bot) => b += 1, - _ if name.is_empty() => l += 1, - _ => p += 1, - } - } - - // There must always be either one p, b, l or n since we're including - // ourselves. - let mut result = vec![]; - if p > 0 { - result.push(format!("{p}p")); - } - if b > 0 { - result.push(format!("{b}b")); - } - if l > 0 { - result.push(format!("{l}l")); - } - if n > 0 { - result.push(format!("{n}n")); - } - result.join(" ") - } - - fn format_room_state(state: Option<&euph::State>) -> Option<String> { - match state { - None | Some(euph::State::Stopped) => None, - Some(euph::State::Disconnected) => Some("waiting".to_string()), - Some(euph::State::Connecting) => Some("connecting".to_string()), - Some(euph::State::Connected(_, connected)) => match connected { - conn::State::Joining(joining) if joining.bounce.is_some() => { - Some("auth required".to_string()) - } - conn::State::Joining(_) => Some("joining".to_string()), - conn::State::Joined(joined) => Some(Self::format_pbln(joined)), - }, - } - } - - fn format_unseen_msgs(unseen: usize) -> Option<String> { - if unseen == 0 { - None - } else { - Some(format!("{unseen}")) - } - } - - fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled { - let unseen_style = ContentStyle::default().bold().green(); - - let state = Self::format_room_state(state); - let unseen = Self::format_unseen_msgs(unseen); - - match (state, unseen) { - (None, None) => Styled::default(), - (None, Some(u)) => Styled::new_plain(" (") - .then(u, unseen_style) - .then_plain(")"), - (Some(s), None) => Styled::new_plain(" (").then_plain(s).then_plain(")"), - (Some(s), Some(u)) => Styled::new_plain(" (") - .then_plain(s) - .then_plain(", ") - .then(u, unseen_style) - .then_plain(")"), - } - } - - fn sort_rooms(&self, rooms: &mut [(&String, Option<&euph::State>, usize)]) { - match self.order { - Order::Alphabet => rooms.sort_unstable_by_key(|(name, _, _)| *name), - Order::Importance => rooms.sort_unstable_by_key(|(name, state, unseen)| { - (state.is_none(), *unseen == 0, *name) - }), - } - } - - async fn render_rows(&self, list: &mut List<String>) { - if self.euph_rooms.is_empty() { - list.add_unsel(Text::new(( - "Press F1 for key bindings", - ContentStyle::default().grey().italic(), - ))) - } - - let mut rooms = vec![]; - for (name, room) in &self.euph_rooms { - let state = room.room_state(); - let unseen = room.unseen_msgs_count().await; - rooms.push((name, state, unseen)); - } - self.sort_rooms(&mut rooms); - for (name, state, unseen) in rooms { - let room_style = ContentStyle::default().bold().blue(); - let room_sel_style = ContentStyle::default().bold().black().on_white(); - - let mut normal = Styled::new(format!("&{name}"), room_style); - let mut selected = Styled::new(format!("&{name}"), room_sel_style); - - let info = Self::format_room_info(state, unseen); - normal = normal.and_then(info.clone()); - selected = selected.and_then(info); - - list.add_sel(name.clone(), Text::new(normal), Text::new(selected)); - } - } - - async fn rooms_widget(&self) -> BoxedWidget { - let heading_style = ContentStyle::default().bold(); - let amount = self.euph_rooms.len(); - let heading = - Text::new(Styled::new("Rooms", heading_style).then_plain(format!(" ({amount})"))); - - let mut list = self.list.widget().focus(true); - self.render_rows(&mut list).await; - - VJoin::new(vec![Segment::new(heading), Segment::new(list).priority(0)]).into() - } - - fn room_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' - } - - fn list_showlist_key_bindings(bindings: &mut KeyBindingsList) { - bindings.heading("Rooms"); - util::list_list_key_bindings(bindings); - bindings.empty(); - bindings.binding("enter", "enter selected room"); - bindings.binding("c", "connect to selected room"); - bindings.binding("C", "connect to all rooms"); - bindings.binding("d", "disconnect from selected room"); - bindings.binding("D", "disconnect from all rooms"); - bindings.binding("a", "connect to all autojoin room"); - bindings.binding("A", "disconnect from all non-autojoin rooms"); - bindings.binding("n", "connect to new room"); - bindings.binding("X", "delete room"); - bindings.empty(); - bindings.binding("s", "change sort order"); - } - - fn handle_showlist_input_event(&mut self, event: &InputEvent) -> bool { - if util::handle_list_input_event(&mut self.list, event) { - return true; - } - - match event { - key!(Enter) => { - if let Some(name) = self.list.cursor() { - self.state = State::ShowRoom(name); - } - return true; - } - key!('c') => { - if let Some(name) = self.list.cursor() { - self.connect_to_room(name); - } - return true; - } - key!('C') => { - self.connect_to_all_rooms(); - return true; - } - key!('d') => { - if let Some(name) = self.list.cursor() { - self.disconnect_from_room(&name); - } - return true; - } - key!('D') => { - self.disconnect_from_all_rooms(); - return true; - } - key!('a') => { - for (name, options) in &self.config.euph.rooms { - if options.autojoin { - self.connect_to_room(name.clone()); - } - } - return true; - } - key!('A') => { - for (name, room) in &mut self.euph_rooms { - let autojoin = self - .config - .euph - .rooms - .get(name) - .map(|r| r.autojoin) - .unwrap_or(false); - if !autojoin { - room.disconnect(); - } - } - return true; - } - key!('n') => { - self.state = State::Connect(EditorState::new()); - return true; - } - key!('X') => { - if let Some(name) = self.list.cursor() { - self.state = State::Delete(name, EditorState::new()); - } - return true; - } - key!('s') => { - self.order = match self.order { - Order::Alphabet => Order::Importance, - Order::Importance => Order::Alphabet, - }; - return true; - } - _ => {} - } - - false - } - - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - match &self.state { - State::ShowList => Self::list_showlist_key_bindings(bindings), - State::ShowRoom(name) => { - // Key bindings for leaving the room are a part of the room's - // list_key_bindings function since they may be shadowed by the - // nick selector or message editor. - if let Some(room) = self.euph_rooms.get(name) { - room.list_key_bindings(bindings).await; - } else { - // There should always be a room here already but I don't - // really want to panic in case it is not. If I show a - // message like this, it'll hopefully be reported if - // somebody ever encounters it. - bindings.binding_ctd("oops, this text should never be visible") - } - } - State::Connect(_) => { - bindings.heading("Rooms"); - bindings.binding("esc", "abort"); - bindings.binding("enter", "connect to room"); - util::list_editor_key_bindings(bindings, Self::room_char); - } - State::Delete(_, _) => { - bindings.heading("Rooms"); - bindings.binding("esc", "abort"); - bindings.binding("enter", "delete room"); - util::list_editor_key_bindings(bindings, Self::room_char); - } - } - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - self.stabilize_rooms().await; - - match &self.state { - State::ShowList => { - if self.handle_showlist_input_event(event) { - return true; - } - } - State::ShowRoom(name) => { - if let Some(room) = self.euph_rooms.get_mut(name) { - if room - .handle_input_event(terminal, crossterm_lock, event) - .await - { - return true; - } - - if let key!(Esc) = event { - self.state = State::ShowList; - return true; - } - } - } - State::Connect(ed) => match event { - key!(Esc) => { - self.state = State::ShowList; - return true; - } - key!(Enter) => { - let name = ed.text(); - if !name.is_empty() { - self.connect_to_room(name.clone()); - self.state = State::ShowRoom(name); - } - return true; - } - _ => { - if util::handle_editor_input_event(ed, terminal, event, Self::room_char) { - return true; - } - } - }, - State::Delete(name, editor) => match event { - key!(Esc) => { - self.state = State::ShowList; - return true; - } - key!(Enter) if editor.text() == *name => { - self.euph_rooms.remove(name); - logging_unwrap!(self.vault.euph().room(name.clone()).delete().await); - self.state = State::ShowList; - return true; - } - _ => { - if util::handle_editor_input_event(editor, terminal, event, Self::room_char) { - return true; - } - } - }, - } - - false - } - - pub async fn handle_euph_event(&mut self, event: Event) -> bool { - let room_name = event.config().room.clone(); - let Some(room) = self.euph_rooms.get_mut(&room_name) else { return false; }; - - let handled = room.handle_event(event).await; - - let room_visible = match &self.state { - State::ShowRoom(name) => *name == room_name, - _ => true, - }; - handled && room_visible - } -} diff --git a/src/ui/util.rs b/src/ui/util.rs deleted file mode 100644 index 583dcd5..0000000 --- a/src/ui/util.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::io; -use std::sync::Arc; - -use parking_lot::FairMutex; -use toss::terminal::Terminal; - -use super::input::{key, InputEvent, KeyBindingsList}; -use super::widgets::editor::EditorState; -use super::widgets::list::ListState; - -pub fn prompt( - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - initial_text: &str, -) -> io::Result<String> { - let content = { - let _guard = crossterm_lock.lock(); - terminal.suspend().expect("could not suspend"); - let content = edit::edit(initial_text); - terminal.unsuspend().expect("could not unsuspend"); - content - }; - - content -} - -////////// -// List // -////////// - -pub fn list_list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down"); -} - -pub fn handle_list_input_event<Id: Clone>(list: &mut ListState<Id>, event: &InputEvent) -> bool { - match event { - key!('k') | key!(Up) => list.move_cursor_up(), - key!('j') | key!(Down) => list.move_cursor_down(), - key!('g') | key!(Home) => list.move_cursor_to_top(), - key!('G') | key!(End) => list.move_cursor_to_bottom(), - key!(Ctrl + 'y') => list.scroll_up(1), - key!(Ctrl + 'e') => list.scroll_down(1), - _ => return false, - } - - true -} - -//////////// -// Editor // -//////////// - -fn list_editor_editing_key_bindings( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - if char_filter('\n') { - bindings.binding("enter+<any modifier>", "insert newline"); - } - - bindings.binding("ctrl+h, backspace", "delete before cursor"); - bindings.binding("ctrl+d, delete", "delete after cursor"); - bindings.binding("ctrl+l", "clear editor contents"); -} - -fn list_editor_cursor_movement_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("ctrl+b, ←", "move cursor left"); - bindings.binding("ctrl+f, →", "move cursor right"); - bindings.binding("alt+b, ctrl+←", "move cursor left a word"); - bindings.binding("alt+f, ctrl+→", "move cursor right a word"); - bindings.binding("ctrl+a, home", "move cursor to start of line"); - bindings.binding("ctrl+e, end", "move cursor to end of line"); - bindings.binding("↑/↓", "move cursor up/down"); -} - -pub fn list_editor_key_bindings( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - list_editor_editing_key_bindings(bindings, char_filter); - bindings.empty(); - list_editor_cursor_movement_key_bindings(bindings); -} - -pub fn handle_editor_input_event( - editor: &EditorState, - terminal: &mut Terminal, - event: &InputEvent, - char_filter: impl Fn(char) -> bool, -) -> bool { - match event { - // Enter with *any* modifier pressed - if ctrl and shift don't - // work, maybe alt does - key!(Enter) => return false, - InputEvent::Key(crate::ui::input::KeyEvent { - code: crossterm::event::KeyCode::Enter, - .. - }) if char_filter('\n') => editor.insert_char(terminal.widthdb(), '\n'), - - // Editing - key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.widthdb(), *ch), - key!(Paste str) => { - // It seems that when pasting, '\n' are converted into '\r' for some - // reason. I don't really know why, or at what point this happens. - // Vim converts any '\r' pasted via the terminal into '\n', so I - // decided to mirror that behaviour. - let str = str.replace('\r', "\n"); - if str.chars().all(char_filter) { - editor.insert_str(terminal.widthdb(), &str); - } else { - return false; - } - } - key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.widthdb()), - key!(Ctrl + 'd') | key!(Delete) => editor.delete(), - key!(Ctrl + 'l') => editor.clear(), - // TODO Key bindings to delete words - - // Cursor movement - key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.widthdb()), - key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.widthdb()), - key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.widthdb()), - key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.widthdb()), - key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.widthdb()), - key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.widthdb()), - key!(Up) => editor.move_cursor_up(terminal.widthdb()), - key!(Down) => editor.move_cursor_down(terminal.widthdb()), - - _ => return false, - } - - true -} - -pub fn list_editor_key_bindings_allowing_external_editing( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - list_editor_editing_key_bindings(bindings, char_filter); - bindings.binding("ctrl+x", "edit in external editor"); - bindings.empty(); - list_editor_cursor_movement_key_bindings(bindings); -} - -pub fn handle_editor_input_event_allowing_external_editing( - editor: &EditorState, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - char_filter: impl Fn(char) -> bool, -) -> io::Result<bool> { - if let key!(Ctrl + 'x') = event { - editor.edit_externally(terminal, crossterm_lock)?; - Ok(true) - } else { - Ok(handle_editor_input_event( - editor, - terminal, - event, - char_filter, - )) - } -} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs deleted file mode 100644 index 33c2c49..0000000 --- a/src/ui/widgets.rs +++ /dev/null @@ -1,39 +0,0 @@ -// Since the widget module is effectively a library and will probably be moved -// to toss later, warnings about unused functions are mostly inaccurate. -// TODO Restrict this a bit more? -#![allow(dead_code)] - -pub mod background; -pub mod border; -pub mod cursor; -pub mod editor; -pub mod empty; -pub mod float; -pub mod join; -pub mod layer; -pub mod list; -pub mod padding; -pub mod popup; -pub mod resize; -pub mod rules; -pub mod text; - -use async_trait::async_trait; -use toss::frame::{Frame, Size}; - -// TODO Add Error type and return Result-s (at least in Widget::render) - -#[async_trait] -pub trait Widget { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size; - - async fn render(self: Box<Self>, frame: &mut Frame); -} - -pub type BoxedWidget = Box<dyn Widget + Send>; - -impl<W: 'static + Widget + Send> From<W> for BoxedWidget { - fn from(widget: W) -> Self { - Box::new(widget) - } -} diff --git a/src/ui/widgets/background.rs b/src/ui/widgets/background.rs deleted file mode 100644 index 4990bcf..0000000 --- a/src/ui/widgets/background.rs +++ /dev/null @@ -1,42 +0,0 @@ -use async_trait::async_trait; -use crossterm::style::ContentStyle; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Background { - inner: BoxedWidget, - style: ContentStyle, -} - -impl Background { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - style: ContentStyle::default(), - } - } - - pub fn style(mut self, style: ContentStyle) -> Self { - self.style = style; - self - } -} - -#[async_trait] -impl Widget for Background { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - self.inner.size(frame, max_width, max_height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - for dy in 0..size.height { - for dx in 0..size.width { - frame.write(Pos::new(dx.into(), dy.into()), (" ", self.style)); - } - } - - self.inner.render(frame).await; - } -} diff --git a/src/ui/widgets/border.rs b/src/ui/widgets/border.rs deleted file mode 100644 index fd32a9c..0000000 --- a/src/ui/widgets/border.rs +++ /dev/null @@ -1,61 +0,0 @@ -use async_trait::async_trait; -use crossterm::style::ContentStyle; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Border { - inner: BoxedWidget, - style: ContentStyle, -} - -impl Border { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - style: ContentStyle::default(), - } - } - - pub fn style(mut self, style: ContentStyle) -> Self { - self.style = style; - self - } -} - -#[async_trait] -impl Widget for Border { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let max_width = max_width.map(|w| w.saturating_sub(2)); - let max_height = max_height.map(|h| h.saturating_sub(2)); - let size = self.inner.size(frame, max_width, max_height); - size + Size::new(2, 2) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let mut size = frame.size(); - size.width = size.width.max(2); - size.height = size.height.max(2); - - let right = size.width as i32 - 1; - let bottom = size.height as i32 - 1; - frame.write(Pos::new(0, 0), ("┌", self.style)); - frame.write(Pos::new(right, 0), ("┐", self.style)); - frame.write(Pos::new(0, bottom), ("└", self.style)); - frame.write(Pos::new(right, bottom), ("┘", self.style)); - - for y in 1..bottom { - frame.write(Pos::new(0, y), ("│", self.style)); - frame.write(Pos::new(right, y), ("│", self.style)); - } - - for x in 1..right { - frame.write(Pos::new(x, 0), ("─", self.style)); - frame.write(Pos::new(x, bottom), ("─", self.style)); - } - - frame.push(Pos::new(1, 1), size - Size::new(2, 2)); - self.inner.render(frame).await; - frame.pop(); - } -} diff --git a/src/ui/widgets/cursor.rs b/src/ui/widgets/cursor.rs deleted file mode 100644 index 205c5c1..0000000 --- a/src/ui/widgets/cursor.rs +++ /dev/null @@ -1,39 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Cursor { - inner: BoxedWidget, - pos: Pos, -} - -impl Cursor { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - pos: Pos::ZERO, - } - } - - pub fn at(mut self, pos: Pos) -> Self { - self.pos = pos; - self - } - - pub fn at_xy(self, x: i32, y: i32) -> Self { - self.at(Pos::new(x, y)) - } -} - -#[async_trait] -impl Widget for Cursor { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - self.inner.size(frame, max_width, max_height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - self.inner.render(frame).await; - frame.set_cursor(Some(self.pos)); - } -} diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs deleted file mode 100644 index 54c8fd4..0000000 --- a/src/ui/widgets/editor.rs +++ /dev/null @@ -1,566 +0,0 @@ -use std::sync::Arc; -use std::{io, iter}; - -use async_trait::async_trait; -use crossterm::style::{ContentStyle, Stylize}; -use parking_lot::{FairMutex, Mutex}; -use toss::frame::{Frame, Pos, Size}; -use toss::styled::Styled; -use toss::terminal::Terminal; -use toss::widthdb::WidthDb; -use unicode_segmentation::UnicodeSegmentation; - -use crate::ui::util; - -use super::text::Text; -use super::Widget; - -/// Like [`WidthDb::wrap`] but includes a final break index if the text ends -/// with a newline. -fn wrap(widthdb: &mut WidthDb, text: &str, width: usize) -> Vec<usize> { - let mut breaks = widthdb.wrap(text, width); - if text.ends_with('\n') { - breaks.push(text.len()) - } - breaks -} - -/////////// -// State // -/////////// - -struct InnerEditorState { - text: String, - - /// Index of the cursor in the text. - /// - /// Must point to a valid grapheme boundary. - idx: usize, - - /// Column of the cursor on the screen just after it was last moved - /// horizontally. - col: usize, - - /// Width of the text when the editor was last rendered. - /// - /// Does not include additional column for cursor. - last_width: u16, -} - -impl InnerEditorState { - fn new(text: String) -> Self { - Self { - idx: text.len(), - col: 0, - last_width: u16::MAX, - text, - } - } - - /////////////////////////////// - // Grapheme helper functions // - /////////////////////////////// - - fn grapheme_boundaries(&self) -> Vec<usize> { - self.text - .grapheme_indices(true) - .map(|(i, _)| i) - .chain(iter::once(self.text.len())) - .collect() - } - - /// Ensure the cursor index lies on a grapheme boundary. If it doesn't, it - /// is moved to the next grapheme boundary. - /// - /// Can handle arbitrary cursor index. - fn move_cursor_to_grapheme_boundary(&mut self) { - for i in self.grapheme_boundaries() { - #[allow(clippy::comparison_chain)] - if i == self.idx { - // We're at a valid grapheme boundary already - return; - } else if i > self.idx { - // There was no valid grapheme boundary at our cursor index, so - // we'll take the next one we can get. - self.idx = i; - return; - } - } - - // The cursor was out of bounds, so move it to the last valid index. - self.idx = self.text.len(); - } - - /////////////////////////////// - // Line/col helper functions // - /////////////////////////////// - - /// Like [`Self::grapheme_boundaries`] but for lines. - /// - /// Note that the last line can have a length of 0 if the text ends with a - /// newline. - fn line_boundaries(&self) -> Vec<usize> { - let newlines = self - .text - .char_indices() - .filter(|(_, c)| *c == '\n') - .map(|(i, _)| i + 1); // utf-8 encodes '\n' as a single byte - iter::once(0) - .chain(newlines) - .chain(iter::once(self.text.len())) - .collect() - } - - /// Find the cursor's current line. - /// - /// Returns `(line_nr, start_idx, end_idx)`. - fn cursor_line(&self, boundaries: &[usize]) -> (usize, usize, usize) { - let mut result = (0, 0, 0); - for (i, (start, end)) in boundaries.iter().zip(boundaries.iter().skip(1)).enumerate() { - if self.idx >= *start { - result = (i, *start, *end); - } else { - break; - } - } - result - } - - fn cursor_col(&self, widthdb: &mut WidthDb, line_start: usize) -> usize { - widthdb.width(&self.text[line_start..self.idx]) - } - - fn line(&self, line: usize) -> (usize, usize) { - let boundaries = self.line_boundaries(); - boundaries - .iter() - .copied() - .zip(boundaries.iter().copied().skip(1)) - .nth(line) - .expect("line exists") - } - - fn move_cursor_to_line_col(&mut self, widthdb: &mut WidthDb, line: usize, col: usize) { - let (start, end) = self.line(line); - let line = &self.text[start..end]; - - let mut width = 0; - for (gi, g) in line.grapheme_indices(true) { - self.idx = start + gi; - if col > width { - width += widthdb.grapheme_width(g, width) as usize; - } else { - return; - } - } - - if !line.ends_with('\n') { - self.idx = end; - } - } - - fn record_cursor_col(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.line_boundaries(); - let (_, start, _) = self.cursor_line(&boundaries); - self.col = self.cursor_col(widthdb, start); - } - - ///////////// - // Editing // - ///////////// - - fn clear(&mut self) { - self.text = String::new(); - self.idx = 0; - self.col = 0; - } - - fn set_text(&mut self, widthdb: &mut WidthDb, text: String) { - self.text = text; - self.move_cursor_to_grapheme_boundary(); - self.record_cursor_col(widthdb); - } - - /// Insert a character at the current cursor position and move the cursor - /// accordingly. - fn insert_char(&mut self, widthdb: &mut WidthDb, ch: char) { - self.text.insert(self.idx, ch); - self.idx += ch.len_utf8(); - self.record_cursor_col(widthdb); - } - - /// Insert a string at the current cursor position and move the cursor - /// accordingly. - fn insert_str(&mut self, widthdb: &mut WidthDb, str: &str) { - self.text.insert_str(self.idx, str); - self.idx += str.len(); - self.record_cursor_col(widthdb); - } - - /// Delete the grapheme before the cursor position. - fn backspace(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.grapheme_boundaries(); - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { - if *end == self.idx { - self.text.replace_range(start..end, ""); - self.idx = *start; - self.record_cursor_col(widthdb); - break; - } - } - } - - /// Delete the grapheme after the cursor position. - fn delete(&mut self) { - let boundaries = self.grapheme_boundaries(); - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { - if *start == self.idx { - self.text.replace_range(start..end, ""); - break; - } - } - } - - ///////////////////// - // Cursor movement // - ///////////////////// - - fn move_cursor_left(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.grapheme_boundaries(); - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { - if *end == self.idx { - self.idx = *start; - self.record_cursor_col(widthdb); - break; - } - } - } - - fn move_cursor_right(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.grapheme_boundaries(); - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { - if *start == self.idx { - self.idx = *end; - self.record_cursor_col(widthdb); - break; - } - } - } - - fn move_cursor_left_a_word(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.grapheme_boundaries(); - let mut encountered_word = false; - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)).rev() { - if *end == self.idx { - let g = &self.text[*start..*end]; - let whitespace = g.chars().all(|c| c.is_whitespace()); - if encountered_word && whitespace { - break; - } else if !whitespace { - encountered_word = true; - } - self.idx = *start; - } - } - self.record_cursor_col(widthdb); - } - - fn move_cursor_right_a_word(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.grapheme_boundaries(); - let mut encountered_word = false; - for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { - if *start == self.idx { - let g = &self.text[*start..*end]; - let whitespace = g.chars().all(|c| c.is_whitespace()); - if encountered_word && whitespace { - break; - } else if !whitespace { - encountered_word = true; - } - self.idx = *end; - } - } - self.record_cursor_col(widthdb); - } - - fn move_cursor_to_start_of_line(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - self.move_cursor_to_line_col(widthdb, line, 0); - self.record_cursor_col(widthdb); - } - - fn move_cursor_to_end_of_line(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - self.move_cursor_to_line_col(widthdb, line, usize::MAX); - self.record_cursor_col(widthdb); - } - - fn move_cursor_up(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - if line > 0 { - self.move_cursor_to_line_col(widthdb, line - 1, self.col); - } - } - - fn move_cursor_down(&mut self, widthdb: &mut WidthDb) { - let boundaries = self.line_boundaries(); - - // There's always at least one line, and always at least two line - // boundaries at 0 and self.text.len(). - let amount_of_lines = boundaries.len() - 1; - - let (line, _, _) = self.cursor_line(&boundaries); - if line + 1 < amount_of_lines { - self.move_cursor_to_line_col(widthdb, line + 1, self.col); - } - } -} - -pub struct EditorState(Arc<Mutex<InnerEditorState>>); - -impl EditorState { - pub fn new() -> Self { - Self(Arc::new(Mutex::new(InnerEditorState::new(String::new())))) - } - - pub fn with_initial_text(text: String) -> Self { - Self(Arc::new(Mutex::new(InnerEditorState::new(text)))) - } - - pub fn widget(&self) -> Editor { - let guard = self.0.lock(); - let text = Styled::new_plain(guard.text.clone()); - let idx = guard.idx; - Editor { - state: self.0.clone(), - text, - idx, - focus: true, - hidden: None, - } - } - - pub fn text(&self) -> String { - self.0.lock().text.clone() - } - - pub fn clear(&self) { - self.0.lock().clear(); - } - - pub fn set_text(&self, widthdb: &mut WidthDb, text: String) { - self.0.lock().set_text(widthdb, text); - } - - pub fn insert_char(&self, widthdb: &mut WidthDb, ch: char) { - self.0.lock().insert_char(widthdb, ch); - } - - pub fn insert_str(&self, widthdb: &mut WidthDb, str: &str) { - self.0.lock().insert_str(widthdb, str); - } - - /// Delete the grapheme before the cursor position. - pub fn backspace(&self, widthdb: &mut WidthDb) { - self.0.lock().backspace(widthdb); - } - - /// Delete the grapheme after the cursor position. - pub fn delete(&self) { - self.0.lock().delete(); - } - - pub fn move_cursor_left(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_left(widthdb); - } - - pub fn move_cursor_right(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_right(widthdb); - } - - pub fn move_cursor_left_a_word(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_left_a_word(widthdb); - } - - pub fn move_cursor_right_a_word(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_right_a_word(widthdb); - } - - pub fn move_cursor_to_start_of_line(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_to_start_of_line(widthdb); - } - - pub fn move_cursor_to_end_of_line(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_to_end_of_line(widthdb); - } - - pub fn move_cursor_up(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_up(widthdb); - } - - pub fn move_cursor_down(&self, widthdb: &mut WidthDb) { - self.0.lock().move_cursor_down(widthdb); - } - - pub fn edit_externally( - &self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - ) -> io::Result<()> { - let mut guard = self.0.lock(); - let text = util::prompt(terminal, crossterm_lock, &guard.text)?; - - if text.trim().is_empty() { - // The user likely wanted to abort the edit and has deleted the - // entire text (bar whitespace left over by some editors). - return Ok(()); - } - - if let Some(text) = text.strip_suffix('\n') { - // Some editors like vim add a trailing newline that would look out - // of place in cove's editor. To intentionally add a trailing - // newline, simply add two in-editor. - guard.set_text(terminal.widthdb(), text.to_string()); - } else { - guard.set_text(terminal.widthdb(), text); - } - - Ok(()) - } -} - -//////////// -// Widget // -//////////// - -pub struct Editor { - state: Arc<Mutex<InnerEditorState>>, - text: Styled, - idx: usize, - focus: bool, - hidden: Option<Box<Text>>, -} - -impl Editor { - pub fn highlight<F>(mut self, f: F) -> Self - where - F: FnOnce(&str) -> Styled, - { - let new_text = f(self.text.text()); - assert_eq!(self.text.text(), new_text.text()); - self.text = new_text; - self - } - - pub fn focus(mut self, active: bool) -> Self { - self.focus = active; - self - } - - pub fn hidden(self) -> Self { - self.hidden_with_placeholder(("<hidden>", ContentStyle::default().grey().italic())) - } - - pub fn hidden_with_placeholder<S: Into<Styled>>(mut self, placeholder: S) -> Self { - self.hidden = Some(Box::new(Text::new(placeholder))); - self - } - - fn wrapped_cursor(cursor_idx: usize, break_indices: &[usize]) -> (usize, usize) { - let mut row = 0; - let mut line_idx = cursor_idx; - - for break_idx in break_indices { - if cursor_idx < *break_idx { - break; - } else { - row += 1; - line_idx = cursor_idx - break_idx; - } - } - - (row, line_idx) - } - - pub fn cursor_row(&self, widthdb: &mut WidthDb) -> usize { - let width = self.state.lock().last_width; - let text_width = (width - 1) as usize; - let indices = wrap(widthdb, self.text.text(), text_width); - let (row, _) = Self::wrapped_cursor(self.idx, &indices); - row - } -} - -#[async_trait] -impl Widget for Editor { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - if let Some(placeholder) = &self.hidden { - let mut size = placeholder.size(frame, max_width, max_height); - - // Cursor needs to fit regardless of focus - size.width = size.width.max(1); - size.height = size.height.max(1); - - return size; - } - - let widthdb = frame.widthdb(); - - let max_width = max_width.map(|w| w as usize).unwrap_or(usize::MAX).max(1); - let max_text_width = max_width - 1; - let indices = wrap(widthdb, self.text.text(), max_text_width); - let lines = self.text.clone().split_at_indices(&indices); - - let min_width = lines - .iter() - .map(|l| widthdb.width(l.text().trim_end())) - .max() - .unwrap_or(0) - + 1; - let min_height = lines.len(); - Size::new(min_width as u16, min_height as u16) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - if let Some(placeholder) = self.hidden { - if !self.text.text().is_empty() { - placeholder.render(frame).await; - } - if self.focus { - frame.set_cursor(Some(Pos::ZERO)); - } - return; - } - - let size = frame.size(); - let widthdb = frame.widthdb(); - - let width = size.width.max(1); - let text_width = (width - 1) as usize; - let indices = wrap(widthdb, self.text.text(), text_width); - let lines = self.text.split_at_indices(&indices); - - // Determine cursor position now while we still have the lines. - let cursor_pos = if self.focus { - let (cursor_row, cursor_line_idx) = Self::wrapped_cursor(self.idx, &indices); - let cursor_col = widthdb.width(lines[cursor_row].text().split_at(cursor_line_idx).0); - let cursor_col = cursor_col.min(text_width); - Some(Pos::new(cursor_col as i32, cursor_row as i32)) - } else { - None - }; - - for (i, line) in lines.into_iter().enumerate() { - frame.write(Pos::new(0, i as i32), line); - } - - if let Some(pos) = cursor_pos { - frame.set_cursor(Some(pos)); - } - - self.state.lock().last_width = width; - } -} diff --git a/src/ui/widgets/empty.rs b/src/ui/widgets/empty.rs deleted file mode 100644 index 40ff3bf..0000000 --- a/src/ui/widgets/empty.rs +++ /dev/null @@ -1,39 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Size}; - -use super::Widget; - -#[derive(Debug, Default, Clone, Copy)] -pub struct Empty { - size: Size, -} - -impl Empty { - pub fn new() -> Self { - Self { size: Size::ZERO } - } - - pub fn width(mut self, width: u16) -> Self { - self.size.width = width; - self - } - - pub fn height(mut self, height: u16) -> Self { - self.size.height = height; - self - } - - pub fn size(mut self, size: Size) -> Self { - self.size = size; - self - } -} - -#[async_trait] -impl Widget for Empty { - fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size { - self.size - } - - async fn render(self: Box<Self>, _frame: &mut Frame) {} -} diff --git a/src/ui/widgets/float.rs b/src/ui/widgets/float.rs deleted file mode 100644 index 96f398c..0000000 --- a/src/ui/widgets/float.rs +++ /dev/null @@ -1,65 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Float { - inner: BoxedWidget, - horizontal: Option<f32>, - vertical: Option<f32>, -} - -impl Float { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - horizontal: None, - vertical: None, - } - } - - pub fn horizontal(mut self, position: f32) -> Self { - self.horizontal = Some(position); - self - } - - pub fn vertical(mut self, position: f32) -> Self { - self.vertical = Some(position); - self - } -} - -#[async_trait] -impl Widget for Float { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - self.inner.size(frame, max_width, max_height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - let mut inner_size = self.inner.size(frame, Some(size.width), Some(size.height)); - inner_size.width = inner_size.width.min(size.width); - inner_size.height = inner_size.height.min(size.height); - - let mut inner_pos = Pos::ZERO; - - if let Some(horizontal) = self.horizontal { - let available = (size.width - inner_size.width) as f32; - // Biased towards the left if horizontal lands exactly on the - // boundary between two cells - inner_pos.x = (horizontal * available).floor().min(available) as i32; - } - - if let Some(vertical) = self.vertical { - let available = (size.height - inner_size.height) as f32; - // Biased towards the top if vertical lands exactly on the boundary - // between two cells - inner_pos.y = (vertical * available).floor().min(available) as i32; - } - - frame.push(inner_pos, inner_size); - self.inner.render(frame).await; - frame.pop(); - } -} diff --git a/src/ui/widgets/join.rs b/src/ui/widgets/join.rs deleted file mode 100644 index 04d01c0..0000000 --- a/src/ui/widgets/join.rs +++ /dev/null @@ -1,241 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Segment { - widget: BoxedWidget, - expanding: bool, - priority: Option<u8>, -} - -impl Segment { - pub fn new<W: Into<BoxedWidget>>(widget: W) -> Self { - Self { - widget: widget.into(), - expanding: false, - priority: None, - } - } - - /// Expand this segment into the remaining space after all segment minimum - /// sizes have been determined. The remaining space is split up evenly. - pub fn expanding(mut self, active: bool) -> Self { - self.expanding = active; - self - } - - /// The size of segments with a priority is calculated in order of - /// increasing priority, using the remaining available space as maximum - /// space for the widget during size calculations. - /// - /// Widgets without priority are processed first without size restrictions. - pub fn priority(mut self, priority: u8) -> Self { - self.priority = Some(priority); - self - } -} - -struct SizedSegment { - idx: usize, - size: Size, - expanding: bool, - priority: Option<u8>, -} - -impl SizedSegment { - pub fn new(idx: usize, segment: &Segment) -> Self { - Self { - idx, - size: Size::ZERO, - expanding: segment.expanding, - priority: segment.priority, - } - } -} - -fn sizes_horiz( - segments: &[Segment], - frame: &mut Frame, - max_width: Option<u16>, - max_height: Option<u16>, -) -> Vec<SizedSegment> { - let mut sized = segments - .iter() - .enumerate() - .map(|(i, s)| SizedSegment::new(i, s)) - .collect::<Vec<_>>(); - sized.sort_by_key(|s| s.priority); - - let mut total_width = 0; - for s in &mut sized { - let available_width = max_width - .filter(|_| s.priority.is_some()) - .map(|w| w.saturating_sub(total_width)); - s.size = segments[s.idx] - .widget - .size(frame, available_width, max_height); - if let Some(available_width) = available_width { - s.size.width = s.size.width.min(available_width); - } - total_width += s.size.width; - } - - sized -} - -fn sizes_vert( - segments: &[Segment], - frame: &mut Frame, - max_width: Option<u16>, - max_height: Option<u16>, -) -> Vec<SizedSegment> { - let mut sized = segments - .iter() - .enumerate() - .map(|(i, s)| SizedSegment::new(i, s)) - .collect::<Vec<_>>(); - sized.sort_by_key(|s| s.priority); - - let mut total_height = 0; - for s in &mut sized { - let available_height = max_height - .filter(|_| s.priority.is_some()) - .map(|w| w.saturating_sub(total_height)); - s.size = segments[s.idx] - .widget - .size(frame, max_width, available_height); - if let Some(available_height) = available_height { - s.size.height = s.size.height.min(available_height); - } - total_height += s.size.height; - } - - sized -} - -fn expand_horiz(segments: &mut [SizedSegment], available_width: u16) { - if !segments.iter().any(|s| s.expanding) { - return; - } - - // Interestingly, rustc needs this type annotation while rust-analyzer - // manages to derive the correct type in an inlay hint. - let current_width = segments.iter().map(|s| s.size.width).sum::<u16>(); - if current_width < available_width { - let mut remaining_width = available_width - current_width; - while remaining_width > 0 { - for segment in segments.iter_mut() { - if segment.expanding { - if remaining_width > 0 { - segment.size.width += 1; - remaining_width -= 1; - } else { - break; - } - } - } - } - } -} - -fn expand_vert(segments: &mut [SizedSegment], available_height: u16) { - if !segments.iter().any(|s| s.expanding) { - return; - } - - // Interestingly, rustc needs this type annotation while rust-analyzer - // manages to derive the correct type in an inlay hint. - let current_height = segments.iter().map(|s| s.size.height).sum::<u16>(); - if current_height < available_height { - let mut remaining_height = available_height - current_height; - while remaining_height > 0 { - for segment in segments.iter_mut() { - if segment.expanding { - if remaining_height > 0 { - segment.size.height += 1; - remaining_height -= 1; - } else { - break; - } - } - } - } - } -} - -/// Place multiple widgets next to each other horizontally. -pub struct HJoin { - segments: Vec<Segment>, -} - -impl HJoin { - pub fn new(segments: Vec<Segment>) -> Self { - Self { segments } - } -} - -#[async_trait] -impl Widget for HJoin { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let sizes = sizes_horiz(&self.segments, frame, max_width, max_height); - let width = sizes.iter().map(|s| s.size.width).sum::<u16>(); - let height = sizes.iter().map(|s| s.size.height).max().unwrap_or(0); - Size::new(width, height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - let mut sizes = sizes_horiz(&self.segments, frame, Some(size.width), Some(size.height)); - expand_horiz(&mut sizes, size.width); - - sizes.sort_by_key(|s| s.idx); - let mut x = 0; - for (segment, sized) in self.segments.into_iter().zip(sizes.into_iter()) { - frame.push(Pos::new(x, 0), Size::new(sized.size.width, size.height)); - segment.widget.render(frame).await; - frame.pop(); - - x += sized.size.width as i32; - } - } -} - -/// Place multiple widgets next to each other vertically. -pub struct VJoin { - segments: Vec<Segment>, -} - -impl VJoin { - pub fn new(segments: Vec<Segment>) -> Self { - Self { segments } - } -} - -#[async_trait] -impl Widget for VJoin { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let sizes = sizes_vert(&self.segments, frame, max_width, max_height); - let width = sizes.iter().map(|s| s.size.width).max().unwrap_or(0); - let height = sizes.iter().map(|s| s.size.height).sum::<u16>(); - Size::new(width, height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - let mut sizes = sizes_vert(&self.segments, frame, Some(size.width), Some(size.height)); - expand_vert(&mut sizes, size.height); - - sizes.sort_by_key(|s| s.idx); - let mut y = 0; - for (segment, sized) in self.segments.into_iter().zip(sizes.into_iter()) { - frame.push(Pos::new(0, y), Size::new(size.width, sized.size.height)); - segment.widget.render(frame).await; - frame.pop(); - - y += sized.size.height as i32; - } - } -} diff --git a/src/ui/widgets/layer.rs b/src/ui/widgets/layer.rs deleted file mode 100644 index 7c5e659..0000000 --- a/src/ui/widgets/layer.rs +++ /dev/null @@ -1,33 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Layer { - layers: Vec<BoxedWidget>, -} - -impl Layer { - pub fn new(layers: Vec<BoxedWidget>) -> Self { - Self { layers } - } -} - -#[async_trait] -impl Widget for Layer { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let mut max_size = Size::ZERO; - for layer in &self.layers { - let size = layer.size(frame, max_width, max_height); - max_size.width = max_size.width.max(size.width); - max_size.height = max_size.height.max(size.height); - } - max_size - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - for layer in self.layers { - layer.render(frame).await; - } - } -} diff --git a/src/ui/widgets/list.rs b/src/ui/widgets/list.rs deleted file mode 100644 index 8df110c..0000000 --- a/src/ui/widgets/list.rs +++ /dev/null @@ -1,386 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use parking_lot::Mutex; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -/////////// -// State // -/////////// - -#[derive(Debug, Clone)] -struct Cursor<Id> { - /// Id of the element the cursor is pointing to. - /// - /// If the rows change (e.g. reorder) but there is still a row with this id, - /// the cursor is moved to this row. - id: Id, - /// Index of the row the cursor is pointing to. - /// - /// If the rows change and there is no longer a row with the cursor's id, - /// the cursor is moved up or down to the next selectable row. This way, it - /// stays close to its previous position. - idx: usize, -} - -impl<Id> Cursor<Id> { - pub fn new(id: Id, idx: usize) -> Self { - Self { id, idx } - } -} - -#[derive(Debug)] -struct InnerListState<Id> { - rows: Vec<Option<Id>>, - - /// Offset of the first line visible on the screen. - offset: usize, - - cursor: Option<Cursor<Id>>, - make_cursor_visible: bool, -} - -impl<Id> InnerListState<Id> { - fn new() -> Self { - Self { - rows: vec![], - offset: 0, - cursor: None, - make_cursor_visible: true, - } - } -} - -impl<Id: Clone> InnerListState<Id> { - fn first_selectable(&self) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn last_selectable(&self) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .rev() - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn selectable_at_or_before_index(&self, i: usize) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .take(i + 1) - .rev() - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn selectable_at_or_after_index(&self, i: usize) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .skip(i) - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn selectable_before_index(&self, i: usize) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .take(i) - .rev() - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn selectable_after_index(&self, i: usize) -> Option<Cursor<Id>> { - self.rows - .iter() - .enumerate() - .skip(i + 1) - .find_map(|(i, r)| r.as_ref().map(|c| Cursor::new(c.clone(), i))) - } - - fn scroll_so_cursor_is_visible(&mut self, height: usize) { - if height == 0 { - // Cursor can't be visible because nothing is visible - return; - } - - if let Some(cursor) = &self.cursor { - // As long as height > 0, min <= max is true - let min = (cursor.idx + 1).saturating_sub(height); - let max = cursor.idx; - self.offset = self.offset.clamp(min, max); - } - } - - fn move_cursor_to_make_it_visible(&mut self, height: usize) { - if let Some(cursor) = &self.cursor { - let min_idx = self.offset; - let max_idx = self.offset.saturating_add(height).saturating_sub(1); - - let new_cursor = if cursor.idx < min_idx { - self.selectable_at_or_after_index(min_idx) - } else if cursor.idx > max_idx { - self.selectable_at_or_before_index(max_idx) - } else { - return; - }; - - if let Some(new_cursor) = new_cursor { - self.cursor = Some(new_cursor); - } - } - } - - fn clamp_scrolling(&mut self, height: usize) { - let min = 0; - let max = self.rows.len().saturating_sub(height); - self.offset = self.offset.clamp(min, max); - } -} - -impl<Id: Clone + Eq> InnerListState<Id> { - fn selectable_of_id(&self, id: &Id) -> Option<Cursor<Id>> { - self.rows.iter().enumerate().find_map(|(i, r)| match r { - Some(rid) if rid == id => Some(Cursor::new(id.clone(), i)), - _ => None, - }) - } - - fn fix_cursor(&mut self) { - self.cursor = if let Some(cursor) = &self.cursor { - self.selectable_of_id(&cursor.id) - .or_else(|| self.selectable_at_or_before_index(cursor.idx)) - .or_else(|| self.selectable_at_or_after_index(cursor.idx)) - } else { - self.first_selectable() - } - } - - /// Bring the list into a state consistent with the current rows and height. - fn stabilize(&mut self, rows: &[Row<Id>], height: usize) { - self.rows = rows.iter().map(|r| r.id().cloned()).collect(); - - self.fix_cursor(); - if self.make_cursor_visible { - self.scroll_so_cursor_is_visible(height); - self.clamp_scrolling(height); - } else { - self.clamp_scrolling(height); - self.move_cursor_to_make_it_visible(height); - } - self.make_cursor_visible = true; - } -} - -pub struct ListState<Id>(Arc<Mutex<InnerListState<Id>>>); - -impl<Id> ListState<Id> { - pub fn new() -> Self { - Self(Arc::new(Mutex::new(InnerListState::new()))) - } - - pub fn widget(&self) -> List<Id> { - List::new(self.0.clone()) - } - - pub fn scroll_up(&mut self, amount: usize) { - let mut guard = self.0.lock(); - guard.offset = guard.offset.saturating_sub(amount); - guard.make_cursor_visible = false; - } - - pub fn scroll_down(&mut self, amount: usize) { - let mut guard = self.0.lock(); - guard.offset = guard.offset.saturating_add(amount); - guard.make_cursor_visible = false; - } -} - -impl<Id: Clone> ListState<Id> { - pub fn cursor(&self) -> Option<Id> { - self.0.lock().cursor.as_ref().map(|c| c.id.clone()) - } - - pub fn move_cursor_up(&mut self) { - let mut guard = self.0.lock(); - if let Some(cursor) = &guard.cursor { - if let Some(new_cursor) = guard.selectable_before_index(cursor.idx) { - guard.cursor = Some(new_cursor); - } - } - guard.make_cursor_visible = true; - } - - pub fn move_cursor_down(&mut self) { - let mut guard = self.0.lock(); - if let Some(cursor) = &guard.cursor { - if let Some(new_cursor) = guard.selectable_after_index(cursor.idx) { - guard.cursor = Some(new_cursor); - } - } - guard.make_cursor_visible = true; - } - - pub fn move_cursor_to_top(&mut self) { - let mut guard = self.0.lock(); - if let Some(new_cursor) = guard.first_selectable() { - guard.cursor = Some(new_cursor); - } - guard.make_cursor_visible = true; - } - - pub fn move_cursor_to_bottom(&mut self) { - let mut guard = self.0.lock(); - if let Some(new_cursor) = guard.last_selectable() { - guard.cursor = Some(new_cursor); - } - guard.make_cursor_visible = true; - } -} - -//////////// -// Widget // -//////////// - -enum Row<Id> { - Unselectable { - normal: BoxedWidget, - }, - Selectable { - id: Id, - normal: BoxedWidget, - selected: BoxedWidget, - }, -} - -impl<Id> Row<Id> { - fn id(&self) -> Option<&Id> { - match self { - Self::Unselectable { .. } => None, - Self::Selectable { id, .. } => Some(id), - } - } - - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - match self { - Self::Unselectable { normal } => normal.size(frame, max_width, max_height), - Self::Selectable { - normal, selected, .. - } => { - let normal_size = normal.size(frame, max_width, max_height); - let selected_size = selected.size(frame, max_width, max_height); - Size::new( - normal_size.width.max(selected_size.width), - normal_size.height.max(selected_size.height), - ) - } - } - } -} - -pub struct List<Id> { - state: Arc<Mutex<InnerListState<Id>>>, - rows: Vec<Row<Id>>, - focus: bool, -} - -impl<Id> List<Id> { - fn new(state: Arc<Mutex<InnerListState<Id>>>) -> Self { - Self { - state, - rows: vec![], - focus: false, - } - } - - pub fn focus(mut self, focus: bool) -> Self { - self.focus = focus; - self - } - - pub fn is_empty(&self) -> bool { - self.rows.is_empty() - } - - pub fn add_unsel<W: Into<BoxedWidget>>(&mut self, normal: W) { - self.rows.push(Row::Unselectable { - normal: normal.into(), - }); - } - - pub fn add_sel<W1, W2>(&mut self, id: Id, normal: W1, selected: W2) - where - W1: Into<BoxedWidget>, - W2: Into<BoxedWidget>, - { - self.rows.push(Row::Selectable { - id, - normal: normal.into(), - selected: selected.into(), - }); - } -} - -#[async_trait] -impl<Id: Clone + Eq + Send> Widget for List<Id> { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, _max_height: Option<u16>) -> Size { - let width = self - .rows - .iter() - .map(|r| r.size(frame, max_width, Some(1)).width) - .max() - .unwrap_or(0); - let height = self.rows.len(); - Size::new(width, height as u16) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - // Guard acquisition and dropping must be inside its own block or the - // compiler complains that "future created by async block is not - // `Send`", pointing to the function body. - // - // I assume this is because I'm using the parking lot mutex whose guard - // is not Send, and even though I was explicitly dropping it with - // drop(), rustc couldn't figure this out without some help. - let (offset, cursor) = { - let mut guard = self.state.lock(); - guard.stabilize(&self.rows, size.height.into()); - (guard.offset as i32, guard.cursor.clone()) - }; - - let row_size = Size::new(size.width, 1); - for (i, row) in self.rows.into_iter().enumerate() { - let dy = i as i32 - offset; - if dy < 0 || dy >= size.height as i32 { - continue; - } - - frame.push(Pos::new(0, dy), row_size); - match row { - Row::Unselectable { normal } => normal.render(frame).await, - Row::Selectable { - id, - normal, - selected, - } => { - let focusing = self.focus - && if let Some(cursor) = &cursor { - cursor.id == id - } else { - false - }; - let widget = if focusing { selected } else { normal }; - widget.render(frame).await; - } - } - frame.pop(); - } - } -} diff --git a/src/ui/widgets/padding.rs b/src/ui/widgets/padding.rs deleted file mode 100644 index 74a7e29..0000000 --- a/src/ui/widgets/padding.rs +++ /dev/null @@ -1,98 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Padding { - inner: BoxedWidget, - stretch: bool, - left: u16, - right: u16, - top: u16, - bottom: u16, -} - -impl Padding { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - stretch: false, - left: 0, - right: 0, - top: 0, - bottom: 0, - } - } - - /// Whether the inner widget should be stretched to fill the additional - /// space. - pub fn stretch(mut self, active: bool) -> Self { - self.stretch = active; - self - } - - pub fn left(mut self, amount: u16) -> Self { - self.left = amount; - self - } - - pub fn right(mut self, amount: u16) -> Self { - self.right = amount; - self - } - - pub fn horizontal(self, amount: u16) -> Self { - self.left(amount).right(amount) - } - - pub fn top(mut self, amount: u16) -> Self { - self.top = amount; - self - } - - pub fn bottom(mut self, amount: u16) -> Self { - self.bottom = amount; - self - } - - pub fn vertical(self, amount: u16) -> Self { - self.top(amount).bottom(amount) - } - - pub fn all(self, amount: u16) -> Self { - self.horizontal(amount).vertical(amount) - } -} - -#[async_trait] -impl Widget for Padding { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let horizontal = self.left + self.right; - let vertical = self.top + self.bottom; - - let max_width = max_width.map(|w| w.saturating_sub(horizontal)); - let max_height = max_height.map(|h| h.saturating_sub(vertical)); - - let size = self.inner.size(frame, max_width, max_height); - - size + Size::new(horizontal, vertical) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - - let inner_pos = Pos::new(self.left.into(), self.top.into()); - let inner_size = if self.stretch { - size - } else { - Size::new( - size.width.saturating_sub(self.left + self.right), - size.height.saturating_sub(self.top + self.bottom), - ) - }; - - frame.push(inner_pos, inner_size); - self.inner.render(frame).await; - frame.pop(); - } -} diff --git a/src/ui/widgets/popup.rs b/src/ui/widgets/popup.rs deleted file mode 100644 index 96ce7c2..0000000 --- a/src/ui/widgets/popup.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crossterm::style::ContentStyle; -use toss::styled::Styled; - -use super::background::Background; -use super::border::Border; -use super::float::Float; -use super::layer::Layer; -use super::padding::Padding; -use super::text::Text; -use super::BoxedWidget; - -pub struct Popup { - inner: BoxedWidget, - inner_padding: bool, - title: Option<Styled>, - border_style: ContentStyle, - bg_style: ContentStyle, -} - -impl Popup { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - inner_padding: true, - title: None, - border_style: ContentStyle::default(), - bg_style: ContentStyle::default(), - } - } - - pub fn inner_padding(mut self, active: bool) -> Self { - self.inner_padding = active; - self - } - - pub fn title<S: Into<Styled>>(mut self, title: S) -> Self { - self.title = Some(title.into()); - self - } - - pub fn border(mut self, style: ContentStyle) -> Self { - self.border_style = style; - self - } - - pub fn background(mut self, style: ContentStyle) -> Self { - self.bg_style = style; - self - } - - pub fn build(self) -> BoxedWidget { - let inner = if self.inner_padding { - Padding::new(self.inner).horizontal(1).into() - } else { - self.inner - }; - let window = - Border::new(Background::new(inner).style(self.bg_style)).style(self.border_style); - - let widget: BoxedWidget = if let Some(title) = self.title { - let title = Float::new( - Padding::new( - Background::new(Padding::new(Text::new(title)).horizontal(1)) - .style(self.border_style), - ) - .horizontal(2), - ); - Layer::new(vec![window.into(), title.into()]).into() - } else { - window.into() - }; - - Float::new(widget).vertical(0.5).horizontal(0.5).into() - } -} diff --git a/src/ui/widgets/resize.rs b/src/ui/widgets/resize.rs deleted file mode 100644 index 15f5577..0000000 --- a/src/ui/widgets/resize.rs +++ /dev/null @@ -1,81 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Size}; - -use super::{BoxedWidget, Widget}; - -pub struct Resize { - inner: BoxedWidget, - min_width: Option<u16>, - min_height: Option<u16>, - max_width: Option<u16>, - max_height: Option<u16>, -} - -impl Resize { - pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self { - Self { - inner: inner.into(), - min_width: None, - min_height: None, - max_width: None, - max_height: None, - } - } - - pub fn min_width(mut self, amount: u16) -> Self { - self.min_width = Some(amount); - self - } - - pub fn max_width(mut self, amount: u16) -> Self { - self.max_width = Some(amount); - self - } - - pub fn min_height(mut self, amount: u16) -> Self { - self.min_height = Some(amount); - self - } - - pub fn max_height(mut self, amount: u16) -> Self { - self.max_height = Some(amount); - self - } -} - -#[async_trait] -impl Widget for Resize { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size { - let max_width = match (max_width, self.max_width) { - (None, None) => None, - (Some(w), None) => Some(w), - (None, Some(sw)) => Some(sw), - (Some(w), Some(sw)) => Some(w.min(sw)), - }; - - let max_height = match (max_height, self.max_height) { - (None, None) => None, - (Some(h), None) => Some(h), - (None, Some(sh)) => Some(sh), - (Some(h), Some(sh)) => Some(h.min(sh)), - }; - - let size = self.inner.size(frame, max_width, max_height); - - let width = match self.min_width { - Some(min_width) => size.width.max(min_width), - None => size.width, - }; - - let height = match self.min_height { - Some(min_height) => size.height.max(min_height), - None => size.height, - }; - - Size::new(width, height) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - self.inner.render(frame).await; - } -} diff --git a/src/ui/widgets/rules.rs b/src/ui/widgets/rules.rs deleted file mode 100644 index 9fcc5df..0000000 --- a/src/ui/widgets/rules.rs +++ /dev/null @@ -1,36 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; - -use super::Widget; - -pub struct HRule; - -#[async_trait] -impl Widget for HRule { - fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size { - Size::new(0, 1) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - for x in 0..size.width as i32 { - frame.write(Pos::new(x, 0), "─"); - } - } -} - -pub struct VRule; - -#[async_trait] -impl Widget for VRule { - fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size { - Size::new(1, 0) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - for y in 0..size.height as i32 { - frame.write(Pos::new(0, y), "│"); - } - } -} diff --git a/src/ui/widgets/text.rs b/src/ui/widgets/text.rs deleted file mode 100644 index 5ab65d8..0000000 --- a/src/ui/widgets/text.rs +++ /dev/null @@ -1,63 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; -use toss::styled::Styled; -use toss::widthdb::WidthDb; - -use super::Widget; - -pub struct Text { - styled: Styled, - wrap: bool, -} - -impl Text { - pub fn new<S: Into<Styled>>(styled: S) -> Self { - Self { - styled: styled.into(), - wrap: false, - } - } - - pub fn wrap(mut self, active: bool) -> Self { - // TODO Re-think and check what behaviour this setting should entail - self.wrap = active; - self - } - - fn wrapped(&self, widthdb: &mut WidthDb, max_width: Option<u16>) -> Vec<Styled> { - let max_width = if self.wrap { - max_width.map(|w| w as usize).unwrap_or(usize::MAX) - } else { - usize::MAX - }; - - let indices = widthdb.wrap(self.styled.text(), max_width); - self.styled.clone().split_at_indices(&indices) - } -} - -#[async_trait] -impl Widget for Text { - fn size(&self, frame: &mut Frame, max_width: Option<u16>, _max_height: Option<u16>) -> Size { - let lines = self.wrapped(frame.widthdb(), max_width); - let widthdb = frame.widthdb(); - let min_width = lines - .iter() - .map(|l| widthdb.width(l.text().trim_end())) - .max() - .unwrap_or(0); - let min_height = lines.len(); - Size::new(min_width as u16, min_height as u16) - } - - async fn render(self: Box<Self>, frame: &mut Frame) { - let size = frame.size(); - for (i, line) in self - .wrapped(frame.widthdb(), Some(size.width)) - .into_iter() - .enumerate() - { - frame.write(Pos::new(0, i as i32), line); - } - } -} diff --git a/src/vault/migrate.rs b/src/vault/migrate.rs deleted file mode 100644 index e5d16da..0000000 --- a/src/vault/migrate.rs +++ /dev/null @@ -1,80 +0,0 @@ -use rusqlite::Transaction; -use vault::Migration; - -pub const MIGRATIONS: [Migration; 2] = [m1, m2]; - -fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> { - eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1); - tx.execute_batch( - " - CREATE TABLE euph_rooms ( - room TEXT NOT NULL PRIMARY KEY, - first_joined INT NOT NULL, - last_joined INT NOT NULL - ) STRICT; - - CREATE TABLE euph_msgs ( - -- Message - room TEXT NOT NULL, - id INT NOT NULL, - parent INT, - previous_edit_id INT, - time INT NOT NULL, - content TEXT NOT NULL, - encryption_key_id TEXT, - edited INT, - deleted INT, - truncated INT NOT NULL, - - -- SessionView - user_id TEXT NOT NULL, - name TEXT, - server_id TEXT NOT NULL, - server_era TEXT NOT NULL, - session_id TEXT NOT NULL, - is_staff INT NOT NULL, - is_manager INT NOT NULL, - client_address TEXT, - real_client_address TEXT, - - PRIMARY KEY (room, id), - FOREIGN KEY (room) REFERENCES euph_rooms (room) - ON DELETE CASCADE - ) STRICT; - - CREATE TABLE euph_spans ( - room TEXT NOT NULL, - start INT, - end INT, - - UNIQUE (room, start, end), - FOREIGN KEY (room) REFERENCES euph_rooms (room) - ON DELETE CASCADE, - CHECK (start IS NULL OR end IS NOT NULL) - ) STRICT; - - CREATE TABLE euph_cookies ( - cookie TEXT NOT NULL - ) STRICT; - - CREATE INDEX euph_idx_msgs_room_id_parent - ON euph_msgs (room, id, parent); - - CREATE INDEX euph_idx_msgs_room_parent_id - ON euph_msgs (room, parent, id); - ", - ) -} - -fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> { - eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1); - tx.execute_batch( - " - ALTER TABLE euph_msgs - ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE; - - CREATE INDEX euph_idx_msgs_room_id_seen - ON euph_msgs (room, id, seen); - ", - ) -}