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 new file mode 100644 index 0000000..4e428aa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "files.insertFinalNewline": true, + "rust-analyzer.cargo.features": "all", + "rust-analyzer.imports.granularity.enforce": true, + "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 9b81f7a..3f9ce8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,254 @@ 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 +- Option to export to stdout via `--out -` +- `--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`) +- 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 +- Session inspection popup +- Error popup when external editor fails +- `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 + +## v0.4.0 - 2022-09-01 + +### Added + +- Config file and `--config` cli option +- `data_dir` config option +- `ephemeral` config option +- `offline` config option and `--offline` cli flag +- `euph.rooms..autojoin` config option +- `euph.rooms..username` config option +- `euph.rooms..force_username` config option +- `euph.rooms..password` config option +- Key binding to change rooms sort order +- Key bindings to connect to/disconnect from all rooms +- Key bindings to connect to autojoin rooms/disconnect from non-autojoin rooms +- Key bindings to move to parent/root message +- 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 @@ -24,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 @@ -35,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 @@ -56,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 4a7be31..2f45a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,38 +1,104 @@ # 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.18" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] -name = "anyhow" -version = "1.0.62" +name = "anstream" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1485d4d2cc45e7b201ee3767015c96faa5904387c9d87c6efdd0fb511f12d305" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" [[package]] name = "async-trait" -version = "0.1.57" +version = "0.1.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f" +checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" dependencies = [ "proc-macro2", "quote", @@ -40,66 +106,121 @@ dependencies = [ ] [[package]] -name = "atty" -version = "0.2.14" +name = "autocfg" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "aws-lc-rs" +version = "1.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabb68eb3a7aa08b46fddfd59a3d55c978243557a90ab804769f7e20e67d2b01" dependencies = [ - "hermit-abi", - "libc", - "winapi", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "autocfg" -version = "1.1.0" +name = "aws-lc-sys" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "6bbe221bbf523b625a4dd8585c7f38166e31167ec2ca98051dbcb4c3b6e825d2" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] [[package]] -name = "base64" -version = "0.13.0" +name = "backtrace" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +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" -version = "0.10.2" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] -[[package]] -name = "bumpalo" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" - -[[package]] -name = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - [[package]] name = "bytes" -version = "1.2.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] [[package]] name = "cc" -version = "1.0.73" +version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +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" @@ -108,30 +229,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "clap" -version = "3.2.17" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e724a68d9319343bb3328c9cc2dfde263f4b3142ee1059a9980580171c954b" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ - "atty", - "bitflags", + "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", +] + +[[package]] +name = "clap_builder" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" +dependencies = [ + "anstream", + "anstyle", "clap_lex", - "indexmap", - "once_cell", "strsim", - "termcolor", - "textwrap", ] [[package]] name = "clap_derive" -version = "3.2.17" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13547f7012c01ab4a0e8f8967730ada8f9fdf419e8b6c792788f39cf4e46eefa" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", - "proc-macro-error", "proc-macro2", "quote", "syn", @@ -139,18 +275,30 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.2.4" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" dependencies = [ - "os_str_bytes", + "cc", ] [[package]] -name = "cookie" -version = "0.16.0" +name = "colorchoice" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" +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", @@ -158,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", @@ -168,55 +316,92 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.3" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cove" -version = "0.3.0" +version = "0.9.3" dependencies = [ "anyhow", "async-trait", "clap", "cookie", + "cove-config", + "cove-input", "crossterm", "directories", - "edit", "euphoxide", + "jiff", + "linkify", "log", + "open", "parking_lot", "rusqlite", + "rustls", "serde_json", "thiserror", - "time", "tokio", - "tokio-tungstenite", "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.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc948ebb96241bb40ab73effeb80d9f93afaad49359d159a5e61be51619fe813" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crossterm" -version = "0.25.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -224,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", ] @@ -242,10 +427,25 @@ dependencies = [ ] [[package]] -name = "digest" -version = "0.10.3" +name = "data-encoding" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +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", @@ -253,29 +453,36 @@ dependencies = [ [[package]] name = "directories" -version = "4.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.3.7" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", + "option-ext", "redox_users", - "winapi", + "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", @@ -283,29 +490,51 @@ dependencies = [ [[package]] name = "either" -version = "1.8.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] [[package]] name = "euphoxide" -version = "0.1.0" -source = "git+https://github.com/Garmelon/euphoxide.git?rev=01a442c1f0695bd11b8f54db406b3a3a03d61983#01a442c1f0695bd11b8f54db406b3a3a03d61983" +version = "0.6.1" +source = "git+https://github.com/Garmelon/euphoxide.git?tag=v0.6.1#7a292c429ad44aa6aa52fc381e3168841d6303b0" dependencies = [ - "futures", + "async-trait", + "caseless", + "clap", + "cookie", + "futures-util", + "jiff", + "log", "serde", "serde_json", - "thiserror", - "time", "tokio", + "tokio-stream", "tokio-tungstenite", + "unicode-normalization", ] [[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" @@ -315,12 +544,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "1.8.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" -dependencies = [ - "instant", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fnv" @@ -329,75 +555,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "form_urlencoded" -version = "1.0.1" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" -dependencies = [ - "matches", - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab30e97ab6aacfe635fad58f22c2bb06c8b685f7421eb1e064a729e2a5f481fa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfc52cbddcfd745bf1740338492bb0bd83d76c67b445f91c5fb29fae29ecaa1" -dependencies = [ - "futures-core", - "futures-sink", -] +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-core" -version = "0.3.23" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2acedae88d38235936c3922476b10fced7b2b68136f5e3c03c2d5be348a1115" - -[[package]] -name = "futures-io" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93a66fc6d035a26a3ae255a6d2bca35eda63ae4c5512bef54449113f7a1228e5" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-sink" -version = "0.3.23" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0bae1fe9752cf7fd9b0064c674ae63f97b37bc714d745cbde0afb7ec4e6765" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.23" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842fc63b931f4056a24d59de13fb1272134ce261816e063e634ad0c15cdc5306" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.23" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0828a5471e340229c11c77ca80017937ce3c58cb788a17e5f1c2d5c485a9577" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", "slab", @@ -405,9 +594,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.6" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -415,53 +604,83 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.7" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +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.0" +name = "hashbrown" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +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.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "libc", + "windows-sys 0.59.0", ] [[package]] name = "http" -version = "0.2.8" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -470,72 +689,153 @@ dependencies = [ [[package]] name = "httparse" -version = "1.7.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" - -[[package]] -name = "idna" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" -dependencies = [ - "matches", - "unicode-bidi", - "unicode-normalization", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "indexmap" -version = "1.9.1" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +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 = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", ] [[package]] name = "itoa" -version = "1.0.3" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] -name = "js-sys" -version = "0.3.59" +name = "jiff" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" dependencies = [ - "wasm-bindgen", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-static" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +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.132" +version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +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.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f0455f2c1bc9a7caa792907026e469c1d91761fb0ea37cbb16427c77280cf35" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" dependencies = [ "cc", "pkg-config", @@ -543,10 +843,31 @@ dependencies = [ ] [[package]] -name = "lock_api" -version = "0.4.7" +name = "linkify" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -554,79 +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", -] - -[[package]] -name = "matches" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] [[package]] name = "mio" -version = "0.8.4" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "log", - "wasi", - "windows-sys", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", ] [[package]] -name = "num_cpus" -version = "1.13.1" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "hermit-abi", - "libc", + "memchr", + "minimal-lexical", ] [[package]] -name = "num_threads" -version = "0.1.6" +name = "num-conv" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "libc", + "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.13.1" +version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "os_str_bytes" -version = "6.3.0" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" +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", @@ -634,28 +996,28 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.3" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-targets", ] [[package]] -name = "percent-encoding" -version = "2.1.0" +name = "pathdiff" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +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" @@ -665,74 +1027,84 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.25" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] -name = "ppv-lite86" -version = "0.2.16" +name = "portable-atomic" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "portable-atomic-util" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn", - "version_check", + "portable-atomic", ] [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", - "quote", - "version_check", + "syn", ] [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +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", @@ -740,38 +1112,50 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.3" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +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" +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", + "getrandom 0.2.15", + "libredox", "thiserror", ] [[package]] name = "regex" -version = "1.6.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +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", @@ -780,39 +1164,29 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.27" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" - -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] +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", @@ -824,75 +1198,114 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.20.6" +name = "rustc-demangle" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" +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", + "libc", + "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.23.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.1" +name = "rustls-pki-types" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55" -dependencies = [ - "base64", -] - -[[package]] -name = "ryu" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" - -[[package]] -name = "schannel" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" -dependencies = [ - "lazy_static", - "windows-sys", -] - -[[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.7.0" +name = "ryu" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" +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", @@ -903,9 +1316,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.6.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -913,18 +1326,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.144" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.144" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" +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", @@ -932,21 +1355,41 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.85" +name = "serde_either" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "689643f4e7826ffcd227d2cc166bfdf5869750191ffe9fd593531e6ba351f2fb" +dependencies = [ + "serde", + "serde-value", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] -name = "sha-1" -version = "0.10.0" +name = "serde_spanned" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", @@ -954,10 +1397,16 @@ dependencies = [ ] [[package]] -name = "signal-hook" -version = "0.3.14" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +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", @@ -965,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", @@ -976,55 +1425,55 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "slab" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" -version = "1.9.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" -version = "0.4.4" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", - "winapi", + "windows-sys 0.52.0", ] [[package]] -name = "spin" -version = "0.5.2" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strsim" -version = "0.10.0" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.99" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -1033,47 +1482,31 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.3.0" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" dependencies = [ - "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "getrandom 0.3.1", + "once_cell", + "rustix 1.0.2", + "windows-sys 0.59.0", ] -[[package]] -name = "termcolor" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "textwrap" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" - [[package]] name = "thiserror" -version = "1.0.32" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.32" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -1082,64 +1515,73 @@ dependencies = [ [[package]] name = "time" -version = "0.3.13" +version = "0.3.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45" +checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" dependencies = [ + "deranged", "itoa", - "libc", - "num_threads", + "num-conv", + "powerfmt", "serde", + "time-core", "time-macros", ] [[package]] -name = "time-macros" -version = "0.2.4" +name = "time-core" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" +checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" + +[[package]] +name = "time-macros" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", ] [[package]] name = "tinyvec_macros" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.20.1" +version = "1.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a8325f63a7d4774dd041e363b2409ed1c5cbbd0f867795e661df066b2b0a581" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" dependencies = [ - "autocfg", + "backtrace", "bytes", "libc", - "memchr", "mio", - "num_cpus", - "once_cell", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "winapi", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "1.8.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -1148,36 +1590,81 @@ dependencies = [ [[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", ] [[package]] name = "tokio-tungstenite" -version = "0.17.2" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", "rustls", "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-rustls", "tungstenite", - "webpki", +] + +[[package]] +name = "toml" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", ] [[package]] name = "toss" -version = "0.1.0" -source = "git+https://github.com/Garmelon/toss.git?rev=45ece466c235cce6e998bbd404f915cad3628c8c#45ece466c235cce6e998bbd404f915cad3628c8c" +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", @@ -1186,90 +1673,67 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.17.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "base64", - "byteorder", "bytes", + "data-encoding", "http", "httparse", "log", "rand", "rustls", - "sha-1", + "rustls-pki-types", + "sha1", "thiserror", - "url", "utf-8", - "webpki", ] [[package]] name = "typenum" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" - -[[package]] -name = "unicode-bidi" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-ident" -version = "1.0.3" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-linebreak" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" -dependencies = [ - "regex", -] +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" -version = "0.1.21" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.9.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +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.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" -dependencies = [ - "form_urlencoded", - "idna", - "matches", - "percent-encoding", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "utf-8" @@ -1277,6 +1741,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vault" +version = "0.4.0" +source = "git+https://github.com/Garmelon/vault.git?tag=v0.4.0#a53254d2e787d15fd2d00584fddf9b84e79572ee" +dependencies = [ + "rusqlite", + "tokio", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1285,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" @@ -1296,88 +1775,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.82" +name = "wasi" +version = "0.13.3+wasi-0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.82" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" - -[[package]] -name = "web-sys" -version = "0.3.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed055ab27f941423197eb86b2035720b1a3ce40504df082cac2ecc6ed73335a1" -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.2.5" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" dependencies = [ "either", - "lazy_static", - "libc", + "home", + "once_cell", + "rustix 0.38.44", ] [[package]] @@ -1396,15 +1811,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1413,43 +1819,146 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" -version = "0.36.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", "windows_x86_64_msvc", ] [[package]] -name = "windows_aarch64_msvc" -version = "0.36.1" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.36.1" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +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.36.1" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.36.1" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.36.1" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 aebfd23..33f245f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,43 +1,72 @@ -[package] -name = "cove" -version = "0.3.0" -edition = "2021" +[workspace] +resolver = "3" +members = ["cove", "cove-*"] -[dependencies] -anyhow = "1.0.62" -async-trait = "0.1.57" -clap = { version = "3.2.17", features = ["derive"] } -cookie = "0.16.0" -crossterm = "0.25.0" -directories = "4.0.1" -edit = "0.1.4" -log = { version = "0.4.17", features = ["std"] } -parking_lot = "0.12.1" -rusqlite = { version = "0.28.0", features = ["bundled", "time"] } -serde_json = "1.0.85" -thiserror = "1.0.32" -tokio = { version = "1.20.1", features = ["full"] } -unicode-segmentation = "1.9.0" -unicode-width = "0.1.9" +[workspace.package] +version = "0.9.3" +edition = "2024" -[dependencies.time] -version = "0.3.13" -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.17.2" -features = ["rustls-tls-native-roots"] - -[dependencies.euphoxide] +[workspace.dependencies.euphoxide] git = "https://github.com/Garmelon/euphoxide.git" -rev = "01a442c1f0695bd11b8f54db406b3a3a03d61983" +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 = "45ece466c235cce6e998bbd404f915cad3628c8c" +tag = "v0.3.4" -# [patch."https://github.com/Garmelon/toss.git"] -# toss = { path = "../toss/" } +[workspace.dependencies.vault] +git = "https://github.com/Garmelon/vault.git" +tag = "v0.4.0" +features = ["tokio"] + +[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 7cdfd17..22fef83 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,18 @@ # 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. -## Manual installation +## Installing cove -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. +Download a binary of your choice from the +[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest). -### 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 -``` - -### Using cove +## Using cove To start cove, simply run `cove` in your terminal. For more info about the available subcommands such as exporting room logs or resetting cookies, run @@ -66,3 +22,12 @@ If you delete rooms, cove's vault (the database it stores messages and other 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. + +## Configuring cove + +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. 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/cove/src/euph/room.rs b/cove/src/euph/room.rs new file mode 100644 index 0000000..a4e29cf --- /dev/null +++ b/cove/src/euph/room.rs @@ -0,0 +1,316 @@ +use std::{convert::Infallible, time::Duration}; + +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 log::{debug, info, warn}; +use tokio::{select, sync::oneshot}; + +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, + Connecting, + Connected(ConnTx, conn::State), + Stopped, +} + +impl State { + pub fn conn_tx(&self) -> Option<&ConnTx> { + if let Self::Connected(conn_tx, _) = self { + Some(conn_tx) + } else { + None + } + } + + pub fn joined(&self) -> Option<&Joined> { + match self { + Self::Connected(_, conn::State::Joined(joined)) => Some(joined), + _ => None, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not connected to room")] + NotConnected, +} + +#[derive(Debug)] +pub struct Room { + vault: EuphRoomVault, + ephemeral: bool, + + instance: Instance, + state: State, + + /// `None` before any `snapshot-event`, then either `Some(None)` or + /// `Some(Some(id))`. Reset whenever connection is lost. + last_msg_id: Option>, + + /// `Some` while `Self::regularly_request_logs` is running. Set to `None` to + /// drop the sender and stop the task. + log_request_canary: Option>, +} + +impl Room { + pub fn new(vault: EuphRoomVault, instance_config: InstanceConfig, on_event: F) -> Self + where + F: Fn(Event) + std::marker::Send + Sync + 'static, + { + Self { + ephemeral: vault.vault().vault().ephemeral(), + instance: instance_config.build(on_event), + state: State::Disconnected, + last_msg_id: None, + log_request_canary: None, + vault, + } + } + + pub fn stopped(&self) -> bool { + self.instance.stopped() + } + + pub fn instance(&self) -> &Instance { + &self.instance + } + + pub fn state(&self) -> &State { + &self.state + } + + fn conn_tx(&self) -> Result<&ConnTx, Error> { + self.state.conn_tx().ok_or(Error::NotConnected) + } + + pub async fn handle_event(&mut self, event: Event) { + match event { + Event::Connecting(_) => { + self.state = State::Connecting; + + // Juuust to make sure + self.last_msg_id = None; + self.log_request_canary = None; + } + Event::Connected(_, ConnSnapshot { conn_tx, state }) => { + if !self.ephemeral { + let (tx, rx) = oneshot::channel(); + self.log_request_canary = Some(tx); + let vault_clone = self.vault.clone(); + let conn_tx_clone = conn_tx.clone(); + debug!("{}: spawning log request task", self.instance.config().room); + tokio::task::spawn(async move { + select! { + _ = rx => {}, + _ = Self::regularly_request_logs(vault_clone, conn_tx_clone) => {}, + } + }); + } + + self.state = State::Connected(conn_tx, state); + + let cookies = &*self.instance.config().server.cookies; + let cookies = cookies.lock().unwrap().clone(); + let domain = self.vault.room().domain.clone(); + logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await); + } + Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => { + self.state = State::Connected(conn_tx, state); + self.on_packet(packet).await; + } + Event::Disconnected(_) => { + self.state = State::Disconnected; + self.last_msg_id = None; + self.log_request_canary = None; + } + Event::Stopped(_) => { + self.state = State::Stopped; + } + } + } + + async fn regularly_request_logs(vault: EuphRoomVault, conn_tx: ConnTx) { + // TODO Make log downloading smarter + + // Possible log-related mechanics. Some of these could also run in some + // sort of "repair logs" mode that can be started via some key binding. + // For now, this is just a list of ideas. + // + // Download room history until there are no more gaps between now and + // the first known message. + // + // Download room history until reaching the beginning of the room's + // history. + // + // Check if the last known message still exists on the server. If it + // doesn't, do a binary search to find the server's last message and + // delete all older messages. + // + // Untruncate messages in the history, as well as new messages. + // + // Try to retrieve messages that are not in the room log by retrieving + // them by id. + // + // Redownload messages that are already known to find any edits and + // deletions that happened while the client was offline. + // + // Delete messages marked as deleted as well as all their children. + + loop { + tokio::time::sleep(LOG_INTERVAL).await; + Self::request_logs(&vault, &conn_tx).await; + } + } + + async fn request_logs(vault: &EuphRoomVault, conn_tx: &ConnTx) { + let before = match logging_unwrap!(vault.last_span().await) { + Some((None, _)) => return, // Already at top of room history + Some((Some(before), _)) => Some(before), + None => None, + }; + + debug!("{:?}: requesting logs", vault.room()); + + 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. + } + + fn own_user_id(&self) -> Option { + if let State::Connected(_, state) = &self.state { + Some(match state { + conn::State::Joining(joining) => joining.hello.as_ref()?.session.id.clone(), + conn::State::Joined(joined) => joined.session.id.clone(), + }) + } else { + None + } + } + + async fn on_packet(&mut self, packet: ParsedPacket) { + let room_name = &self.instance.config().room; + let Ok(data) = &packet.content else { + return; + }; + match data { + Data::BounceEvent(_) => {} + Data::DisconnectEvent(_) => {} + Data::HelloEvent(_) => {} + Data::JoinEvent(d) => { + debug!("{room_name}: {:?} joined", d.0.name); + } + Data::LoginEvent(_) => {} + Data::LogoutEvent(_) => {} + Data::NetworkEvent(d) => { + warn!("{room_name}: network event ({})", d.r#type); + } + Data::NickEvent(d) => { + debug!("{room_name}: {:?} renamed to {:?}", d.from, d.to); + } + Data::EditMessageEvent(_) => { + info!("{room_name}: a message was edited"); + } + Data::PartEvent(d) => { + debug!("{room_name}: {:?} left", d.0.name); + } + Data::PingEvent(_) => {} + Data::PmInitiateEvent(d) => { + // TODO Show info popup and automatically join PM room + info!( + "{room_name}: {:?} initiated a pm from &{}", + d.from_nick, d.from_room + ); + } + Data::SendEvent(SendEvent(msg)) | Data::SendReply(SendReply(msg)) => { + let own_user_id = self.own_user_id(); + if let Some(last_msg_id) = &mut self.last_msg_id { + logging_unwrap!( + self.vault + .add_msg(Box::new(msg.clone()), *last_msg_id, own_user_id) + .await + ); + *last_msg_id = Some(msg.id); + } + } + Data::SnapshotEvent(d) => { + info!("{room_name}: successfully joined"); + logging_unwrap!(self.vault.join(Time::now()).await); + self.last_msg_id = Some(d.log.last().map(|m| m.id)); + logging_unwrap!( + self.vault + .add_msgs(d.log.clone(), None, self.own_user_id()) + .await + ); + } + Data::LogReply(d) => { + logging_unwrap!( + self.vault + .add_msgs(d.log.clone(), d.before, self.own_user_id()) + .await + ); + } + _ => {} + } + } + + pub fn auth(&self, password: String) -> Result<(), Error> { + self.conn_tx()?.send_only(Auth { + r#type: AuthOption::Passcode, + passcode: Some(password), + }); + Ok(()) + } + + pub fn log(&self) -> Result<(), Error> { + let conn_tx_clone = self.conn_tx()?.clone(); + let vault_clone = self.vault.clone(); + tokio::task::spawn(async move { Self::request_logs(&vault_clone, &conn_tx_clone).await }); + Ok(()) + } + + pub fn nick(&self, name: String) -> Result<(), Error> { + self.conn_tx()?.send_only(Nick { name }); + Ok(()) + } + + pub fn send( + &self, + parent: Option, + content: String, + ) -> Result, Error> { + let reply = self.conn_tx()?.send(Send { content, parent }); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + if let Ok(reply) = reply.await { + let _ = tx.send(reply.0.id); + } + }); + Ok(rx) + } + + pub fn login(&self, email: String, password: String) -> Result<(), Error> { + self.conn_tx()?.send_only(Login { + namespace: "email".to_string(), + id: email, + password, + }); + Ok(()) + } + + pub fn logout(&self) -> Result<(), Error> { + 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/cove/src/euph/util.rs b/cove/src/euph/util.rs new file mode 100644 index 0000000..ea1782a --- /dev/null +++ b/cove/src/euph/util.rs @@ -0,0 +1,96 @@ +use std::{ + collections::HashSet, + hash::{DefaultHasher, Hash, Hasher}, + sync::LazyLock, +}; + +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]. +/// +/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`. +/// +/// [1]: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + assert!((0.0..=360.0).contains(&h), "h must be in range [0, 360]"); + assert!((0.0..=1.0).contains(&s), "s must be in range [0, 1]"); + assert!((0.0..=1.0).contains(&l), "l must be in range [0, 1]"); + + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + + let h_prime = h / 60.0; + let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs()); + + let (r1, g1, b1) = match () { + _ if h_prime < 1.0 => (c, x, 0.0), + _ if h_prime < 2.0 => (x, c, 0.0), + _ if h_prime < 3.0 => (0.0, c, x), + _ if h_prime < 4.0 => (0.0, x, c), + _ if h_prime < 5.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + + let m = l - c / 2.0; + let (r, g, b) = (r1 + m, g1 + m, b1 + m); + + // The rgb values in the range [0,1] are each split into 256 segments of the + // same length, which are then assigned to the 256 possible values of an u8. + ((r * 256.0) as u8, (g * 256.0) as u8, (b * 256.0) as u8) +} + +pub fn nick_color(nick: &str) -> (u8, u8, u8) { + let hue = euphoxide::nick::hue(&EMOJI, nick) as f32; + hsl_to_rgb(hue, 1.0, 0.72) +} + +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: Style) -> Styled { + Styled::new(EMOJI.replace(nick), nick_style(nick, base)) +} + +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/cove/src/export.rs b/cove/src/export.rs new file mode 100644 index 0000000..80db7b6 --- /dev/null +++ b/cove/src/export.rs @@ -0,0 +1,158 @@ +//! 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; + +#[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 (https://jsonlines.org/). + JsonLines, +} + +impl Format { + fn name(&self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + Self::JsonLines => "json lines", + } + } + + fn extension(&self) -> &'static str { + match self { + Self::Text => "txt", + Self::Json => "json", + Self::JsonLines => "jsonl", + } + } +} + +#[derive(Debug, clap::Parser)] +pub struct Args { + rooms: Vec, + + /// Export all rooms. + #[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, + + /// Location of the output file + /// + /// May include the following placeholders: + /// `%r` - room name + /// `%e` - format extension + /// A literal `%` can be written as `%%`. + /// + /// If the value ends with a `/`, it is assumed to point to a directory and + /// `%r.%e` will be appended. + /// + /// If the value is a literal `-`, the export will be written to stdout. To + /// write to a file named `-`, you can use `./-`. + /// + /// Must be a valid utf-8 encoded string. + #[arg(long, short, default_value_t = Into::into("%r.%e"))] + #[arg(verbatim_doc_comment)] + out: String, +} + +async fn export_room( + vault: &EuphRoomVault, + out: &mut W, + format: Format, +) -> anyhow::Result<()> { + match format { + Format::Text => text::export(vault, out).await?, + Format::Json => json::export(vault, out).await?, + Format::JsonLines => json::export_lines(vault, out).await?, + } + Ok(()) +} + +pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> { + if args.out.ends_with('/') { + args.out.push_str("%r.%e"); + } + + let rooms = if args.all { + let mut rooms = vault + .rooms() + .await? + .into_iter() + .map(|id| id.name) + .collect::>(); + rooms.sort_unstable(); + rooms + } else { + let mut rooms = args.rooms.clone(); + rooms.dedup(); + rooms + }; + + if rooms.is_empty() { + eprintln!("No rooms to export"); + } + + for room in rooms { + if args.out == "-" { + eprintln!("Exporting &{room} as {} to stdout", args.format.name()); + 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(RoomIdentifier::new(args.domain.clone(), room)); + let mut file = BufWriter::new(File::create(out)?); + export_room(&vault, &mut file, args.format).await?; + file.flush()?; + } + } + + Ok(()) +} + +fn format_out(out: &str, room: &str, format: Format) -> String { + let mut result = String::new(); + + let mut special = false; + for char in out.chars() { + if special { + match char { + 'r' => result.push_str(room), + 'e' => result.push_str(format.extension()), + '%' => result.push('%'), + _ => { + result.push('%'); + result.push(char); + } + } + special = false; + } else if char == '%' { + special = true; + } else { + result.push(char); + } + } + + result +} diff --git a/cove/src/export/json.rs b/cove/src/export/json.rs new file mode 100644 index 0000000..9c16e46 --- /dev/null +++ b/cove/src/export/json.rs @@ -0,0 +1,63 @@ +use std::io::Write; + +use crate::vault::EuphRoomVault; + +const CHUNK_SIZE: usize = 10000; + +pub async fn export(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> { + write!(file, "[")?; + + let mut total = 0; + let mut last_msg_id = None; + loop { + 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 { + writeln!(file)?; + } else { + writeln!(file, ",")?; + } + serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D + total += 1; + } + + if total % 100000 == 0 { + eprintln!(" {total} messages"); + } + } + + write!(file, "\n]")?; + + eprintln!(" {total} messages in total"); + Ok(()) +} + +pub async fn export_lines(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> { + let mut total = 0; + let mut last_msg_id = None; + loop { + 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 + writeln!(file)?; + total += 1; + } + + if total % 100000 == 0 { + eprintln!(" {total} messages"); + } + } + + eprintln!(" {total} messages in total"); + Ok(()) +} diff --git a/cove/src/export/text.rs b/cove/src/export/text.rs new file mode 100644 index 0000000..2ca6687 --- /dev/null +++ b/cove/src/export/text.rs @@ -0,0 +1,78 @@ +use std::io::Write; + +use euphoxide::api::MessageId; +use unicode_width::UnicodeWidthStr; + +use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault}; + +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<()> { + let mut exported_trees = 0; + let mut exported_msgs = 0; + let mut root_id = vault.first_root_id().await?; + while let Some(some_root_id) = root_id { + let tree = vault.tree(some_root_id).await?; + write_tree(out, &tree, some_root_id, 0)?; + root_id = vault.next_root_id(some_root_id).await?; + + exported_trees += 1; + exported_msgs += tree.len(); + + if exported_trees % 10000 == 0 { + eprintln!(" {exported_trees} trees, {exported_msgs} messages") + } + } + eprintln!(" {exported_trees} trees, {exported_msgs} messages in total"); + + Ok(()) +} + +fn write_tree( + out: &mut W, + tree: &Tree, + id: MessageId, + indent: usize, +) -> anyhow::Result<()> { + let indent_string = "| ".repeat(indent); + + if let Some(msg) = tree.msg(&id) { + write_msg(out, &indent_string, msg)?; + } else { + write_placeholder(out, &indent_string)?; + } + + if let Some(children) = tree.children(&id) { + for child in children { + write_tree(out, tree, *child, indent + 1)?; + } + } + + Ok(()) +} + +fn write_msg( + file: &mut W, + indent_string: &str, + msg: &SmallMessage, +) -> anyhow::Result<()> { + let nick = &msg.nick; + let nick_empty = " ".repeat(nick.width()); + + for (i, line) in msg.content.lines().enumerate() { + if i == 0 { + 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}")?; + } + } + + Ok(()) +} + +fn write_placeholder(file: &mut W, indent_string: &str) -> anyhow::Result<()> { + writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?; + Ok(()) +} diff --git a/cove/src/logger.rs b/cove/src/logger.rs new file mode 100644 index 0000000..940e1a9 --- /dev/null +++ b/cove/src/logger.rs @@ -0,0 +1,245 @@ +use std::{convert::Infallible, sync::Arc, vec}; + +use async_trait::async_trait; +use crossterm::style::Stylize; +use jiff::Timestamp; +use log::{Level, LevelFilter, Log}; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use toss::{Style, Styled}; + +use crate::{ + store::{Msg, MsgStore, Path, Tree}, + ui::ChatMsg, +}; + +#[derive(Debug, Clone)] +pub struct LogMsg { + id: usize, + time: Timestamp, + level: Level, + content: String, +} + +impl Msg for LogMsg { + type Id = usize; + + fn id(&self) -> Self::Id { + self.id + } + + fn parent(&self) -> Option { + None + } + + fn seen(&self) -> bool { + true + } + + fn last_possible_id() -> Self::Id { + Self::Id::MAX + } +} + +impl ChatMsg for LogMsg { + fn time(&self) -> Option { + Some(self.time) + } + + fn styled(&self) -> (Styled, Styled) { + let nick_style = match self.level { + 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); + (nick, content) + } + + fn edit(_nick: &str, _content: &str) -> (Styled, Styled) { + panic!("log is not editable") + } + + fn pseudo(_nick: &str, _content: &str) -> (Styled, Styled) { + panic!("log is not editable") + } +} + +/// Prints all error messages when dropped. +pub struct LoggerGuard { + messages: Arc>>, +} + +impl Drop for LoggerGuard { + fn drop(&mut self) { + let guard = self.messages.lock(); + let mut error_encountered = false; + for msg in &*guard { + if msg.level == Level::Error { + if !error_encountered { + eprintln!(); + eprintln!("The following errors occurred while cove was running:"); + } + error_encountered = true; + eprintln!("{}", msg.content); + } + } + if error_encountered { + eprintln!(); + } + } +} + +#[derive(Debug, Clone)] +pub struct Logger { + event_tx: mpsc::UnboundedSender<()>, + messages: Arc>>, +} + +#[async_trait] +impl MsgStore for Logger { + type Error = Infallible; + + async fn path(&self, id: &usize) -> Result, Self::Error> { + Ok(Path::new(vec![*id])) + } + + async fn msg(&self, id: &usize) -> Result, Self::Error> { + Ok(self.messages.lock().get(*id).cloned()) + } + + async fn tree(&self, root_id: &usize) -> Result, Self::Error> { + let msgs = self + .messages + .lock() + .get(*root_id) + .map(|msg| vec![msg.clone()]) + .unwrap_or_default(); + Ok(Tree::new(*root_id, msgs)) + } + + async fn first_root_id(&self) -> Result, Self::Error> { + let empty = self.messages.lock().is_empty(); + Ok(Some(0).filter(|_| !empty)) + } + + async fn last_root_id(&self) -> Result, Self::Error> { + Ok(self.messages.lock().len().checked_sub(1)) + } + + async fn prev_root_id(&self, root_id: &usize) -> Result, Self::Error> { + Ok(root_id.checked_sub(1)) + } + + async fn next_root_id(&self, root_id: &usize) -> Result, Self::Error> { + let len = self.messages.lock().len(); + Ok(root_id.checked_add(1).filter(|t| *t < len)) + } + + async fn oldest_msg_id(&self) -> Result, Self::Error> { + self.first_root_id().await + } + + async fn newest_msg_id(&self) -> Result, Self::Error> { + self.last_root_id().await + } + + async fn older_msg_id(&self, id: &usize) -> Result, Self::Error> { + self.prev_root_id(id).await + } + + async fn newer_msg_id(&self, id: &usize) -> Result, Self::Error> { + self.next_root_id(id).await + } + + async fn oldest_unseen_msg_id(&self) -> Result, Self::Error> { + Ok(None) + } + + async fn newest_unseen_msg_id(&self) -> Result, Self::Error> { + Ok(None) + } + + async fn older_unseen_msg_id(&self, _id: &usize) -> Result, Self::Error> { + Ok(None) + } + + async fn newer_unseen_msg_id(&self, _id: &usize) -> Result, Self::Error> { + Ok(None) + } + + async fn unseen_msgs_count(&self) -> Result { + Ok(0) + } + + async fn set_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> { + Ok(()) + } + + async fn set_older_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> { + Ok(()) + } +} + +impl Log for Logger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + if metadata.level() <= Level::Info { + return true; + } + + let target = metadata.target(); + if target.starts_with("cove") + || target.starts_with("euphoxide::bot") + || target.starts_with("euphoxide::live") + { + return true; + } + + false + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + + let mut guard = self.messages.lock(); + let msg = LogMsg { + id: guard.len(), + time: Timestamp::now(), + level: record.level(), + content: format!("<{}> {}", record.target(), record.args()), + }; + guard.push(msg); + + let _ = self.event_tx.send(()); + } + + fn flush(&self) {} +} + +impl Logger { + pub fn init(verbose: bool) -> (Self, LoggerGuard, mpsc::UnboundedReceiver<()>) { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let logger = Self { + event_tx, + messages: Arc::new(Mutex::new(Vec::new())), + }; + let guard = LoggerGuard { + messages: logger.messages.clone(), + }; + + log::set_max_level(if verbose { + LevelFilter::Debug + } else { + LevelFilter::Info + }); + + log::set_boxed_logger(Box::new(logger.clone())).expect("logger already set"); + + (logger, guard, event_rx) + } +} 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 63% rename from src/store.rs rename to cove/src/store.rs index 24b95d0..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,23 +131,26 @@ impl Tree { } } +#[allow(dead_code)] #[async_trait] pub trait MsgStore { - async fn path(&self, id: &M::Id) -> Path; - async fn tree(&self, tree_id: &M::Id) -> Tree; - async fn first_tree_id(&self) -> Option; - async fn last_tree_id(&self) -> Option; - async fn prev_tree_id(&self, tree_id: &M::Id) -> Option; - async fn next_tree_id(&self, tree_id: &M::Id) -> Option; - async fn oldest_msg_id(&self) -> Option; - async fn newest_msg_id(&self) -> Option; - async fn older_msg_id(&self, id: &M::Id) -> Option; - async fn newer_msg_id(&self, id: &M::Id) -> Option; - async fn oldest_unseen_msg_id(&self) -> Option; - async fn newest_unseen_msg_id(&self) -> Option; - async fn older_unseen_msg_id(&self, id: &M::Id) -> Option; - async fn newer_unseen_msg_id(&self, id: &M::Id) -> Option; - async fn unseen_msgs_count(&self) -> usize; - async fn set_seen(&self, id: &M::Id, seen: bool); - async fn set_older_seen(&self, id: &M::Id, seen: bool); + type Error; + async fn path(&self, id: &M::Id) -> Result, Self::Error>; + async fn msg(&self, id: &M::Id) -> Result, Self::Error>; + async fn tree(&self, root_id: &M::Id) -> Result, Self::Error>; + async fn first_root_id(&self) -> Result, Self::Error>; + async fn last_root_id(&self) -> Result, Self::Error>; + async fn prev_root_id(&self, root_id: &M::Id) -> Result, Self::Error>; + async fn next_root_id(&self, root_id: &M::Id) -> Result, Self::Error>; + async fn oldest_msg_id(&self) -> Result, Self::Error>; + async fn newest_msg_id(&self) -> Result, Self::Error>; + async fn older_msg_id(&self, id: &M::Id) -> Result, Self::Error>; + async fn newer_msg_id(&self, id: &M::Id) -> Result, Self::Error>; + async fn oldest_unseen_msg_id(&self) -> Result, Self::Error>; + async fn newest_unseen_msg_id(&self) -> Result, Self::Error>; + async fn older_unseen_msg_id(&self, id: &M::Id) -> Result, Self::Error>; + async fn newer_unseen_msg_id(&self, id: &M::Id) -> Result, Self::Error>; + async fn unseen_msgs_count(&self) -> Result; + async fn set_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>; + async fn set_older_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>; } diff --git a/cove/src/ui.rs b/cove/src/ui.rs new file mode 100644 index 0000000..5ebd540 --- /dev/null +++ b/cove/src/ui.rs @@ -0,0 +1,311 @@ +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 key_bindings; +mod rooms; +mod util; +mod widgets; + +/// 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, + Term(crossterm::event::Event), + Euph(euphoxide::bot::instance::Event), +} + +enum EventHandleResult { + Redraw, + Continue, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Main, + Log, +} + +pub struct Ui { + config: &'static Config, + event_tx: UnboundedSender, + + mode: Mode, + + rooms: Rooms, + log_chat: ChatState, + + key_bindings_visible: bool, + key_bindings_list: ListState, +} + +impl Ui { + const POLL_DURATION: Duration = Duration::from_millis(100); + + pub async fn run( + config: &'static Config, + tz: TimeZone, + terminal: &mut Terminal, + vault: Vault, + logger: Logger, + logger_rx: UnboundedReceiver<()>, + ) -> anyhow::Result<()> { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let crossterm_lock = Arc::new(FairMutex::new(())); + + // Prepare and start crossterm event polling task + let weak_crossterm_lock = Arc::downgrade(&crossterm_lock); + let event_tx_clone = event_tx.clone(); + let crossterm_event_task = task::spawn_blocking(|| { + Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock) + }); + + // Run main UI. + // + // If the run_main method exits at any point or if this `run` method is + // not awaited any more, the crossterm_lock Arc should be deallocated, + // meaning the crossterm_event_task will also stop after at most + // `Self::POLL_DURATION`. + // + // 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, 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?, + _ = Self::update_on_log_event(logger_rx, &event_tx) => (), + e = crossterm_event_task => e??, + } + Ok(()) + } + + fn poll_crossterm_events( + tx: UnboundedSender, + lock: Weak>, + ) -> io::Result<()> { + loop { + let Some(lock) = lock.upgrade() else { + return Ok(()); + }; + let _guard = lock.lock(); + if crossterm::event::poll(Self::POLL_DURATION)? { + let event = crossterm::event::read()?; + if tx.send(UiEvent::Term(event)).is_err() { + return Ok(()); + } + } + } + } + + async fn update_on_log_event( + mut logger_rx: UnboundedReceiver<()>, + event_tx: &UnboundedSender, + ) { + loop { + if logger_rx.recv().await.is_none() { + return; + } + if event_tx.send(UiEvent::LogChanged).is_err() { + return; + } + } + } + + async fn run_main( + &mut self, + terminal: &mut Terminal, + mut event_rx: UnboundedReceiver, + crossterm_lock: Arc>, + ) -> Result<(), UiError> { + let mut redraw = true; + + loop { + // 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(()); + } + } + } + + // Handle events (in batches) + let Some(mut event) = event_rx.recv().await else { + return Ok(()); + }; + let end_time = Instant::now() + EVENT_PROCESSING_TIME; + loop { + match self.handle_event(terminal, &crossterm_lock, event).await { + EventHandleResult::Redraw => redraw = true, + EventHandleResult::Continue => {} + EventHandleResult::Stop => return Ok(()), + } + if Instant::now() >= end_time { + break; + } + event = match event_rx.try_recv() { + Ok(event) => event, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return Ok(()), + }; + } + } + } + + 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), + }; + + 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 + } + } + + async fn handle_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: UiEvent, + ) -> EventHandleResult { + match event { + UiEvent::GraphemeWidthsChanged => EventHandleResult::Redraw, + UiEvent::LogChanged if self.mode == Mode::Log => EventHandleResult::Redraw, + UiEvent::LogChanged => EventHandleResult::Continue, + UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw, + UiEvent::Term(event) => { + self.handle_term_event(terminal, crossterm_lock.clone(), event) + .await + } + UiEvent::Euph(event) => { + if self.rooms.handle_euph_event(event).await { + EventHandleResult::Redraw + } else { + EventHandleResult::Continue + } + } + } + } + + async fn handle_term_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: Arc>, + event: crossterm::event::Event, + ) -> EventHandleResult { + let mut event = InputEvent::new(event, terminal, crossterm_lock); + let keys = &self.config.keys; + + if event.matches(&keys.general.exit) { + return EventHandleResult::Stop; + } + + // Key bindings list overrides any other bindings if visible + 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 self.mode { + Mode::Main => { + 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(&mut event, keys, false) + .await; + let reaction = logging_unwrap!(reaction); + if reaction.handled() { + return EventHandleResult::Redraw; + } + } + } + + 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 75% rename from src/ui/euph.rs rename to cove/src/ui/euph.rs index b18bd8b..9ca6d15 100644 --- a/src/ui/euph.rs +++ b/cove/src/ui/euph.rs @@ -1,5 +1,7 @@ mod account; mod auth; +mod inspect; +mod links; mod nick; mod nick_list; mod popup; 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/cove/src/ui/euph/inspect.rs b/cove/src/ui/euph/inspect.rs new file mode 100644 index 0000000..b3c4e0e --- /dev/null +++ b/cove/src/ui/euph/inspect.rs @@ -0,0 +1,134 @@ +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::{UiError, widgets::Popup}; + +use super::popup::PopupResult; + +macro_rules! line { + ( $text:ident, $name:expr, $val:expr ) => { + $text = $text + .then($name, Style::new().cyan()) + .then_plain(format!(" {}\n", $val)); + }; + ( $text:ident, $name:expr, $val:expr, debug ) => { + $text = $text + .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, Style::new().cyan()) + .then_plain(format!(" {val}\n")); + } else { + $text = $text + .then($name, Style::new().cyan()) + .then_plain(" ") + .then("none", Style::new().italic().grey()) + .then_plain("\n"); + } + }; + ( $text:ident, $name:expr, $val:expr, yes or no ) => { + $text = $text.then($name, Style::new().cyan()).then_plain(if $val { + " yes\n" + } else { + " no\n" + }); + }; +} + +fn session_view_lines(mut text: Styled, session: &SessionView) -> Styled { + line!(text, "id", session.id); + line!(text, "name", session.name); + line!(text, "name (raw)", session.name, debug); + line!(text, "server_id", session.server_id); + line!(text, "server_era", session.server_era); + line!(text, "session_id", session.session_id.0); + line!(text, "is_staff", session.is_staff, yes or no); + line!(text, "is_manager", session.is_manager, yes or no); + line!( + text, + "client_address", + session.client_address.as_ref(), + optional + ); + line!( + text, + "real_client_address", + session.real_client_address.as_ref(), + optional + ); + + text +} + +fn nick_event_lines(mut text: Styled, event: &NickEvent) -> Styled { + line!(text, "id", event.id); + line!(text, "name", event.to); + line!(text, "name (raw)", event.to, debug); + line!(text, "session_id", event.session_id.0); + + text +} + +fn message_lines(mut text: Styled, msg: &Message) -> Styled { + line!(text, "id", msg.id.0); + line!(text, "parent", msg.parent.map(|p| p.0), optional); + line!(text, "previous_edit_id", msg.previous_edit_id, optional); + line!(text, "time", msg.time.0); + line!(text, "encryption_key_id", &msg.encryption_key_id, optional); + line!(text, "edited", msg.edited.map(|t| t.0), optional); + line!(text, "deleted", msg.deleted.map(|t| t.0), optional); + line!(text, "truncated", msg.truncated, yes or no); + + text +} + +pub fn session_widget(session: &SessionInfo) -> impl Widget + use<> { + let heading_style = Style::new().bold(); + + let text = match session { + SessionInfo::Full(session) => { + let text = Styled::new("Full session", heading_style).then_plain("\n"); + session_view_lines(text, session) + } + SessionInfo::Partial(event) => { + let text = Styled::new("Partial session", heading_style).then_plain("\n"); + nick_event_lines(text, event) + } + }; + + Popup::new(Text::new(text), "Inspect session") +} + +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"); + + text = message_lines(text, msg); + + text = text + .then_plain("\n") + .then("Sender", heading_style) + .then_plain("\n"); + + text = session_view_lines(text, &msg.sender); + + Popup::new(Text::new(text), "Inspect message") +} + +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/cove/src/vault.rs b/cove/src/vault.rs new file mode 100644 index 0000000..05bd1a5 --- /dev/null +++ b/cove/src/vault.rs @@ -0,0 +1,79 @@ +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; + +#[derive(Debug, Clone)] +pub struct Vault { + tokio_vault: TokioVault, + ephemeral: bool, +} + +struct GcAction; + +impl Action for GcAction { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.execute_batch("ANALYZE; VACUUM;") + } +} + +impl Vault { + pub fn ephemeral(&self) -> bool { + self.ephemeral + } + + pub async fn close(&self) { + self.tokio_vault.stop().await; + } + + pub async fn gc(&self) -> Result<(), vault::tokio::Error<rusqlite::Error>> { + self.tokio_vault.execute(GcAction).await + } + + pub fn euph(&self) -> EuphVault { + EuphVault::new(self.clone()) + } +} + +fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result<Vault> { + conn.pragma_update(None, "foreign_keys", true)?; + conn.pragma_update(None, "trusted_schema", false)?; + + let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?; + Ok(Vault { + tokio_vault, + ephemeral, + }) +} + +pub fn launch(path: &Path) -> rusqlite::Result<Vault> { + // If this fails, rusqlite will complain about not being able to open the db + // file, which saves me from adding a separate vault error type. + let _ = fs::create_dir_all(path.parent().expect("path to file")); + + let conn = Connection::open(path)?; + + // Setting locking mode before journal mode so no shared memory files + // (*-shm) need to be created by sqlite. Apparently, setting the journal + // mode is also enough to immediately acquire the exclusive lock even if the + // database was already using WAL. + // https://sqlite.org/pragma.html#pragma_locking_mode + conn.pragma_update(None, "locking_mode", "exclusive")?; + conn.pragma_update(None, "journal_mode", "wal")?; + + launch_from_connection(conn, false) +} + +pub fn launch_in_memory() -> rusqlite::Result<Vault> { + let conn = Connection::open_in_memory()?; + launch_from_connection(conn, true) +} diff --git a/cove/src/vault/euph.rs b/cove/src/vault/euph.rs new file mode 100644 index 0000000..4a4109e --- /dev/null +++ b/cove/src/vault/euph.rs @@ -0,0 +1,1243 @@ +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::{ + Connection, OptionalExtension, Row, ToSql, Transaction, named_params, params, + types::{FromSql, FromSqlError, ToSqlOutput, Value, ValueRef}, +}; +use vault::Action; + +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() + } +} + +impl FromSql for WSnowflake { + fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> { + u64::column_result(value).map(|v| Self(Snowflake(v))) + } +} + +/// Wrapper for [`Time`] that implements useful rusqlite traits. +struct WTime(Time); + +impl ToSql for WTime { + fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> { + let timestamp = self.0.0; + Ok(ToSqlOutput::Owned(Value::Integer(timestamp))) + } +} + +impl FromSql for WTime { + fn column_result(value: ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> { + let timestamp = i64::column_result(value)?; + 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 } + } +} + +/////////////// +// EuphVault // +/////////////// + +#[derive(Debug, Clone)] +pub struct EuphVault { + vault: super::Vault, +} + +impl EuphVault { + pub(crate) fn new(vault: super::Vault) -> Self { + Self { vault } + } + + pub fn vault(&self) -> &super::Vault { + &self.vault + } + + pub fn room(&self, room: RoomIdentifier) -> EuphRoomVault { + EuphRoomVault { + vault: self.clone(), + room, + } + } +} + +macro_rules! euph_vault_actions { + ( $( + $struct:ident : $fn:ident ( $( $arg:ident : $arg_ty:ty ),* ) -> $res:ty ; + )* ) => { + $( + struct $struct { + $( $arg: $arg_ty, )* + } + )* + + impl EuphVault { + $( + pub async fn $fn(&self, $( $arg: $arg_ty, )* ) -> Result<$res, vault::tokio::Error<rusqlite::Error>> { + self.vault.tokio_vault.execute($struct { $( $arg, )* }).await + } + )* + } + }; +} + +euph_vault_actions! { + 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 Output = CookieJar; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let cookies = conn + .prepare( + " + SELECT cookie + FROM euph_cookies + WHERE domain = ? + ", + )? + .query_map([self.domain], |row| { + let cookie_str: String = row.get(0)?; + Ok(Cookie::from_str(&cookie_str).expect("cookie in db is valid")) + })? + .collect::<rusqlite::Result<Vec<_>>>()?; + + let mut cookie_jar = CookieJar::new(); + for cookie in cookies { + cookie_jar.add_original(cookie); + } + Ok(cookie_jar) + } +} + +impl Action for SetCookies { + type Output = (); + type Error = rusqlite::Error; + + 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( + " + DELETE FROM euph_cookies + WHERE domain = ?", + [&self.domain], + )?; + + let mut insert_cookie = tx.prepare( + " + INSERT INTO euph_cookies (domain, cookie) + VALUES (?, ?) + ", + )?; + for cookie in self.cookies.iter() { + insert_cookie.execute(params![self.domain, format!("{cookie}")])?; + } + drop(insert_cookie); + + tx.commit()?; + Ok(()) + } +} + +impl Action for ClearCookies { + type Output = (); + type Error = rusqlite::Error; + + 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 domain, room + FROM euph_rooms + ", + )? + .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 // +/////////////////// + +#[derive(Debug, Clone)] +pub struct EuphRoomVault { + vault: EuphVault, + room: RoomIdentifier, +} + +impl EuphRoomVault { + pub fn vault(&self) -> &EuphVault { + &self.vault + } + + pub fn room(&self) -> &RoomIdentifier { + &self.room + } +} + +macro_rules! euph_room_vault_actions { + ( $( + $struct:ident : $fn:ident ( $( $arg:ident : $arg_ty:ty ),* ) -> $res:ty ; + )* ) => { + $( + struct $struct { + room: RoomIdentifier, + $( $arg: $arg_ty, )* + } + )* + + impl EuphRoomVault { + $( + 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, )* + }).await + } + )* + } + }; +} + +euph_room_vault_actions! { + // Room + Join : join(time: Time) -> (); + Delete : delete() -> (); + + // Message + AddMsg : add_msg(msg: Box<Message>, prev_msg_id: Option<MessageId>, own_user_id: Option<UserId>) -> (); + AddMsgs : add_msgs(msgs: Vec<Message>, next_msg_id: Option<MessageId>, own_user_id: Option<UserId>) -> (); + GetLastSpan : last_span() -> Option<(Option<MessageId>, Option<MessageId>)>; + GetPath : path(id: MessageId) -> Path<MessageId>; + GetMsg : msg(id: MessageId) -> Option<SmallMessage>; + GetFullMsg : full_msg(id: MessageId) -> Option<Message>; + GetTree : tree(root_id: MessageId) -> Tree<SmallMessage>; + GetFirstRootId : first_root_id() -> Option<MessageId>; + GetLastRootId : last_root_id() -> Option<MessageId>; + GetPrevRootId : prev_root_id(root_id: MessageId) -> Option<MessageId>; + GetNextRootId : next_root_id(root_id: MessageId) -> Option<MessageId>; + GetOldestMsgId : oldest_msg_id() -> Option<MessageId>; + GetNewestMsgId : newest_msg_id() -> Option<MessageId>; + GetOlderMsgId : older_msg_id(id: MessageId) -> Option<MessageId>; + GetNewerMsgId : newer_msg_id(id: MessageId) -> Option<MessageId>; + GetOldestUnseenMsgId : oldest_unseen_msg_id() -> Option<MessageId>; + GetNewestUnseenMsgId : newest_unseen_msg_id() -> Option<MessageId>; + GetOlderUnseenMsgId : older_unseen_msg_id(id: MessageId) -> Option<MessageId>; + GetNewerUnseenMsgId : newer_unseen_msg_id(id: MessageId) -> Option<MessageId>; + GetUnseenMsgsCount : unseen_msgs_count() -> usize; + SetSeen : set_seen(id: MessageId, seen: bool) -> (); + SetOlderSeen : set_older_seen(id: MessageId, seen: bool) -> (); + GetChunkAfter : chunk_after(id: Option<MessageId>, amount: usize) -> Vec<Message>; +} + +impl Action for Join { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.execute( + " + 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! { + ":domain": self.room.domain, + ":room": self.room.name, + ":time": WTime(self.time), + }, + )?; + Ok(()) + } +} + +impl Action for Delete { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.execute( + " + DELETE FROM euph_rooms + WHERE domain = ? + AND room = ? + ", + [&self.room.domain, &self.room.name], + )?; + Ok(()) + } +} + +fn insert_msgs( + tx: &Transaction<'_>, + room: &RoomIdentifier, + own_user_id: &Option<UserId>, + msgs: Vec<Message>, +) -> rusqlite::Result<()> { + let mut insert_msg = tx.prepare( + " + INSERT INTO euph_msgs ( + 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 ( + :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 domain = :domain + AND room = :room + AND :time < first_joined + )) + ) + ON CONFLICT (domain, room, id) DO UPDATE + SET + domain = :domain, + room = :room, + id = :id, + parent = :parent, + previous_edit_id = :previous_edit_id, + time = :time, + content = :content, + encryption_key_id = :encryption_key_id, + edited = :edited, + deleted = :deleted, + truncated = :truncated, + + user_id = :user_id, + name = :name, + server_id = :server_id, + server_era = :server_era, + session_id = :session_id, + is_staff = :is_staff, + is_manager = :is_manager, + client_address = :client_address, + real_client_address = :real_client_address + " + )?; + + let own_user_id = own_user_id.as_ref().map(|u| &u.0); + for msg in msgs { + insert_msg.execute(named_params! { + ":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), + ":time": WTime(msg.time), + ":content": msg.content, + ":encryption_key_id": msg.encryption_key_id, + ":edited": msg.edited.map(WTime), + ":deleted": msg.deleted.map(WTime), + ":truncated": msg.truncated, + ":user_id": msg.sender.id.0, + ":name": msg.sender.name, + ":server_id": msg.sender.server_id, + ":server_era": msg.sender.server_era, + ":session_id": msg.sender.session_id.0, + ":is_staff": msg.sender.is_staff, + ":is_manager": msg.sender.is_manager, + ":client_address": msg.sender.client_address, + ":real_client_address": msg.sender.real_client_address, + ":own_user_id": own_user_id, // May be NULL + })?; + } + + Ok(()) +} + +fn add_span( + tx: &Transaction<'_>, + room: &RoomIdentifier, + start: Option<MessageId>, + end: Option<MessageId>, +) -> rusqlite::Result<()> { + // Retrieve all spans for the room + let mut spans = tx + .prepare( + " + SELECT start, end + FROM euph_spans + WHERE domain = ? + AND room = ? + ", + )? + .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)) + })? + .collect::<Result<Vec<_>, _>>()?; + + // Add new span and sort spans lexicographically + spans.push((start, end)); + spans.sort_unstable(); + + // Combine overlapping spans (including newly added span) + let mut cur_span: Option<(Option<MessageId>, Option<MessageId>)> = None; + let mut result = vec![]; + for mut span in spans { + if let Some(cur_span) = &mut cur_span { + if span.0 <= cur_span.1 { + // Since spans are sorted lexicographically, we know that + // cur_span.0 <= span.0, which means that span starts inside + // of cur_span. + cur_span.1 = cur_span.1.max(span.1); + } else { + // Since span doesn't overlap cur_span, we know that no + // later span will overlap cur_span either. The size of + // cur_span is thus final. + mem::swap(cur_span, &mut span); + result.push(span); + } + } else { + cur_span = Some(span); + } + } + if let Some(cur_span) = cur_span { + result.push(cur_span); + } + + // Delete all spans for the room + tx.execute( + " + DELETE FROM euph_spans + WHERE domain = ? + AND room = ? + ", + [&room.domain, &room.name], + )?; + + // Re-insert combined spans for the room + let mut stmt = tx.prepare( + " + INSERT INTO euph_spans (domain, room, start, end) + VALUES (?, ?, ?, ?) + ", + )?; + for (start, end) in result { + stmt.execute(params![ + room.domain, + room.name, + start.map(|id| WSnowflake(id.0)), + end.map(|id| WSnowflake(id.0)) + ])?; + } + + Ok(()) +} + +impl Action for AddMsg { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let tx = conn.transaction()?; + + let end = self.msg.id; + insert_msgs(&tx, &self.room, &self.own_user_id, vec![*self.msg])?; + add_span(&tx, &self.room, self.prev_msg_id, Some(end))?; + + tx.commit()?; + Ok(()) + } +} + +impl Action for AddMsgs { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let tx = conn.transaction()?; + + if self.msgs.is_empty() { + add_span(&tx, &self.room, None, self.next_msg_id)?; + } else { + let first_msg_id = self.msgs.first().unwrap().id; + let last_msg_id = self.msgs.last().unwrap().id; + + insert_msgs(&tx, &self.room, &self.own_user_id, self.msgs)?; + + let end = self.next_msg_id.unwrap_or(last_msg_id); + add_span(&tx, &self.room, Some(first_msg_id), Some(end))?; + } + + tx.commit()?; + Ok(()) + } +} + +impl Action for GetLastSpan { + type Output = Option<(Option<MessageId>, Option<MessageId>)>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let span = conn + .prepare( + " + SELECT start, end + FROM euph_spans + WHERE domain = ? + AND room = ? + ORDER BY start DESC + LIMIT 1 + ", + )? + .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)), + )) + }) + .optional()?; + Ok(span) + } +} + +impl Action for GetPath { + type Output = Path<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let path = conn + .prepare( + " + WITH RECURSIVE + path (domain, room, id) AS ( + VALUES (?, ?, ?) + UNION + SELECT domain, room, parent + FROM euph_msgs + JOIN path USING (domain, room, id) + ) + SELECT id + FROM path + WHERE id IS NOT NULL + ORDER BY id ASC + ", + )? + .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 Output = Option<SmallMessage>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg = conn + .query_row( + " + SELECT id, parent, time, user_id, name, content, seen + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND id = ? + ", + 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, + user_id: UserId(row.get(3)?), + nick: row.get(4)?, + content: row.get(5)?, + seen: row.get(6)?, + }) + }, + ) + .optional()?; + Ok(msg) + } +} + +impl Action for GetFullMsg { + type Output = Option<Message>; + type Error = rusqlite::Error; + + 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 domain = ? + AND room = ? + AND id = ? + " + )?; + + let msg = query + .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 Output = Tree<SmallMessage>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msgs = conn + .prepare( + " + WITH RECURSIVE + tree (domain, room, id) AS ( + VALUES (?, ?, ?) + UNION + SELECT euph_msgs.domain, euph_msgs.room, euph_msgs.id + FROM euph_msgs + JOIN tree + ON tree.domain = euph_msgs.domain + AND tree.room = euph_msgs.room + AND tree.id = euph_msgs.parent + ) + SELECT id, parent, time, user_id, name, content, seen + FROM euph_msgs + JOIN tree USING (domain, room, id) + ORDER BY id ASC + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let root_id = conn + .prepare( + " + SELECT id + FROM euph_trees + WHERE domain = ? + AND room = ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(root_id) + } +} + +impl Action for GetLastRootId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let root_id = conn + .prepare( + " + SELECT id + FROM euph_trees + WHERE domain = ? + AND room = ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(root_id) + } +} + +impl Action for GetPrevRootId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let root_id = conn + .prepare( + " + SELECT id + FROM euph_trees + WHERE domain = ? + AND room = ? + AND id < ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let root_id = conn + .prepare( + " + SELECT id + FROM euph_trees + WHERE domain = ? + AND room = ? + AND id > ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(msg_id) + } +} + +impl Action for GetNewestMsgId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(msg_id) + } +} + +impl Action for GetOlderMsgId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND id < ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND id > ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND NOT seen + ORDER BY id ASC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(msg_id) + } +} + +impl Action for GetNewestUnseenMsgId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND NOT seen + ORDER BY id DESC + LIMIT 1 + ", + )? + .query_row([&self.room.domain, &self.room.name], |row| { + row.get::<_, WSnowflake>(0).map(|s| MessageId(s.0)) + }) + .optional()?; + Ok(msg_id) + } +} + +impl Action for GetOlderUnseenMsgId { + type Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND NOT seen + AND id < ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .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 Output = Option<MessageId>; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let msg_id = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE domain = ? + AND room = ? + AND NOT seen + AND id > ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .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 Output = usize; + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + let amount = conn + .prepare( + " + SELECT amount + FROM euph_unseen_counts + WHERE domain = ? + AND room = ? + ", + )? + .query_row(params![self.room.domain, self.room.name], |row| row.get(0)) + .optional()? + .unwrap_or(0); + Ok(amount) + } +} + +impl Action for SetSeen { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.execute( + " + UPDATE euph_msgs + SET seen = :seen + WHERE domain = :domain + AND room = :room + AND id = :id + ", + named_params! { + ":domain": self.room.domain, + ":room": self.room.name, + ":id": WSnowflake(self.id.0), + ":seen": self.seen, + }, + )?; + Ok(()) + } +} + +impl Action for SetOlderSeen { + type Output = (); + type Error = rusqlite::Error; + + fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> { + conn.execute( + " + UPDATE euph_msgs + SET seen = :seen + WHERE domain = :domain + AND room = :room + AND id <= :id + AND seen != :seen + ", + named_params! { + ":domain": self.room.domain, + ":room": self.room.name, + ":id": WSnowflake(self.id.0), + ":seen": self.seen, + }, + )?; + Ok(()) + } +} + +impl Action for GetChunkAfter { + type Output = Vec<Message>; + type Error = rusqlite::Error; + + 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<_>>()? + }; + + Ok(messages) + } +} + +#[async_trait] +impl MsgStore<SmallMessage> for EuphRoomVault { + type Error = vault::tokio::Error<rusqlite::Error>; + + async fn path(&self, id: &MessageId) -> Result<Path<MessageId>, Self::Error> { + self.path(*id).await + } + + async fn msg(&self, id: &MessageId) -> Result<Option<SmallMessage>, Self::Error> { + self.msg(*id).await + } + + async fn tree(&self, root_id: &MessageId) -> Result<Tree<SmallMessage>, Self::Error> { + self.tree(*root_id).await + } + + async fn first_root_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.first_root_id().await + } + + async fn last_root_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.last_root_id().await + } + + async fn prev_root_id(&self, root_id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.prev_root_id(*root_id).await + } + + async fn next_root_id(&self, root_id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.next_root_id(*root_id).await + } + + async fn oldest_msg_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.oldest_msg_id().await + } + + async fn newest_msg_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.newest_msg_id().await + } + + async fn older_msg_id(&self, id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.older_msg_id(*id).await + } + + async fn newer_msg_id(&self, id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.newer_msg_id(*id).await + } + + async fn oldest_unseen_msg_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.oldest_unseen_msg_id().await + } + + async fn newest_unseen_msg_id(&self) -> Result<Option<MessageId>, Self::Error> { + self.newest_unseen_msg_id().await + } + + async fn older_unseen_msg_id(&self, id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.older_unseen_msg_id(*id).await + } + + async fn newer_unseen_msg_id(&self, id: &MessageId) -> Result<Option<MessageId>, Self::Error> { + self.newer_unseen_msg_id(*id).await + } + + async fn unseen_msgs_count(&self) -> Result<usize, Self::Error> { + self.unseen_msgs_count().await + } + + async fn set_seen(&self, id: &MessageId, seen: bool) -> Result<(), Self::Error> { + self.set_seen(*id, seen).await + } + + async fn set_older_seen(&self, id: &MessageId, seen: bool) -> Result<(), Self::Error> { + self.set_older_seen(*id, seen).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/src/euph/room.rs b/src/euph/room.rs deleted file mode 100644 index 324196f..0000000 --- a/src/euph/room.rs +++ /dev/null @@ -1,496 +0,0 @@ -use std::convert::Infallible; -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::bail; -use cookie::{Cookie, CookieJar}; -use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{ - Auth, AuthOption, Data, Log, Login, Logout, Nick, Send, Snowflake, Time, UserId, -}; -use euphoxide::conn::{ConnRx, ConnTx, Joining, Status}; -use log::{error, info, warn}; -use parking_lot::Mutex; -use tokio::sync::{mpsc, oneshot}; -use tokio::{select, task}; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::handshake::client::Response; -use tokio_tungstenite::tungstenite::http::{header, HeaderValue}; - -use crate::macros::ok_or_return; -use crate::vault::{EuphVault, Vault}; - -const TIMEOUT: Duration = Duration::from_secs(30); -const RECONNECT_INTERVAL: Duration = Duration::from_secs(5); -const LOG_INTERVAL: Duration = Duration::from_secs(10); - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("room stopped")] - Stopped, -} - -pub enum EuphRoomEvent { - Connected, - Disconnected, - Packet(Box<ParsedPacket>), - Stopped, -} - -#[derive(Debug)] -enum Event { - // Events - Connected(ConnTx), - Disconnected, - Packet(Box<ParsedPacket>), - // Commands - Status(oneshot::Sender<Option<Status>>), - RequestLogs, - Auth(String), - Nick(String), - Send(Option<Snowflake>, String, oneshot::Sender<Snowflake>), - Login { email: String, password: String }, - Logout, -} - -#[derive(Debug)] -struct State { - name: String, - vault: EuphVault, - - conn_tx: Option<ConnTx>, - /// `None` before any `snapshot-event`, then either `Some(None)` or - /// `Some(Some(id))`. - last_msg_id: Option<Option<Snowflake>>, - requesting_logs: Arc<Mutex<bool>>, -} - -impl State { - async fn run( - mut self, - canary: oneshot::Receiver<Infallible>, - event_tx: mpsc::UnboundedSender<Event>, - mut event_rx: mpsc::UnboundedReceiver<Event>, - euph_room_event_tx: mpsc::UnboundedSender<EuphRoomEvent>, - ephemeral: bool, - ) { - let vault = self.vault.clone(); - let name = self.name.clone(); - let result = if ephemeral { - select! { - _ = canary => Ok(()), - _ = Self::reconnect(&vault, &name, &event_tx) => Ok(()), - e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e, - } - } else { - select! { - _ = canary => Ok(()), - _ = Self::reconnect(&vault, &name, &event_tx) => Ok(()), - e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e, - _ = Self::regularly_request_logs(&event_tx) => Ok(()), - } - }; - - if let Err(e) = result { - error!("e&{name}: {}", e); - } - - // Ensure that whoever is using this room knows that it's gone. - // Otherwise, the users of the Room may be left in an inconsistent or - // outdated state, and the UI may not update correctly. - let _ = euph_room_event_tx.send(EuphRoomEvent::Stopped); - } - - async fn reconnect( - vault: &EuphVault, - name: &str, - event_tx: &mpsc::UnboundedSender<Event>, - ) -> anyhow::Result<()> { - loop { - info!("e&{}: connecting", name); - let connected = if let Some((conn_tx, mut conn_rx)) = Self::connect(vault, name).await? - { - info!("e&{}: connected", name); - event_tx.send(Event::Connected(conn_tx))?; - - while let Some(packet) = conn_rx.recv().await { - event_tx.send(Event::Packet(Box::new(packet)))?; - } - - info!("e&{}: disconnected", name); - event_tx.send(Event::Disconnected)?; - - true - } else { - info!("e&{}: could not connect", name); - event_tx.send(Event::Disconnected)?; - false - }; - - // Only delay reconnecting if the previous attempt failed. This way, - // we'll reconnect immediately if we login or logout. - if !connected { - tokio::time::sleep(RECONNECT_INTERVAL).await; - } - } - } - - async fn get_cookies(vault: &Vault) -> String { - let cookie_jar = vault.euph_cookies().await; - let cookies = cookie_jar - .iter() - .map(|c| format!("{}", c.stripped())) - .collect::<Vec<_>>(); - cookies.join("; ") - } - - fn update_cookies(vault: &Vault, response: &Response) { - let mut cookie_jar = CookieJar::new(); - - for (name, value) in response.headers() { - if name == header::SET_COOKIE { - let value_str = ok_or_return!(value.to_str()); - let cookie = ok_or_return!(Cookie::from_str(value_str)); - cookie_jar.add(cookie); - } - } - - vault.set_euph_cookies(cookie_jar); - } - - async fn connect(vault: &EuphVault, name: &str) -> anyhow::Result<Option<(ConnTx, ConnRx)>> { - let uri = format!("wss://euphoria.io/room/{name}/ws?h=1"); - let mut request = uri.into_client_request().expect("valid request"); - let cookies = Self::get_cookies(vault.vault()).await; - let cookies = HeaderValue::from_str(&cookies).expect("valid cookies"); - request.headers_mut().append(header::COOKIE, cookies); - - match tokio_tungstenite::connect_async(request).await { - Ok((ws, response)) => { - Self::update_cookies(vault.vault(), &response); - Ok(Some(euphoxide::wrap(ws, TIMEOUT))) - } - Err(tungstenite::Error::Http(resp)) if resp.status().is_client_error() => { - bail!("room {name} doesn't exist"); - } - Err(tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_)) => { - bail!("format error for room {name}"); - } - Err(_) => Ok(None), - } - } - - async fn regularly_request_logs(event_tx: &mpsc::UnboundedSender<Event>) { - loop { - tokio::time::sleep(LOG_INTERVAL).await; - let _ = event_tx.send(Event::RequestLogs); - } - } - - async fn handle_events( - &mut self, - event_rx: &mut mpsc::UnboundedReceiver<Event>, - euph_room_event_tx: &mpsc::UnboundedSender<EuphRoomEvent>, - ) -> anyhow::Result<()> { - while let Some(event) = event_rx.recv().await { - match event { - Event::Connected(conn_tx) => { - self.conn_tx = Some(conn_tx); - let _ = euph_room_event_tx.send(EuphRoomEvent::Connected); - } - Event::Disconnected => { - self.conn_tx = None; - self.last_msg_id = None; - let _ = euph_room_event_tx.send(EuphRoomEvent::Disconnected); - } - Event::Packet(packet) => { - self.on_packet(&*packet).await?; - let _ = euph_room_event_tx.send(EuphRoomEvent::Packet(packet)); - } - Event::Status(reply_tx) => self.on_status(reply_tx).await, - Event::RequestLogs => self.on_request_logs(), - Event::Auth(password) => self.on_auth(password), - Event::Nick(name) => self.on_nick(name), - Event::Send(parent, content, id_tx) => self.on_send(parent, content, id_tx), - Event::Login { email, password } => self.on_login(email, password), - Event::Logout => self.on_logout(), - } - } - Ok(()) - } - - async fn own_user_id(&self) -> Option<UserId> { - Some(match self.conn_tx.as_ref()?.status().await.ok()? { - Status::Joining(Joining { hello, .. }) => hello?.session.id, - Status::Joined(joined) => joined.session.id, - }) - } - - async fn on_packet(&mut self, packet: &ParsedPacket) -> anyhow::Result<()> { - let data = ok_or_return!(&packet.content, Ok(())); - match data { - Data::BounceEvent(_) => {} - Data::DisconnectEvent(d) => { - warn!("e&{}: disconnected for reason {:?}", self.name, d.reason); - } - Data::HelloEvent(_) => {} - Data::JoinEvent(d) => { - info!("e&{}: {:?} joined", self.name, d.0.name); - } - Data::LoginEvent(_) => {} - Data::LogoutEvent(_) => {} - Data::NetworkEvent(d) => { - info!("e&{}: network event ({})", self.name, d.r#type); - } - Data::NickEvent(d) => { - info!("e&{}: {:?} renamed to {:?}", self.name, d.from, d.to); - } - Data::EditMessageEvent(_) => { - info!("e&{}: a message was edited", self.name); - } - Data::PartEvent(d) => { - info!("e&{}: {:?} left", self.name, d.0.name); - } - Data::PingEvent(_) => {} - Data::PmInitiateEvent(d) => { - info!( - "e&{}: {:?} initiated a pm from &{}", - self.name, d.from_nick, d.from_room - ); - } - Data::SendEvent(d) => { - let own_user_id = self.own_user_id().await; - if let Some(last_msg_id) = &mut self.last_msg_id { - let id = d.0.id; - self.vault - .add_message(d.0.clone(), *last_msg_id, own_user_id); - *last_msg_id = Some(id); - } else { - bail!("send event before snapshot event"); - } - } - Data::SnapshotEvent(d) => { - info!("e&{}: successfully joined", self.name); - self.vault.join(Time::now()); - self.last_msg_id = Some(d.log.last().map(|m| m.id)); - let own_user_id = self.own_user_id().await; - self.vault.add_messages(d.log.clone(), None, own_user_id); - } - Data::LogReply(d) => { - let own_user_id = self.own_user_id().await; - self.vault - .add_messages(d.log.clone(), d.before, own_user_id); - } - Data::SendReply(d) => { - let own_user_id = self.own_user_id().await; - if let Some(last_msg_id) = &mut self.last_msg_id { - let id = d.0.id; - self.vault - .add_message(d.0.clone(), *last_msg_id, own_user_id); - *last_msg_id = Some(id); - } else { - bail!("send reply before snapshot event"); - } - } - _ => {} - } - Ok(()) - } - - async fn on_status(&self, reply_tx: oneshot::Sender<Option<Status>>) { - let status = if let Some(conn_tx) = &self.conn_tx { - conn_tx.status().await.ok() - } else { - None - }; - - let _ = reply_tx.send(status); - } - - fn on_request_logs(&self) { - if let Some(conn_tx) = &self.conn_tx { - // Check whether logs are already being requested - let mut guard = self.requesting_logs.lock(); - if *guard { - return; - } else { - *guard = true; - } - drop(guard); - - // No logs are being requested and we've reserved our spot, so let's - // request some logs! - let vault = self.vault.clone(); - let conn_tx = conn_tx.clone(); - let requesting_logs = self.requesting_logs.clone(); - task::spawn(async move { - let result = Self::request_logs(vault, conn_tx).await; - *requesting_logs.lock() = false; - result - }); - } - } - - async fn request_logs(vault: EuphVault, conn_tx: ConnTx) -> anyhow::Result<()> { - let before = match vault.last_span().await { - Some((None, _)) => return Ok(()), // Already at top of room history - Some((Some(before), _)) => Some(before), - None => None, - }; - - 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. - - Ok(()) - } - - fn on_auth(&self, password: String) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - let _ = conn_tx - .send(Auth { - r#type: AuthOption::Passcode, - passcode: Some(password), - }) - .await; - }); - } - } - - fn on_nick(&self, name: String) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - let _ = conn_tx.send(Nick { name }).await; - }); - } - } - - fn on_send( - &self, - parent: Option<Snowflake>, - content: String, - id_tx: oneshot::Sender<Snowflake>, - ) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - if let Ok(reply) = conn_tx.send(Send { content, parent }).await { - let _ = id_tx.send(reply.0.id); - } - }); - } - } - - fn on_login(&self, email: String, password: String) { - if let Some(conn_tx) = &self.conn_tx { - let _ = conn_tx.send(Login { - namespace: "email".to_string(), - id: email, - password, - }); - } - } - - fn on_logout(&self) { - if let Some(conn_tx) = &self.conn_tx { - let _ = conn_tx.send(Logout); - } - } -} - -#[derive(Debug)] -pub struct Room { - #[allow(dead_code)] - canary: oneshot::Sender<Infallible>, - event_tx: mpsc::UnboundedSender<Event>, -} - -impl Room { - pub fn new(vault: EuphVault) -> (Self, mpsc::UnboundedReceiver<EuphRoomEvent>) { - let (canary_tx, canary_rx) = oneshot::channel(); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (euph_room_event_tx, euph_room_event_rx) = mpsc::unbounded_channel(); - let ephemeral = vault.vault().ephemeral(); - - let state = State { - name: vault.room().to_string(), - vault, - conn_tx: None, - last_msg_id: None, - requesting_logs: Arc::new(Mutex::new(false)), - }; - - task::spawn(state.run( - canary_rx, - event_tx.clone(), - event_rx, - euph_room_event_tx, - ephemeral, - )); - - let new_room = Self { - canary: canary_tx, - event_tx, - }; - (new_room, euph_room_event_rx) - } - - pub fn stopped(&self) -> bool { - self.event_tx.is_closed() - } - - pub async fn status(&self) -> Result<Option<Status>, Error> { - let (tx, rx) = oneshot::channel(); - self.event_tx - .send(Event::Status(tx)) - .map_err(|_| Error::Stopped)?; - rx.await.map_err(|_| Error::Stopped) - } - - pub fn auth(&self, password: String) -> Result<(), Error> { - self.event_tx - .send(Event::Auth(password)) - .map_err(|_| Error::Stopped) - } - - pub fn log(&self) -> Result<(), Error> { - self.event_tx - .send(Event::RequestLogs) - .map_err(|_| Error::Stopped) - } - - pub fn nick(&self, name: String) -> Result<(), Error> { - self.event_tx - .send(Event::Nick(name)) - .map_err(|_| Error::Stopped) - } - - pub fn send( - &self, - parent: Option<Snowflake>, - content: String, - ) -> Result<oneshot::Receiver<Snowflake>, Error> { - let (id_tx, id_rx) = oneshot::channel(); - self.event_tx - .send(Event::Send(parent, content, id_tx)) - .map(|_| id_rx) - .map_err(|_| Error::Stopped) - } - - pub fn login(&self, email: String, password: String) -> Result<(), Error> { - self.event_tx - .send(Event::Login { email, password }) - .map_err(|_| Error::Stopped) - } - - pub fn logout(&self) -> Result<(), Error> { - self.event_tx - .send(Event::Logout) - .map_err(|_| Error::Stopped) - } -} diff --git a/src/euph/small_message.rs b/src/euph/small_message.rs deleted file mode 100644 index 4aa45c2..0000000 --- a/src/euph/small_message.rs +++ /dev/null @@ -1,177 +0,0 @@ -use crossterm::style::{Color, ContentStyle, Stylize}; -use euphoxide::api::{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 - match ch { - ',' | '.' | '!' | '?' | ';' | '&' | '<' | '\'' | '"' => false, - _ => !ch.is_whitespace(), - } -} - -fn nick_char_(ch: Option<&char>) -> bool { - ch.filter(|c| nick_char(**c)).is_some() -} - -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 == '_' -} - -fn room_char_(ch: Option<&char>) -> bool { - ch.filter(|c| room_char(**c)).is_some() -} - -// TODO Allocate less? -fn highlight_content(content: &str, base_style: ContentStyle) -> Styled { - let mut result = Styled::default(); - let mut current = String::new(); - let mut chars = content.chars().peekable(); - let mut possible_room_or_mention = true; - - while let Some(char) = chars.next() { - match char { - '@' if possible_room_or_mention && nick_char_(chars.peek()) => { - result = result.then(¤t, base_style); - current.clear(); - - let mut nick = String::new(); - while let Some(ch) = chars.peek() { - if nick_char(*ch) { - nick.push(*ch); - } else { - break; - } - chars.next(); - } - - let (r, g, b) = util::nick_color(&nick); - let style = base_style.with(Color::Rgb { r, g, b }).bold(); - result = result.then("@", style).then(nick, style); - } - '&' if possible_room_or_mention && room_char_(chars.peek()) => { - result = result.then(¤t, base_style); - current.clear(); - - let mut room = "&".to_string(); - while let Some(ch) = chars.peek() { - if room_char(*ch) { - room.push(*ch); - } else { - break; - } - chars.next(); - } - - let style = base_style.blue().bold(); - result = result.then(room, style); - } - _ => current.push(char), - } - - // More permissive than the heim web client - possible_room_or_mention = !char.is_alphanumeric(); - } - - result = result.then(current, base_style); - - result -} - -#[derive(Debug, Clone)] -pub struct SmallMessage { - pub id: Snowflake, - pub parent: Option<Snowflake>, - 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("[") - .then(nick, util::nick_style(nick)) - .then_plain("]") -} - -fn styled_nick_me(nick: &str) -> Styled { - let style = style_me(); - Styled::new("*", style).then(nick, util::nick_style(nick).italic()) -} - -fn styled_content(content: &str) -> Styled { - highlight_content(content.trim(), ContentStyle::default()) -} - -fn styled_content_me(content: &str) -> Styled { - let style = style_me(); - highlight_content(content.trim(), style).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) -} - -impl Msg for SmallMessage { - type Id = Snowflake; - - 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 { - 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/euph/util.rs b/src/euph/util.rs deleted file mode 100644 index 05eb467..0000000 --- a/src/euph/util.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crossterm::style::{Color, ContentStyle, Stylize}; - -/// Convert HSL to RGB following [this approach from wikipedia][1]. -/// -/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`. -/// -/// [1]: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB -fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { - assert!((0.0..=360.0).contains(&h), "h must be in range [0, 360]"); - assert!((0.0..=1.0).contains(&s), "s must be in range [0, 1]"); - assert!((0.0..=1.0).contains(&l), "l must be in range [0, 1]"); - - let c = (1.0 - (2.0 * l - 1.0).abs()) * s; - - let h_prime = h / 60.0; - let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs()); - - let (r1, g1, b1) = match () { - _ if h_prime < 1.0 => (c, x, 0.0), - _ if h_prime < 2.0 => (x, c, 0.0), - _ if h_prime < 3.0 => (0.0, c, x), - _ if h_prime < 4.0 => (0.0, x, c), - _ if h_prime < 5.0 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - - let m = l - c / 2.0; - let (r, g, b) = (r1 + m, g1 + m, b1 + m); - - // The rgb values in the range [0,1] are each split into 256 segments of the - // same length, which are then assigned to the 256 possible values of an u8. - ((r * 256.0) as u8, (g * 256.0) as u8, (b * 256.0) as u8) -} - -pub fn nick_color(nick: &str) -> (u8, u8, u8) { - let hue = euphoxide::nick_hue(nick) as f32; - hsl_to_rgb(hue, 1.0, 0.72) -} - -pub fn nick_style(nick: &str) -> ContentStyle { - let (r, g, b) = nick_color(nick); - ContentStyle::default().bold().with(Color::Rgb { r, g, b }) -} diff --git a/src/export.rs b/src/export.rs deleted file mode 100644 index 093ec26..0000000 --- a/src/export.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Export logs from the vault to plain text files. - -mod json; -mod text; - -use std::fs::File; -use std::io::{BufWriter, Write}; - -use crate::vault::Vault; - -#[derive(Debug, Clone, Copy, clap::ValueEnum)] -pub enum Format { - /// Human-readable tree-structured messages. - Text, - /// List of message objects in the same format as the euphoria API uses. - Json, -} - -impl Format { - fn name(&self) -> &'static str { - match self { - Self::Text => "text", - Self::Json => "json", - } - } - - fn extension(&self) -> &'static str { - match self { - Self::Text => "txt", - Self::Json => "json", - } - } -} - -#[derive(Debug, clap::Parser)] -pub struct Args { - rooms: Vec<String>, - - /// Export all rooms. - #[clap(long, short)] - all: bool, - - /// Format of the output file. - #[clap(long, short, value_enum, default_value_t = Format::Text)] - format: Format, - - /// Location of the output file - /// - /// May include the following placeholders: - /// `%r` - room name - /// `%e` - format extension - /// A literal `%` can be written as `%%`. - /// - /// If the value ends with a `/`, it is assumed to point to a directory and - /// `%r.%e` will be appended. - /// - /// Must be a valid utf-8 encoded string. - #[clap(long, short, default_value_t = Into::into("%r.%e"))] - #[clap(verbatim_doc_comment)] - out: String, -} - -pub async fn export(vault: &Vault, mut args: Args) -> anyhow::Result<()> { - if args.out.ends_with('/') { - args.out.push_str("%r.%e"); - } - - let rooms = if args.all { - let mut rooms = vault.euph_rooms().await; - rooms.sort_unstable(); - rooms - } else { - let mut rooms = args.rooms.clone(); - rooms.dedup(); - rooms - }; - - if rooms.is_empty() { - println!("No rooms to export"); - } - - for room in rooms { - let out = format_out(&args.out, &room, args.format); - println!("Exporting &{room} as {} to {out}", args.format.name()); - - let mut file = BufWriter::new(File::create(out)?); - match args.format { - Format::Text => text::export_to_file(vault, room, &mut file).await?, - Format::Json => json::export_to_file(vault, room, &mut file).await?, - } - file.flush()?; - } - - Ok(()) -} - -fn format_out(out: &str, room: &str, format: Format) -> String { - let mut result = String::new(); - - let mut special = false; - for char in out.chars() { - if special { - match char { - 'r' => result.push_str(room), - 'e' => result.push_str(format.extension()), - '%' => result.push('%'), - _ => { - result.push('%'); - result.push(char); - } - } - special = false; - } else if char == '%' { - special = true; - } else { - result.push(char); - } - } - - result -} diff --git a/src/export/json.rs b/src/export/json.rs deleted file mode 100644 index fbec86b..0000000 --- a/src/export/json.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::fs::File; -use std::io::{BufWriter, Write}; - -use crate::vault::Vault; - -const CHUNK_SIZE: usize = 10000; - -pub async fn export_to_file( - vault: &Vault, - room: String, - file: &mut BufWriter<File>, -) -> anyhow::Result<()> { - let vault = vault.euph(room); - - write!(file, "[")?; - - let mut total = 0; - let mut offset = 0; - loop { - let messages = vault.chunk_at_offset(CHUNK_SIZE, offset).await; - offset += messages.len(); - - if messages.is_empty() { - break; - } - - for message in messages { - if total == 0 { - writeln!(file)?; - } else { - writeln!(file, ",")?; - } - serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D - total += 1; - } - - if total % 100000 == 0 { - println!(" {total} messages"); - } - } - - write!(file, "\n]")?; - - println!(" {total} messages in total"); - - Ok(()) -} diff --git a/src/export/text.rs b/src/export/text.rs deleted file mode 100644 index 236728c..0000000 --- a/src/export/text.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::fs::File; -use std::io::{BufWriter, Write}; - -use euphoxide::api::Snowflake; -use time::format_description::FormatItem; -use time::macros::format_description; -use unicode_width::UnicodeWidthStr; - -use crate::euph::SmallMessage; -use crate::store::{MsgStore, Tree}; -use crate::vault::Vault; - -const TIME_FORMAT: &[FormatItem<'_>] = - format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); -const TIME_EMPTY: &str = " "; - -pub async fn export_to_file( - vault: &Vault, - room: String, - file: &mut BufWriter<File>, -) -> anyhow::Result<()> { - let vault = vault.euph(room); - - let mut exported_trees = 0; - let mut exported_msgs = 0; - let mut tree_id = vault.first_tree_id().await; - while let Some(some_tree_id) = tree_id { - let tree = vault.tree(&some_tree_id).await; - write_tree(file, &tree, some_tree_id, 0)?; - tree_id = vault.next_tree_id(&some_tree_id).await; - - exported_trees += 1; - exported_msgs += tree.len(); - - if exported_trees % 10000 == 0 { - println!(" {exported_trees} trees, {exported_msgs} messages") - } - } - println!(" {exported_trees} trees, {exported_msgs} messages in total"); - - Ok(()) -} - -fn write_tree( - file: &mut BufWriter<File>, - tree: &Tree<SmallMessage>, - id: Snowflake, - indent: usize, -) -> anyhow::Result<()> { - let indent_string = "| ".repeat(indent); - - if let Some(msg) = tree.msg(&id) { - write_msg(file, &indent_string, msg)?; - } else { - write_placeholder(file, &indent_string)?; - } - - if let Some(children) = tree.children(&id) { - for child in children { - write_tree(file, tree, *child, indent + 1)?; - } - } - - Ok(()) -} - -fn write_msg( - file: &mut BufWriter<File>, - indent_string: &str, - msg: &SmallMessage, -) -> anyhow::Result<()> { - let nick = &msg.nick; - let nick_empty = " ".repeat(nick.width()); - - for (i, line) in msg.content.lines().enumerate() { - if i == 0 { - let time = msg - .time - .0 - .format(TIME_FORMAT) - .expect("time can be formatted"); - writeln!(file, "{time} {indent_string}[{nick}] {line}")?; - } else { - writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?; - } - } - - Ok(()) -} - -fn write_placeholder(file: &mut BufWriter<File>, indent_string: &str) -> anyhow::Result<()> { - writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?; - Ok(()) -} diff --git a/src/logger.rs b/src/logger.rs deleted file mode 100644 index eb4b10b..0000000 --- a/src/logger.rs +++ /dev/null @@ -1,189 +0,0 @@ -use std::sync::Arc; -use std::vec; - -use async_trait::async_trait; -use crossterm::style::{ContentStyle, Stylize}; -use log::{Level, Log}; -use parking_lot::Mutex; -use time::OffsetDateTime; -use tokio::sync::mpsc; -use toss::styled::Styled; - -use crate::store::{Msg, MsgStore, Path, Tree}; -use crate::ui::ChatMsg; - -#[derive(Debug, Clone)] -pub struct LogMsg { - id: usize, - time: OffsetDateTime, - level: Level, - content: String, -} - -impl Msg for LogMsg { - type Id = usize; - - fn id(&self) -> Self::Id { - self.id - } - - fn parent(&self) -> Option<Self::Id> { - None - } - - fn seen(&self) -> bool { - true - } - - fn last_possible_id() -> Self::Id { - Self::Id::MAX - } -} - -impl ChatMsg for LogMsg { - fn time(&self) -> OffsetDateTime { - 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(), - }; - let nick = Styled::new(format!("{}", self.level), nick_style); - let content = Styled::new_plain(&self.content); - (nick, content) - } - - fn edit(_nick: &str, _content: &str) -> (Styled, Styled) { - panic!("log is not editable") - } - - fn pseudo(_nick: &str, _content: &str) -> (Styled, Styled) { - panic!("log is not editable") - } -} - -#[derive(Debug, Clone)] -pub struct Logger { - event_tx: mpsc::UnboundedSender<()>, - messages: Arc<Mutex<Vec<LogMsg>>>, -} - -#[async_trait] -impl MsgStore<LogMsg> for Logger { - async fn path(&self, id: &usize) -> Path<usize> { - Path::new(vec![*id]) - } - - async fn tree(&self, tree_id: &usize) -> Tree<LogMsg> { - let msgs = self - .messages - .lock() - .get(*tree_id) - .map(|msg| vec![msg.clone()]) - .unwrap_or_default(); - Tree::new(*tree_id, msgs) - } - - async fn first_tree_id(&self) -> Option<usize> { - let empty = self.messages.lock().is_empty(); - Some(0).filter(|_| !empty) - } - - async fn last_tree_id(&self) -> Option<usize> { - self.messages.lock().len().checked_sub(1) - } - - async fn prev_tree_id(&self, tree_id: &usize) -> Option<usize> { - tree_id.checked_sub(1) - } - - async fn next_tree_id(&self, tree_id: &usize) -> Option<usize> { - let len = self.messages.lock().len(); - tree_id.checked_add(1).filter(|t| *t < len) - } - - async fn oldest_msg_id(&self) -> Option<usize> { - self.first_tree_id().await - } - - async fn newest_msg_id(&self) -> Option<usize> { - self.last_tree_id().await - } - - async fn older_msg_id(&self, id: &usize) -> Option<usize> { - self.prev_tree_id(id).await - } - - async fn newer_msg_id(&self, id: &usize) -> Option<usize> { - self.next_tree_id(id).await - } - - async fn oldest_unseen_msg_id(&self) -> Option<usize> { - None - } - - async fn newest_unseen_msg_id(&self) -> Option<usize> { - None - } - - async fn older_unseen_msg_id(&self, _id: &usize) -> Option<usize> { - None - } - - async fn newer_unseen_msg_id(&self, _id: &usize) -> Option<usize> { - None - } - - async fn unseen_msgs_count(&self) -> usize { - 0 - } - - async fn set_seen(&self, _id: &usize, _seen: bool) {} - - async fn set_older_seen(&self, _id: &usize, _seen: bool) {} -} - -impl Log for Logger { - fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { - true - } - - fn log(&self, record: &log::Record<'_>) { - if !self.enabled(record.metadata()) { - return; - } - - let mut guard = self.messages.lock(); - let msg = LogMsg { - id: guard.len(), - time: OffsetDateTime::now_utc(), - level: record.level(), - content: format!("<{}> {}", record.target(), record.args()), - }; - guard.push(msg); - - let _ = self.event_tx.send(()); - } - - fn flush(&self) {} -} - -impl Logger { - pub fn init(level: Level) -> (Self, mpsc::UnboundedReceiver<()>) { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let logger = Self { - event_tx, - messages: Arc::new(Mutex::new(Vec::new())), - }; - - log::set_boxed_logger(Box::new(logger.clone())).expect("logger already set"); - log::set_max_level(level.to_level_filter()); - - (logger, event_rx) - } -} diff --git a/src/macros.rs b/src/macros.rs deleted file mode 100644 index 36372d7..0000000 --- a/src/macros.rs +++ /dev/null @@ -1,31 +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; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 0d8115a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,134 +0,0 @@ -#![deny(unsafe_code)] -// Rustc lint groups -#![warn(future_incompatible)] -#![warn(rust_2018_idioms)] -// Rustc lints -#![warn(noop_method_call)] -#![warn(single_use_lifetimes)] -#![warn(trivial_numeric_casts)] -#![warn(unused_crate_dependencies)] -#![warn(unused_extern_crates)] -#![warn(unused_import_braces)] -#![warn(unused_lifetimes)] -#![warn(unused_qualifications)] -// Clippy lints -#![warn(clippy::use_self)] - -// TODO Enable warn(unreachable_pub)? -// TODO Clean up use and manipulation of toss Pos and Size - -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::ProjectDirs; -use log::info; -use toss::terminal::Terminal; -use ui::Ui; -use vault::Vault; - -use crate::logger::Logger; - -#[derive(Debug, clap::Subcommand)] -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)] -#[clap(version)] -struct Args { - /// Path to a directory for cove to store its data in. - #[clap(long, short)] - data_dir: Option<PathBuf>, - - /// If set, cove won't store data permanently. - #[clap(long, short, action)] - ephemeral: bool, - - /// Measure the width of characters as displayed by the terminal emulator - /// instead of guessing the width. - #[clap(long, short, action)] - measure_widths: bool, - - #[clap(subcommand)] - command: Option<Command>, -} - -fn data_dir(args_data_dir: Option<PathBuf>) -> PathBuf { - if let Some(data_dir) = args_data_dir { - data_dir - } else { - let dirs = - ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories"); - dirs.data_dir().to_path_buf() - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - - let vault = if args.ephemeral { - vault::launch_in_memory()? - } else { - let data_dir = data_dir(args.data_dir); - println!("Data dir: {}", data_dir.to_string_lossy()); - vault::launch(&data_dir.join("vault.db"))? - }; - - match args.command.unwrap_or_default() { - Command::Run => run(&vault, args.measure_widths).await?, - Command::Export(args) => export::export(&vault, args).await?, - Command::Gc => { - println!("Cleaning up and compacting vault"); - println!("This may take a while..."); - vault.gc().await; - } - Command::ClearCookies => { - println!("Clearing cookies"); - vault.set_euph_cookies(CookieJar::new()); - } - } - - vault.close().await; - - println!("Goodbye!"); - Ok(()) -} - -async fn run(vault: &Vault, measure_widths: bool) -> anyhow::Result<()> { - let (logger, logger_rx) = Logger::init(log::Level::Debug); - info!( - "Welcome to {} {}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - ); - - let mut terminal = Terminal::new()?; - terminal.set_measuring(measure_widths); - Ui::run(&mut terminal, vault.clone(), logger, logger_rx).await?; - drop(terminal); // So the vault can print again - - Ok(()) -} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index cc2035d..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,318 +0,0 @@ -mod chat; -mod euph; -mod input; -mod rooms; -mod util; -mod widgets; - -use std::convert::Infallible; -use std::io; -use std::sync::{Arc, Weak}; -use std::time::{Duration, Instant}; - -use crossterm::event::KeyCode; -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::euph::EuphRoomEvent; -use crate::logger::{LogMsg, Logger}; -use crate::macros::{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, KeyEvent}; -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 - -pub enum UiEvent { - GraphemeWidthsChanged, - LogChanged, - Term(crossterm::event::Event), - EuphRoom { name: String, event: EuphRoomEvent }, -} - -enum EventHandleResult { - Redraw, - Continue, - Stop, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Mode { - Main, - Log, -} - -pub struct Ui { - event_tx: UnboundedSender<UiEvent>, - - mode: Mode, - - rooms: Rooms, - log_chat: ChatState<LogMsg, Logger>, - key_bindings_list: Option<ListState<Infallible>>, -} - -impl Ui { - const POLL_DURATION: Duration = Duration::from_millis(100); - - pub async fn run( - terminal: &mut Terminal, - vault: Vault, - logger: Logger, - logger_rx: UnboundedReceiver<()>, - ) -> anyhow::Result<()> { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let crossterm_lock = Arc::new(FairMutex::new(())); - - // Prepare and start crossterm event polling task - let weak_crossterm_lock = Arc::downgrade(&crossterm_lock); - let event_tx_clone = event_tx.clone(); - let crossterm_event_task = task::spawn_blocking(|| { - Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock) - }); - - // Run main UI. - // - // If the run_main method exits at any point or if this `run` method is - // not awaited any more, the crossterm_lock Arc should be deallocated, - // meaning the crossterm_event_task will also stop after at most - // `Self::POLL_DURATION`. - // - // 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 { - event_tx: event_tx.clone(), - mode: Mode::Main, - rooms: Rooms::new(vault, event_tx.clone()), - log_chat: ChatState::new(logger), - key_bindings_list: None, - }; - tokio::select! { - e = ui.run_main(terminal, event_rx, crossterm_lock) => e?, - _ = Self::update_on_log_event(logger_rx, &event_tx) => (), - e = crossterm_event_task => e??, - } - Ok(()) - } - - fn poll_crossterm_events( - tx: UnboundedSender<UiEvent>, - lock: Weak<FairMutex<()>>, - ) -> crossterm::Result<()> { - loop { - let lock = some_or_return!(lock.upgrade(), 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(())); - } - } - } - - async fn update_on_log_event( - mut logger_rx: UnboundedReceiver<()>, - event_tx: &UnboundedSender<UiEvent>, - ) { - loop { - some_or_return!(logger_rx.recv().await); - ok_or_return!(event_tx.send(UiEvent::LogChanged)); - } - } - - async fn run_main( - &mut self, - terminal: &mut Terminal, - mut event_rx: UnboundedReceiver<UiEvent>, - crossterm_lock: Arc<FairMutex<()>>, - ) -> 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()?; - - 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(())); - } - - // 2. Handle events (in batches) - let mut event = match event_rx.recv().await { - Some(event) => event, - None => return Ok(()), - }; - let mut redraw = false; - let end_time = Instant::now() + EVENT_PROCESSING_TIME; - loop { - match self.handle_event(terminal, &crossterm_lock, event).await { - EventHandleResult::Redraw => redraw = true, - EventHandleResult::Continue => {} - EventHandleResult::Stop => return Ok(()), - } - if Instant::now() >= end_time { - break; - } - event = match event_rx.try_recv() { - Ok(event) => event, - Err(TryRecvError::Empty) => break, - 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 { - let widget = match self.mode { - Mode::Main => self.rooms.widget().await, - Mode::Log => self.log_chat.widget(String::new()).into(), - }; - - 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() - } 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, - crossterm_lock: &Arc<FairMutex<()>>, - event: UiEvent, - ) -> EventHandleResult { - match event { - UiEvent::GraphemeWidthsChanged => EventHandleResult::Redraw, - UiEvent::LogChanged if self.mode == Mode::Log => EventHandleResult::Redraw, - UiEvent::LogChanged => EventHandleResult::Continue, - UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw, - UiEvent::Term(event) => { - self.handle_term_event(terminal, crossterm_lock, event) - .await - } - UiEvent::EuphRoom { name, event } => { - let handled = self.handle_euph_room_event(name, event).await; - if self.mode == Mode::Main && handled { - EventHandleResult::Redraw - } else { - EventHandleResult::Continue - } - } - } - } - - async fn handle_term_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: crossterm::event::Event, - ) -> EventHandleResult { - let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue); - - 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. - 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, - } - 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 { - Mode::Main => { - self.rooms - .handle_input_event(terminal, crossterm_lock, &event) - .await - } - Mode::Log => self - .log_chat - .handle_input_event(terminal, crossterm_lock, &event, false) - .await - .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 handled { - EventHandleResult::Redraw - } else { - EventHandleResult::Continue - } - } - - async fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool { - let handled = self.rooms.handle_euph_room_event(name, event); - handled && self.mode == Mode::Main - } -} diff --git a/src/ui/chat.rs b/src/ui/chat.rs deleted file mode 100644 index d4736de..0000000 --- a/src/ui/chat.rs +++ /dev/null @@ -1,147 +0,0 @@ -mod blocks; -mod tree; - -use std::sync::Arc; - -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) -> Chat<M, S> { - match self.mode { - Mode::Tree => Chat::Tree(self.tree.widget(nick)), - } - } -} - -pub enum Reaction<M: Msg> { - NotHandled, - Handled, - Composed { - parent: Option<M::Id>, - content: String, - }, -} - -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, - ) -> Reaction<M> { - match self.mode { - Mode::Tree => { - self.tree - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .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, -{ - 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 4087094..0000000 --- a/src/ui/chat/tree.rs +++ /dev/null @@ -1,424 +0,0 @@ -mod cursor; -mod layout; -mod tree_blocks; -mod widgets; - -use std::collections::HashSet; -use std::sync::Arc; - -use async_trait::async_trait; -use crossterm::event::KeyCode; -use parking_lot::FairMutex; -use tokio::sync::Mutex; -use toss::frame::{Frame, Pos, Size}; -use toss::terminal::Terminal; - -use crate::store::{Msg, MsgStore}; -use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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("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"); - } - - async fn handle_movement_input_event(&mut self, frame: &mut Frame, event: &InputEvent) -> bool { - 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!('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 false, - } - - 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>) -> bool { - match event { - key!(' ') => { - if let Some(id) = id { - if !self.folded.remove(id) { - self.folded.insert(id.clone()); - } - return 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 true; - } - } - key!('S') => { - for id in &self.last_visible_msgs { - self.store.set_seen(id, true).await; - } - return 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 true; - } - _ => {} - } - false - } - - pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.empty(); - bindings.binding("r", "reply to message"); - bindings.binding_ctd("(inline if possible, otherwise 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>, - ) -> bool { - 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 false, - } - - true - } - - pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - self.list_movement_key_bindings(bindings); - self.list_action_key_bindings(bindings); - if can_compose { - 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>, - ) -> bool { - #[allow(clippy::if_same_then_else)] - 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(bindings, |_| true, 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( - &self.editor, - terminal, - crossterm_lock, - event, - |_| true, - true, - ); - if !handled { - return Reaction::NotHandled; - } - } - } - - 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, - ) -> Reaction<M> { - 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 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) -> TreeView<M, S> { - TreeView { - inner: self.0.clone(), - nick, - } - } - - 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, - ) -> Reaction<M> { - self.0 - .lock() - .await - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await - } - - 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, -} - -#[async_trait] -impl<M, S> Widget for TreeView<M, S> -where - M: Msg + ChatMsg, - M::Id: Send + Sync, - S: MsgStore<M> + Send + Sync, -{ - 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 = guard.relayout(&self.nick, 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 aba8bc3..0000000 --- a/src/ui/chat/tree/cursor.rs +++ /dev/null @@ -1,458 +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) -> bool { - 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_tree_id) = store.prev_tree_id(tree.root()).await { - *tree = store.tree(&prev_tree_id).await; - *id = prev_tree_id; - true - } else { - false - } - } else { - false - } - } - - /// 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) -> bool { - 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_tree_id) = store.next_tree_id(tree.root()).await { - *tree = store.tree(&next_tree_id).await; - *id = next_tree_id; - true - } else { - false - } - } else { - false - } - } - - /// 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, - ) -> bool { - // Move to previous sibling, then to its last child - // If not possible, move to parent - if Self::find_prev_sibling(store, tree, id).await { - while Self::find_last_child(folded, tree, id) {} - true - } else { - Self::find_parent(tree, id) - } - } - - /// 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, - ) -> bool { - if Self::find_first_child(folded, tree, id) { - return true; - } - - if Self::find_next_sibling(store, tree, id).await { - return 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 true; - } - } - - false - } - - pub async fn move_cursor_up(&mut self) { - match &mut self.cursor { - Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => { - if let Some(last_tree_id) = self.store.last_tree_id().await { - let tree = self.store.tree(&last_tree_id).await; - let mut id = last_tree_id; - while Self::find_last_child(&self.folded, &tree, &mut id) {} - self.cursor = Cursor::Msg(id); - } - } - Cursor::Msg(ref mut 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); - } - - pub async fn move_cursor_down(&mut self) { - match &mut self.cursor { - Cursor::Msg(ref mut 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); - } - - pub async fn move_cursor_up_sibling(&mut self) { - match &mut self.cursor { - Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => { - if let Some(last_tree_id) = self.store.last_tree_id().await { - self.cursor = Cursor::Msg(last_tree_id); - } - } - Cursor::Msg(ref mut 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); - } - - pub async fn move_cursor_down_sibling(&mut self) { - match &mut self.cursor { - Cursor::Msg(ref mut 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); - } - - pub async fn move_cursor_older(&mut self) { - 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); - } - - pub async fn move_cursor_newer(&mut self) { - 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); - } - - pub async fn move_cursor_older_unseen(&mut self) { - 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); - } - - pub async fn move_cursor_newer_unseen(&mut self) { - 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); - } - - pub async fn move_cursor_to_top(&mut self) { - if let Some(first_tree_id) = self.store.first_tree_id().await { - self.cursor = Cursor::Msg(first_tree_id); - self.correction = Some(Correction::MakeCursorVisible); - } - } - - 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); - } - - pub async fn parent_for_normal_reply(&self) -> Option<Option<M::Id>> { - 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, - } - } - - pub async fn parent_for_alternate_reply(&self) -> Option<Option<M::Id>> { - 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, - } - } -} - -/* - pub async fn center_cursor<S: MsgStore<M>>( - &mut self, - store: &S, - cursor: &mut Option<Cursor<M::Id>>, - frame: &mut Frame, - size: Size, - ) { - if let Some(cursor) = cursor { - cursor.proportion = 0.5; - - // Correcting the offset just to make sure that this function - // behaves nicely if the cursor has too many lines. - let old_blocks = self.layout_blocks(store, Some(cursor), frame, size).await; - let old_cursor_id = Some(cursor.id.clone()); - self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor) - .await; - } - } -*/ diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs deleted file mode 100644 index 7e41f00..0000000 --- a/src/ui/chat/tree/layout.rs +++ /dev/null @@ -1,564 +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) -} - -impl<M: Msg + ChatMsg, S: MsgStore<M>> InnerTreeViewState<M, S> { - async fn cursor_path(&self, cursor: &Cursor<M::Id>) -> Path<M::Id> { - 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, nick: &str, frame: &mut Frame, indent: usize) -> Block<BlockId<M::Id>> { - let (widget, cursor_row) = widgets::editor::<M>(frame, indent, 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, nick: &str, frame: &mut Frame, indent: usize) -> Block<BlockId<M::Id>> { - let widget = widgets::pseudo::<M>(indent, nick, &self.editor); - Block::new(frame, BlockId::Cursor, widget) - } - - fn layout_subtree( - &self, - nick: &str, - 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 = 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(nick, 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(nick, frame, indent + 1)) - } - Cursor::Pseudo { .. } => { - blocks - .blocks_mut() - .push_back(self.pseudo_block(nick, frame, indent + 1)) - } - _ => {} - } - } - } - - fn layout_tree(&self, nick: &str, 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(nick, frame, &tree, 0, tree.root(), &mut blocks); - blocks - } - - fn layout_bottom(&self, nick: &str, 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(nick, frame, 0)), - Cursor::Pseudo { parent: None, .. } => blocks - .blocks_mut() - .push_back(self.pseudo_block(nick, frame, 0)), - _ => {} - } - - blocks - } - - async fn expand_to_top(&self, nick: &str, frame: &mut Frame, blocks: &mut TreeBlocks<M::Id>) { - let top_line = 0; - - while blocks.blocks().top_line > top_line { - let top_root = blocks.top_root(); - let prev_tree_id = match top_root { - Root::Bottom => self.store.last_tree_id().await, - Root::Tree(tree_id) => self.store.prev_tree_id(tree_id).await, - }; - let prev_tree_id = match prev_tree_id { - Some(tree_id) => tree_id, - None => break, - }; - let prev_tree = self.store.tree(&prev_tree_id).await; - blocks.prepend(self.layout_tree(nick, frame, prev_tree)); - } - } - - async fn expand_to_bottom( - &self, - nick: &str, - frame: &mut Frame, - blocks: &mut TreeBlocks<M::Id>, - ) { - let bottom_line = frame.size().height as i32 - 1; - - while blocks.blocks().bottom_line < bottom_line { - let bottom_root = blocks.bottom_root(); - let next_tree_id = match bottom_root { - Root::Bottom => break, - Root::Tree(tree_id) => self.store.next_tree_id(tree_id).await, - }; - if let Some(next_tree_id) = next_tree_id { - let next_tree = self.store.tree(&next_tree_id).await; - blocks.append(self.layout_tree(nick, frame, next_tree)); - } else { - blocks.append(self.layout_bottom(nick, frame)); - } - } - } - - async fn fill_screen_and_clamp_scrolling( - &self, - nick: &str, - frame: &mut Frame, - blocks: &mut TreeBlocks<M::Id>, - ) { - let top_line = 0; - let bottom_line = frame.size().height as i32 - 1; - - self.expand_to_top(nick, frame, blocks).await; - - if blocks.blocks().top_line > top_line { - blocks.blocks_mut().set_top_line(0); - } - - self.expand_to_bottom(nick, frame, blocks).await; - - if blocks.blocks().bottom_line < bottom_line { - blocks.blocks_mut().set_bottom_line(bottom_line); - } - - self.expand_to_top(nick, frame, blocks).await; - } - - async fn layout_last_cursor_seed( - &self, - nick: &str, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - ) -> TreeBlocks<M::Id> { - match &self.last_cursor { - Cursor::Bottom => { - let mut blocks = self.layout_bottom(nick, 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(nick, 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(nick, frame, tree); - - blocks - .blocks_mut() - .recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line); - - blocks - } - } - } - - async fn layout_cursor_seed( - &self, - nick: &str, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - cursor_path: &Path<M::Id>, - ) -> TreeBlocks<M::Id> { - let bottom_line = frame.size().height as i32 - 1; - - match &self.cursor { - Cursor::Bottom - | Cursor::Editor { parent: None, .. } - | Cursor::Pseudo { parent: None, .. } => { - let mut blocks = self.layout_bottom(nick, 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(nick, 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, - nick: &str, - frame: &mut Frame, - last_cursor_path: &Path<M::Id>, - cursor_path: &Path<M::Id>, - ) -> TreeBlocks<M::Id> { - if let Cursor::Bottom = self.cursor { - self.layout_cursor_seed(nick, frame, last_cursor_path, cursor_path) - .await - } else { - self.layout_last_cursor_seed(nick, frame, last_cursor_path) - .await - } - } - - fn scroll_so_cursor_is_visible(&self, frame: &mut 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 = 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: &mut 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; - 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: &mut 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: &str, frame: &mut Frame) -> TreeBlocks<M::Id> { - // 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 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(nick, frame, &last_cursor_path, &cursor_path) - .await; - blocks.blocks_mut().offset(self.scroll); - self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks) - .await; - - if !self.contains_cursor(&blocks) { - blocks = self - .layout_cursor_seed(nick, frame, &last_cursor_path, &cursor_path) - .await; - self.fill_screen_and_clamp_scrolling(nick, 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(nick, 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(nick, frame, &last_cursor_path) - .await; - self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks) - .await; - } - } - Some(Correction::CenterCursor) => { - self.scroll_so_cursor_is_centered(frame, &mut blocks); - self.fill_screen_and_clamp_scrolling(nick, 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; - - 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 2ba3d14..0000000 --- a/src/ui/chat/tree/widgets.rs +++ /dev/null @@ -1,162 +0,0 @@ -// TODO Remove mut in &mut Frame wherever applicable in this entire module - -mod indent; -mod seen; -mod time; - -use crossterm::style::{ContentStyle, Stylize}; -use toss::frame::Frame; -use toss::styled::Styled; - -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>( - frame: &mut Frame, - 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(frame); - - 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 546ce2c..0000000 --- a/src/ui/euph/account.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::sync::Arc; - -use crossterm::event::KeyCode; -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::PersonalAccountView; -use euphoxide::conn::Status; -use parking_lot::FairMutex; -use toss::terminal::Terminal; - -use crate::euph::Room; -use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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; - -use super::room::RoomStatus; - -#[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, status: &RoomStatus) -> bool { - if let RoomStatus::Connected(Status::Joined(status)) = status { - match (&self, &status.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', false); - } - Self::LoggedIn(_) => bindings.binding("L", "log out"), - } - } - - pub fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - 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, - crossterm_lock, - event, - |c| c != '\n', - false, - ) { - 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, - crossterm_lock, - event, - |c| c != '\n', - false, - ) { - 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 7767df0..0000000 --- a/src/ui/euph/auth.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::sync::Arc; - -use crossterm::event::KeyCode; -use parking_lot::FairMutex; -use toss::terminal::Terminal; - -use crate::euph::Room; -use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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, false); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - -pub fn handle_input_event( - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - 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, - crossterm_lock, - event, - |_| true, - false, - ) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } - } -} diff --git a/src/ui/euph/nick.rs b/src/ui/euph/nick.rs deleted file mode 100644 index 513e0e4..0000000 --- a/src/ui/euph/nick.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; - -use crossterm::event::KeyCode; -use euphoxide::conn::Joined; -use parking_lot::FairMutex; -use toss::styled::Styled; -use toss::terminal::Terminal; - -use crate::euph::{self, Room}; -use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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| Styled::new(s, euph::nick_style(s))); - 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, false); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - -pub fn handle_input_event( - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - 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, - crossterm_lock, - event, - nick_char, - false, - ) { - 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 d54a58a..0000000 --- a/src/ui/euph/nick_list.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::iter; - -use crossterm::style::{Color, ContentStyle, Stylize}; -use euphoxide::api::{SessionType, SessionView}; -use euphoxide::conn::Joined; -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<String>, joined: &Joined) -> BoxedWidget { - let mut list = state.widget(); - render_rows(&mut list, joined); - list.into() -} - -fn render_rows(list: &mut List<String>, joined: &Joined) { - let mut people = vec![]; - let mut bots = vec![]; - let mut lurkers = vec![]; - let mut nurkers = vec![]; - - let mut sessions = iter::once(&joined.session) - .chain(joined.listing.values()) - .collect::<Vec<_>>(); - sessions.sort_unstable_by_key(|s| &s.name); - 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_by_key(|s| (&s.name, &s.session_id)); - bots.sort_unstable_by_key(|s| (&s.name, &s.session_id)); - lurkers.sort_unstable_by_key(|s| &s.session_id); - nurkers.sort_unstable_by_key(|s| &s.session_id); - - 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<String>, - name: &str, - sessions: &[&SessionView], - 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<String>, session: &SessionView, own_session: &SessionView) { - let id = session.session_id.clone(); - - let (name, style, style_inv) = if session.name.is_empty() { - let name = "lurk"; - let style = ContentStyle::default().grey(); - let style_inv = ContentStyle::default().black().on_grey(); - (name, style, 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); - (name, style, 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_plain(perms); - list.add_sel( - id, - 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 878177a..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 { - ServerError { 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::ServerError { - 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 78baa80..0000000 --- a/src/ui/euph/room.rs +++ /dev/null @@ -1,509 +0,0 @@ -use std::collections::VecDeque; -use std::sync::Arc; - -use crossterm::event::KeyCode; -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::{Data, PacketType, Snowflake}; -use euphoxide::conn::{Joined, Joining, Status}; -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::euph::{self, EuphRoomEvent}; -use crate::macros::{ok_or_return, some_or_return}; -use crate::store::MsgStore; -use crate::ui::chat::{ChatState, Reaction}; -use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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::UiEvent; -use crate::vault::EuphVault; - -use super::account::{self, AccountUiState}; -use super::popup::RoomPopup; -use super::{auth, nick, nick_list}; - -enum State { - Normal, - Auth(EditorState), - Nick(EditorState), - Account(AccountUiState), -} - -#[allow(clippy::large_enum_variant)] -pub enum RoomStatus { - NoRoom, - Stopped, - Connecting, - Connected(Status), -} - -pub struct EuphRoom { - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - - vault: EuphVault, - room: Option<euph::Room>, - - state: State, - popups: VecDeque<RoomPopup>, - - chat: ChatState<euph::SmallMessage, EuphVault>, - last_msg_sent: Option<oneshot::Receiver<Snowflake>>, - - nick_list: ListState<String>, -} - -impl EuphRoom { - pub fn new(vault: EuphVault, ui_event_tx: mpsc::UnboundedSender<UiEvent>) -> Self { - Self { - ui_event_tx, - vault: vault.clone(), - room: None, - state: State::Normal, - popups: VecDeque::new(), - chat: ChatState::new(vault), - last_msg_sent: None, - nick_list: ListState::new(), - } - } - - async fn shovel_room_events( - name: String, - mut euph_room_event_rx: mpsc::UnboundedReceiver<EuphRoomEvent>, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - ) { - loop { - let event = some_or_return!(euph_room_event_rx.recv().await); - let event = UiEvent::EuphRoom { - name: name.clone(), - event, - }; - ok_or_return!(ui_event_tx.send(event)); - } - } - - pub fn connect(&mut self) { - if self.room.is_none() { - let store = self.chat.store().clone(); - let name = store.room().to_string(); - let (room, euph_room_event_rx) = euph::Room::new(store); - - self.room = Some(room); - - tokio::task::spawn(Self::shovel_room_events( - name, - euph_room_event_rx, - self.ui_event_tx.clone(), - )); - } - } - - pub fn disconnect(&mut self) { - self.room = None; - } - - pub async fn status(&self) -> RoomStatus { - match &self.room { - Some(room) => match room.status().await { - Ok(Some(status)) => RoomStatus::Connected(status), - Ok(None) => RoomStatus::Connecting, - Err(_) => RoomStatus::Stopped, - }, - None => RoomStatus::NoRoom, - } - } - - 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 { - 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_state(&mut self, status: &RoomStatus) { - match &mut self.state { - State::Auth(_) - if !matches!( - status, - RoomStatus::Connected(Status::Joining(Joining { - bounce: Some(_), - .. - })) - ) => - { - self.state = State::Normal - } - State::Nick(_) if !matches!(status, RoomStatus::Connected(Status::Joined(_))) => { - self.state = State::Normal - } - State::Account(account) => { - if !account.stabilize(status) { - self.state = State::Normal - } - } - _ => {} - } - } - - async fn stabilize(&mut self, status: &RoomStatus) { - self.stabilize_pseudo_msg().await; - self.stabilize_state(status); - } - - pub async fn widget(&mut self) -> BoxedWidget { - let status = self.status().await; - self.stabilize(&status).await; - - let chat = if let RoomStatus::Connected(Status::Joined(joined)) = &status { - self.widget_with_nick_list(&status, joined).await - } else { - self.widget_without_nick_list(&status).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()), - } - - for popup in &self.popups { - layers.push(popup.widget()); - } - - Layer::new(layers).into() - } - - async fn widget_without_nick_list(&self, status: &RoomStatus) -> BoxedWidget { - VJoin::new(vec![ - Segment::new(Border::new( - Padding::new(self.status_widget(status).await).horizontal(1), - )), - // TODO Use last known nick? - Segment::new(self.chat.widget(String::new())).expanding(true), - ]) - .into() - } - - async fn widget_with_nick_list(&self, status: &RoomStatus, joined: &Joined) -> BoxedWidget { - HJoin::new(vec![ - Segment::new(VJoin::new(vec![ - Segment::new(Border::new( - Padding::new(self.status_widget(status).await).horizontal(1), - )), - Segment::new(self.chat.widget(joined.session.name.clone())).expanding(true), - ])) - .expanding(true), - Segment::new(Border::new( - Padding::new(nick_list::widget(&self.nick_list, joined)).right(1), - )), - ]) - .into() - } - - async fn status_widget(&self, status: &RoomStatus) -> BoxedWidget { - // TODO Include unread message count - let room = self.chat.store().room(); - let room_style = ContentStyle::default().bold().blue(); - let mut info = Styled::new(format!("&{room}"), room_style); - - info = match status { - RoomStatus::NoRoom | RoomStatus::Stopped => info.then_plain(", archive"), - RoomStatus::Connecting => info.then_plain(", connecting..."), - RoomStatus::Connected(Status::Joining(j)) if j.bounce.is_some() => { - info.then_plain(", auth required") - } - RoomStatus::Connected(Status::Joining(_)) => info.then_plain(", joining..."), - RoomStatus::Connected(Status::Joined(j)) => { - let nick = &j.session.name; - if nick.is_empty() { - info.then_plain(", present without nick") - } else { - let nick_style = euph::nick_style(nick); - info.then_plain(", present as ").then(nick, nick_style) - } - } - }; - - 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() - } - - pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "leave room"); - - let can_compose = if let Some(room) = &self.room { - match room.status().await.ok().flatten() { - Some(Status::Joining(Joining { - bounce: Some(_), .. - })) => { - bindings.binding("a", "authenticate"); - false - } - Some(Status::Joined(_)) => { - bindings.binding("n", "change nick"); - bindings.binding("m", "download more messages"); - bindings.binding("A", "show account ui"); - true - } - _ => false, - } - } else { - false - }; - - bindings.empty(); - self.chat.list_key_bindings(bindings, can_compose).await; - } - - async fn handle_normal_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - ) -> bool { - if let Some(room) = &self.room { - let status = room.status().await; - let can_compose = matches!(status, Ok(Some(Status::Joined(_)))); - - // We need to handle chat input first, otherwise the other - // key bindings will shadow characters in the editor. - match self - .chat - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await - { - Reaction::NotHandled => {} - Reaction::Handled => return true, - Reaction::Composed { parent, content } => { - match room.send(parent, content) { - Ok(id_rx) => self.last_msg_sent = Some(id_rx), - Err(_) => self.chat.sent(None).await, - } - return true; - } - } - - match status.ok().flatten() { - Some(Status::Joining(Joining { - bounce: Some(_), .. - })) if matches!(event, key!('a')) => { - self.state = State::Auth(auth::new()); - true - } - Some(Status::Joined(joined)) => match event { - key!('n') | key!('N') => { - self.state = State::Nick(nick::new(joined)); - true - } - key!('m') => { - if let Some(room) = &self.room { - let _ = room.log(); - } - true - } - key!('A') => { - self.state = State::Account(AccountUiState::new()); - true - } - _ => false, - }, - _ => false, - } - } else { - self.chat - .handle_input_event(terminal, crossterm_lock, event, false) - .await - .handled() - } - } - - 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), - } - } - - 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; - } - - 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, crossterm_lock, 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, crossterm_lock, 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, crossterm_lock, event, &self.room) { - account::EventResult::NotHandled => false, - account::EventResult::Handled => true, - account::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - } - } - - pub fn handle_euph_room_event(&mut self, event: EuphRoomEvent) -> bool { - match event { - EuphRoomEvent::Connected | EuphRoomEvent::Disconnected | EuphRoomEvent::Stopped => true, - EuphRoomEvent::Packet(packet) => match packet.content { - Ok(data) => self.handle_euph_data(data), - Err(reason) => self.handle_euph_error(packet.r#type, reason), - }, - } - } - - 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)), - Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason)), - _ => 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::ServerError { - description, - reason, - }); - } - - handled - } - - fn handle_euph_error(&mut self, r#type: PacketType, reason: String) -> 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::ServerError { - description, - reason, - }); - true - } -} diff --git a/src/ui/input.rs b/src/ui/input.rs deleted file mode 100644 index 2d1eb23..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), - } - } -} - -// TODO Use absolute paths -#[rustfmt::skip] -macro_rules! key { - // key!(Paste text) - ( Paste $text:ident ) => { InputEvent::Paste($text) }; - - // key!('a') - ( $key:literal ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + $key:literal ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + $key:literal ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(Char c) - ( Char $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + Char $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + Char $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(F n) - ( F $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::F($key), shift: false, ctrl: false, alt: false, }) }; - ( Shift + F $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::F($key), shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + F $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::F($key), shift: false, ctrl: true, alt: false, }) }; - ( Alt + F $key:pat ) => { InputEvent::Key(KeyEvent { code: KeyCode::F($key), shift: false, ctrl: false, alt: true, }) }; - - // key!(other) - ( $key:ident ) => { InputEvent::Key(KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: false, }) }; - ( Shift + $key:ident ) => { InputEvent::Key(KeyEvent { code: KeyCode::$key, shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + $key:ident ) => { InputEvent::Key(KeyEvent { code: KeyCode::$key, shift: false, ctrl: true, alt: false, }) }; - ( Alt + $key:ident ) => { InputEvent::Key(KeyEvent { code: 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 59d21bb..0000000 --- a/src/ui/rooms.rs +++ /dev/null @@ -1,379 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::iter; -use std::sync::Arc; - -use crossterm::event::KeyCode; -use crossterm::style::{ContentStyle, Stylize}; -use euphoxide::api::SessionType; -use euphoxide::conn::{Joined, Status}; -use parking_lot::FairMutex; -use tokio::sync::mpsc; -use toss::styled::Styled; -use toss::terminal::Terminal; - -use crate::euph::EuphRoomEvent; -use crate::vault::Vault; - -use super::euph::room::{EuphRoom, RoomStatus}; -use super::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -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::padding::Padding; -use super::widgets::popup::Popup; -use super::widgets::text::Text; -use super::widgets::BoxedWidget; -use super::{util, UiEvent}; - -enum State { - ShowList, - ShowRoom(String), - Connect(EditorState), -} - -pub struct Rooms { - vault: Vault, - ui_event_tx: mpsc::UnboundedSender<UiEvent>, - - state: State, - - list: ListState<String>, - euph_rooms: HashMap<String, EuphRoom>, -} - -impl Rooms { - pub fn new(vault: Vault, ui_event_tx: mpsc::UnboundedSender<UiEvent>) -> Self { - Self { - vault, - ui_event_tx, - state: State::ShowList, - list: ListState::new(), - euph_rooms: HashMap::new(), - } - } - - fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom { - self.euph_rooms - .entry(name.clone()) - .or_insert_with(|| EuphRoom::new(self.vault.euph(name), self.ui_event_tx.clone())) - } - - /// 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 mut rooms_set = self - .vault - .euph_rooms() - .await - .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(), - } - } - - 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( - Padding::new(HJoin::new(vec![ - Segment::new(Text::new(("&", room_style))), - Segment::new(editor).priority(0), - ])) - .left(1), - ) - .title("Connect to") - .inner_padding(false) - .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; - for sess in iter::once(&joined.session).chain(joined.listing.values()) { - match sess.id.session_type() { - Some(SessionType::Bot) if sess.name.is_empty() => n += 1, - Some(SessionType::Bot) => b += 1, - _ if sess.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(" ") - } - - async fn format_status(room: &EuphRoom) -> Option<String> { - match room.status().await { - RoomStatus::NoRoom | RoomStatus::Stopped => None, - RoomStatus::Connecting => Some("connecting".to_string()), - RoomStatus::Connected(Status::Joining(j)) if j.bounce.is_some() => { - Some("auth required".to_string()) - } - RoomStatus::Connected(Status::Joining(_)) => Some("joining".to_string()), - RoomStatus::Connected(Status::Joined(joined)) => Some(Self::format_pbln(&joined)), - } - } - - async fn format_unseen_msgs(room: &EuphRoom) -> Option<String> { - let unseen = room.unseen_msgs_count().await; - if unseen == 0 { - None - } else { - Some(format!("{unseen}")) - } - } - - async fn format_room_info(room: &EuphRoom) -> Styled { - let unseen_style = ContentStyle::default().bold().green(); - - let status = Self::format_status(room).await; - let unseen = Self::format_unseen_msgs(room).await; - - match (status, 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(")"), - } - } - - 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 = self.euph_rooms.iter().collect::<Vec<_>>(); - rooms.sort_by_key(|(n, _)| *n); - for (name, room) 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(room).await; - 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 == '_' - } - - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - match &self.state { - State::ShowList => { - bindings.heading("Rooms"); - 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", "enter selected room"); - bindings.binding("c", "connect to selected room"); - bindings.binding("C", "connect to new room"); - bindings.binding("d", "disconnect from selected room"); - bindings.binding("D", "delete room"); - } - 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, false); - } - } - } - - 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 => match event { - 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) => { - if let Some(name) = self.list.cursor() { - self.state = State::ShowRoom(name); - } - } - key!('c') => { - if let Some(name) = self.list.cursor() { - if let Some(room) = self.euph_rooms.get_mut(&name) { - room.connect(); - } - } - } - key!('C') => self.state = State::Connect(EditorState::new()), - key!('d') => { - if let Some(name) = self.list.cursor() { - if let Some(room) = self.euph_rooms.get_mut(&name) { - room.disconnect(); - } - } - } - key!('D') => { - // TODO Check whether user wanted this via popup - if let Some(name) = self.list.cursor() { - self.euph_rooms.remove(&name); - self.vault.euph(name.clone()).delete(); - } - } - _ => return false, - }, - 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; - } - } - - return false; - } - State::Connect(ed) => match event { - key!(Esc) => self.state = State::ShowList, - key!(Enter) => { - let name = ed.text(); - if !name.is_empty() { - self.get_or_insert_room(name.clone()).connect(); - self.state = State::ShowRoom(name); - } - } - _ => { - return util::handle_editor_input_event( - ed, - terminal, - crossterm_lock, - event, - Self::room_char, - false, - ) - } - }, - } - - true - } - - pub fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool { - let room_visible = if let State::ShowRoom(n) = &self.state { - *n == name - } else { - true - }; - - let room = self.get_or_insert_room(name); - let handled = room.handle_euph_room_event(event); - handled && room_visible - } -} diff --git a/src/ui/util.rs b/src/ui/util.rs deleted file mode 100644 index e14e5d9..0000000 --- a/src/ui/util.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::sync::Arc; - -use crossterm::event::KeyCode; -use parking_lot::FairMutex; -use toss::terminal::Terminal; - -use super::input::{key, InputEvent, KeyBindingsList, KeyEvent}; -use super::widgets::editor::EditorState; - -pub fn prompt( - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - initial_text: &str, -) -> Option<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 - }; - - // TODO Don't swipe this error under the rug - let content = content.ok()?; - - if content.trim().is_empty() { - None - } else { - Some(content) - } -} - -pub fn list_editor_key_bindings( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, - can_edit_externally: bool, -) { - if char_filter('\n') { - bindings.binding("enter+<any modifier>", "insert newline"); - } - - // Editing - bindings.binding("ctrl+h, backspace", "delete before cursor"); - bindings.binding("ctrl+d, delete", "delete after cursor"); - bindings.binding("ctrl+l", "clear editor contents"); - if can_edit_externally { - bindings.binding("ctrl+x", "edit in external editor"); - } - - bindings.empty(); - - // Cursor movement - 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 handle_editor_input_event( - editor: &EditorState, - terminal: &mut Terminal, - crossterm_lock: &Arc<FairMutex<()>>, - event: &InputEvent, - char_filter: impl Fn(char) -> bool, - can_edit_externally: 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(KeyEvent { - code: KeyCode::Enter, - .. - }) if char_filter('\n') => editor.insert_char(terminal.frame(), '\n'), - - // Editing - key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.frame(), *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.frame(), &str); - } else { - return false; - } - } - key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.frame()), - key!(Ctrl + 'd') | key!(Delete) => editor.delete(), - key!(Ctrl + 'l') => editor.clear(), - key!(Ctrl + 'x') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), - - // Cursor movement - key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()), - key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.frame()), - key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.frame()), - key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.frame()), - key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.frame()), - key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.frame()), - key!(Up) => editor.move_cursor_up(terminal.frame()), - key!(Down) => editor.move_cursor_down(terminal.frame()), - - _ => return false, - } - - true -} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs deleted file mode 100644 index f9ebba1..0000000 --- a/src/ui/widgets.rs +++ /dev/null @@ -1,37 +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}; - -#[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 ff4a183..0000000 --- a/src/ui/widgets/editor.rs +++ /dev/null @@ -1,542 +0,0 @@ -use std::iter; -use std::sync::Arc; - -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 unicode_segmentation::UnicodeSegmentation; - -use crate::ui::util; - -use super::text::Text; -use super::Widget; - -/// Like [`Frame::wrap`] but includes a final break index if the text ends with -/// a newline. -fn wrap(frame: &mut Frame, text: &str, width: usize) -> Vec<usize> { - let mut breaks = frame.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, frame: &mut Frame, line_start: usize) -> usize { - frame.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, frame: &mut Frame, 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 += frame.grapheme_width(g, width) as usize; - } else { - return; - } - } - - if !line.ends_with('\n') { - self.idx = end; - } - } - - fn record_cursor_col(&mut self, frame: &mut Frame) { - let boundaries = self.line_boundaries(); - let (_, start, _) = self.cursor_line(&boundaries); - self.col = self.cursor_col(frame, start); - } - - ///////////// - // Editing // - ///////////// - - fn clear(&mut self) { - self.text = String::new(); - self.idx = 0; - self.col = 0; - } - - fn set_text(&mut self, frame: &mut Frame, text: String) { - self.text = text; - self.move_cursor_to_grapheme_boundary(); - self.record_cursor_col(frame); - } - - /// Insert a character at the current cursor position and move the cursor - /// accordingly. - fn insert_char(&mut self, frame: &mut Frame, ch: char) { - self.text.insert(self.idx, ch); - self.idx += ch.len_utf8(); - self.record_cursor_col(frame); - } - - /// Insert a string at the current cursor position and move the cursor - /// accordingly. - fn insert_str(&mut self, frame: &mut Frame, str: &str) { - self.text.insert_str(self.idx, str); - self.idx += str.len(); - self.record_cursor_col(frame); - } - - /// Delete the grapheme before the cursor position. - fn backspace(&mut self, frame: &mut Frame) { - 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(frame); - 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, frame: &mut Frame) { - 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(frame); - break; - } - } - } - - fn move_cursor_right(&mut self, frame: &mut Frame) { - 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(frame); - break; - } - } - } - - fn move_cursor_left_a_word(&mut self, frame: &mut Frame) { - 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(frame); - } - - fn move_cursor_right_a_word(&mut self, frame: &mut Frame) { - 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(frame); - } - - fn move_cursor_to_start_of_line(&mut self, frame: &mut Frame) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - self.move_cursor_to_line_col(frame, line, 0); - self.record_cursor_col(frame); - } - - fn move_cursor_to_end_of_line(&mut self, frame: &mut Frame) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - self.move_cursor_to_line_col(frame, line, usize::MAX); - self.record_cursor_col(frame); - } - - fn move_cursor_up(&mut self, frame: &mut Frame) { - let boundaries = self.line_boundaries(); - let (line, _, _) = self.cursor_line(&boundaries); - if line > 0 { - self.move_cursor_to_line_col(frame, line - 1, self.col); - } - } - - fn move_cursor_down(&mut self, frame: &mut Frame) { - 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(frame, 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, frame: &mut Frame, text: String) { - self.0.lock().set_text(frame, text); - } - - pub fn insert_char(&self, frame: &mut Frame, ch: char) { - self.0.lock().insert_char(frame, ch); - } - - pub fn insert_str(&self, frame: &mut Frame, str: &str) { - self.0.lock().insert_str(frame, str); - } - - /// Delete the grapheme before the cursor position. - pub fn backspace(&self, frame: &mut Frame) { - self.0.lock().backspace(frame); - } - - /// Delete the grapheme after the cursor position. - pub fn delete(&self) { - self.0.lock().delete(); - } - - pub fn move_cursor_left(&self, frame: &mut Frame) { - self.0.lock().move_cursor_left(frame); - } - - pub fn move_cursor_right(&self, frame: &mut Frame) { - self.0.lock().move_cursor_right(frame); - } - - pub fn move_cursor_left_a_word(&self, frame: &mut Frame) { - self.0.lock().move_cursor_left_a_word(frame); - } - - pub fn move_cursor_right_a_word(&self, frame: &mut Frame) { - self.0.lock().move_cursor_right_a_word(frame); - } - - pub fn move_cursor_to_start_of_line(&self, frame: &mut Frame) { - self.0.lock().move_cursor_to_start_of_line(frame); - } - - pub fn move_cursor_to_end_of_line(&self, frame: &mut Frame) { - self.0.lock().move_cursor_to_end_of_line(frame); - } - - pub fn move_cursor_up(&self, frame: &mut Frame) { - self.0.lock().move_cursor_up(frame); - } - - pub fn move_cursor_down(&self, frame: &mut Frame) { - self.0.lock().move_cursor_down(frame); - } - - pub fn edit_externally(&self, terminal: &mut Terminal, crossterm_lock: &Arc<FairMutex<()>>) { - let mut guard = self.0.lock(); - if let Some(text) = util::prompt(terminal, crossterm_lock, &guard.text) { - if let Some(text) = text.strip_suffix('\n') { - guard.set_text(terminal.frame(), text.to_string()); - } else { - guard.set_text(terminal.frame(), text); - } - } - } -} - -//////////// -// 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, frame: &mut Frame) -> usize { - let width = self.state.lock().last_width; - let text_width = (width - 1) as usize; - let indices = wrap(frame, 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 = if self.text.text().is_empty() { - Size::new(1, 1) - } else { - 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 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(frame, self.text.text(), max_text_width); - let lines = self.text.clone().split_at_indices(&indices); - - let min_width = lines - .iter() - .map(|l| frame.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 width = frame.size().width.max(1); - let text_width = (width - 1) as usize; - let indices = wrap(frame, self.text.text(), text_width); - let lines = self.text.split_at_indices(&indices); - - if self.focus { - let (cursor_row, cursor_line_idx) = Self::wrapped_cursor(self.idx, &indices); - let cursor_col = frame.width(lines[cursor_row].text().split_at(cursor_line_idx).0); - let cursor_col = cursor_col.min(text_width); - frame.set_cursor(Some(Pos::new(cursor_col as i32, cursor_row as i32))); - } - - for (i, line) in lines.into_iter().enumerate() { - frame.write(Pos::new(0, i as i32), line); - } - - 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 ab175f0..0000000 --- a/src/ui/widgets/list.rs +++ /dev/null @@ -1,381 +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: 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: false, - } - } -} - -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 = false; - } -} - -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); - } - - pub fn scroll_down(&mut self, amount: usize) { - let mut guard = self.0.lock(); - guard.offset = guard.offset.saturating_add(amount); - } -} - -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 7cab2bb..0000000 --- a/src/ui/widgets/text.rs +++ /dev/null @@ -1,60 +0,0 @@ -use async_trait::async_trait; -use toss::frame::{Frame, Pos, Size}; -use toss::styled::Styled; - -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 { - self.wrap = active; - self - } - - fn wrapped(&self, frame: &mut Frame, 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 = frame.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, max_width); - let min_width = lines - .iter() - .map(|l| frame.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, Some(size.width)) - .into_iter() - .enumerate() - { - frame.write(Pos::new(0, i as i32), line); - } - } -} diff --git a/src/vault.rs b/src/vault.rs deleted file mode 100644 index 66b52c2..0000000 --- a/src/vault.rs +++ /dev/null @@ -1,107 +0,0 @@ -mod euph; -mod migrate; -mod prepare; - -use std::path::Path; -use std::{fs, thread}; - -use rusqlite::Connection; -use tokio::sync::{mpsc, oneshot}; - -use self::euph::EuphRequest; -pub use self::euph::EuphVault; - -enum Request { - Close(oneshot::Sender<()>), - Gc(oneshot::Sender<()>), - Euph(EuphRequest), -} - -#[derive(Debug, Clone)] -pub struct Vault { - tx: mpsc::UnboundedSender<Request>, - ephemeral: bool, -} - -impl Vault { - pub fn ephemeral(&self) -> bool { - self.ephemeral - } - - pub async fn close(&self) { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(Request::Close(tx)); - let _ = rx.await; - } - - pub async fn gc(&self) { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(Request::Gc(tx)); - let _ = rx.await; - } - - pub fn euph(&self, room: String) -> EuphVault { - EuphVault { - vault: self.clone(), - room, - } - } -} - -fn run(mut conn: Connection, mut rx: mpsc::UnboundedReceiver<Request>) { - while let Some(request) = rx.blocking_recv() { - match request { - Request::Close(tx) => { - println!("Closing vault"); - let _ = conn.execute_batch("PRAGMA optimize"); - // Ensure `Vault::close` exits only after the sqlite connection - // has been closed properly. - drop(conn); - drop(tx); - break; - } - Request::Gc(tx) => { - let _ = conn.execute_batch("ANALYZE; VACUUM;"); - drop(tx); - } - Request::Euph(r) => r.perform(&mut conn), - } - } -} - -fn launch_from_connection(mut conn: Connection, ephemeral: bool) -> rusqlite::Result<Vault> { - conn.pragma_update(None, "foreign_keys", true)?; - conn.pragma_update(None, "trusted_schema", false)?; - - println!("Opening vault"); - - migrate::migrate(&mut conn)?; - prepare::prepare(&mut conn)?; - - let (tx, rx) = mpsc::unbounded_channel(); - thread::spawn(move || run(conn, rx)); - Ok(Vault { tx, ephemeral }) -} - -pub fn launch(path: &Path) -> rusqlite::Result<Vault> { - // If this fails, rusqlite will complain about not being able to open the db - // file, which saves me from adding a separate vault error type. - let _ = fs::create_dir_all(path.parent().expect("path to file")); - - let conn = Connection::open(path)?; - - // Setting locking mode before journal mode so no shared memory files - // (*-shm) need to be created by sqlite. Apparently, setting the journal - // mode is also enough to immediately acquire the exclusive lock even if the - // database was already using WAL. - // https://sqlite.org/pragma.html#pragma_locking_mode - conn.pragma_update(None, "locking_mode", "exclusive")?; - conn.pragma_update(None, "journal_mode", "wal")?; - - launch_from_connection(conn, false) -} - -pub fn launch_in_memory() -> rusqlite::Result<Vault> { - let conn = Connection::open_in_memory()?; - launch_from_connection(conn, true) -} diff --git a/src/vault/euph.rs b/src/vault/euph.rs deleted file mode 100644 index 119baee..0000000 --- a/src/vault/euph.rs +++ /dev/null @@ -1,1330 +0,0 @@ -use std::mem; -use std::str::FromStr; - -use async_trait::async_trait; -use cookie::{Cookie, CookieJar}; -use euphoxide::api::{Message, 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 tokio::sync::oneshot; - -use crate::euph::SmallMessage; -use crate::store::{MsgStore, Path, Tree}; - -use super::{Request, Vault}; - -/// 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() - } -} - -impl FromSql for WSnowflake { - fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> { - u64::column_result(value).map(|v| Self(Snowflake(v))) - } -} - -/// Wrapper for [`Time`] that implements useful rusqlite traits. -struct WTime(Time); - -impl ToSql for WTime { - fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> { - let timestamp = self.0 .0.unix_timestamp(); - Ok(ToSqlOutput::Owned(Value::Integer(timestamp))) - } -} - -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"), - ))) - } -} - -impl From<EuphRequest> for Request { - fn from(r: EuphRequest) -> Self { - Self::Euph(r) - } -} - -impl Vault { - pub async fn euph_cookies(&self) -> CookieJar { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetCookies { result: tx }; - let _ = self.tx.send(request.into()); - rx.await.unwrap() - } - - pub fn set_euph_cookies(&self, cookies: CookieJar) { - let request = EuphRequest::SetCookies { cookies }; - let _ = self.tx.send(request.into()); - } - - pub async fn euph_rooms(&self) -> Vec<String> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetRooms { result: tx }; - let _ = self.tx.send(request.into()); - rx.await.unwrap() - } -} - -#[derive(Debug, Clone)] -pub struct EuphVault { - pub(super) vault: Vault, - pub(super) room: String, -} - -impl EuphVault { - pub fn vault(&self) -> &Vault { - &self.vault - } - - pub fn room(&self) -> &str { - &self.room - } - - pub fn join(&self, time: Time) { - let request = EuphRequest::Join { - room: self.room.clone(), - time, - }; - let _ = self.vault.tx.send(request.into()); - } - - pub fn delete(self) { - let request = EuphRequest::Delete { room: self.room }; - let _ = self.vault.tx.send(request.into()); - } - - pub fn add_message( - &self, - msg: Message, - prev_msg: Option<Snowflake>, - own_user_id: Option<UserId>, - ) { - let request = EuphRequest::AddMsg { - room: self.room.clone(), - msg: Box::new(msg), - prev_msg, - own_user_id, - }; - let _ = self.vault.tx.send(request.into()); - } - - pub fn add_messages( - &self, - msgs: Vec<Message>, - next_msg: Option<Snowflake>, - own_user_id: Option<UserId>, - ) { - let request = EuphRequest::AddMsgs { - room: self.room.clone(), - msgs, - next_msg, - own_user_id, - }; - let _ = self.vault.tx.send(request.into()); - } - - pub async fn last_span(&self) -> Option<(Option<Snowflake>, Option<Snowflake>)> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetLastSpan { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - pub async fn chunk_at_offset(&self, amount: usize, offset: usize) -> Vec<Message> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetChunkAtOffset { - room: self.room.clone(), - amount, - offset, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } -} - -#[async_trait] -impl MsgStore<SmallMessage> for EuphVault { - async fn path(&self, id: &Snowflake) -> Path<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetPath { - room: self.room.clone(), - id: *id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn tree(&self, tree_id: &Snowflake) -> Tree<SmallMessage> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetTree { - room: self.room.clone(), - root: *tree_id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn first_tree_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetFirstTreeId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn last_tree_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetLastTreeId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn prev_tree_id(&self, tree_id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetPrevTreeId { - room: self.room.clone(), - root: *tree_id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn next_tree_id(&self, tree_id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetNextTreeId { - room: self.room.clone(), - root: *tree_id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn oldest_msg_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetOldestMsgId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn newest_msg_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetNewestMsgId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn older_msg_id(&self, id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetOlderMsgId { - room: self.room.clone(), - id: *id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn newer_msg_id(&self, id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetNewerMsgId { - room: self.room.clone(), - id: *id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn oldest_unseen_msg_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetOldestUnseenMsgId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn newest_unseen_msg_id(&self) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetNewestUnseenMsgId { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn older_unseen_msg_id(&self, id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetOlderUnseenMsgId { - room: self.room.clone(), - id: *id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn newer_unseen_msg_id(&self, id: &Snowflake) -> Option<Snowflake> { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetNewerUnseenMsgId { - room: self.room.clone(), - id: *id, - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn unseen_msgs_count(&self) -> usize { - // TODO vault::Error - let (tx, rx) = oneshot::channel(); - let request = EuphRequest::GetUnseenMsgsCount { - room: self.room.clone(), - result: tx, - }; - let _ = self.vault.tx.send(request.into()); - rx.await.unwrap() - } - - async fn set_seen(&self, id: &Snowflake, seen: bool) { - let request = EuphRequest::SetSeen { - room: self.room.clone(), - id: *id, - seen, - }; - let _ = self.vault.tx.send(request.into()); - } - - async fn set_older_seen(&self, id: &Snowflake, seen: bool) { - let request = EuphRequest::SetOlderSeen { - room: self.room.clone(), - id: *id, - seen, - }; - let _ = self.vault.tx.send(request.into()); - } -} - -pub(super) enum EuphRequest { - ///////////// - // Cookies // - ///////////// - GetCookies { - result: oneshot::Sender<CookieJar>, - }, - SetCookies { - cookies: CookieJar, - }, - - /////////// - // Rooms // - /////////// - GetRooms { - result: oneshot::Sender<Vec<String>>, - }, - Join { - room: String, - time: Time, - }, - Delete { - room: String, - }, - - ////////////// - // Messages // - ////////////// - AddMsg { - room: String, - msg: Box<Message>, - prev_msg: Option<Snowflake>, - own_user_id: Option<UserId>, - }, - AddMsgs { - room: String, - msgs: Vec<Message>, - next_msg: Option<Snowflake>, - own_user_id: Option<UserId>, - }, - GetLastSpan { - room: String, - result: oneshot::Sender<Option<(Option<Snowflake>, Option<Snowflake>)>>, - }, - GetPath { - room: String, - id: Snowflake, - result: oneshot::Sender<Path<Snowflake>>, - }, - GetTree { - room: String, - root: Snowflake, - result: oneshot::Sender<Tree<SmallMessage>>, - }, - GetFirstTreeId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetLastTreeId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetPrevTreeId { - room: String, - root: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetNextTreeId { - room: String, - root: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetOldestMsgId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetNewestMsgId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetOlderMsgId { - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetNewerMsgId { - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetOlderUnseenMsgId { - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetOldestUnseenMsgId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetNewestUnseenMsgId { - room: String, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetNewerUnseenMsgId { - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - }, - GetUnseenMsgsCount { - room: String, - result: oneshot::Sender<usize>, - }, - SetSeen { - room: String, - id: Snowflake, - seen: bool, - }, - SetOlderSeen { - room: String, - id: Snowflake, - seen: bool, - }, - GetChunkAtOffset { - room: String, - amount: usize, - offset: usize, - result: oneshot::Sender<Vec<Message>>, - }, -} - -impl EuphRequest { - pub(super) fn perform(self, conn: &mut Connection) { - let result = match self { - Self::GetCookies { result } => Self::get_cookies(conn, result), - Self::SetCookies { cookies } => Self::set_cookies(conn, cookies), - Self::GetRooms { result } => Self::get_rooms(conn, result), - Self::Join { room, time } => Self::join(conn, room, time), - Self::Delete { room } => Self::delete(conn, room), - Self::AddMsg { - room, - msg, - prev_msg, - own_user_id, - } => Self::add_msg(conn, room, *msg, prev_msg, own_user_id), - Self::AddMsgs { - room, - msgs, - next_msg, - own_user_id, - } => Self::add_msgs(conn, room, msgs, next_msg, own_user_id), - Self::GetLastSpan { room, result } => Self::get_last_span(conn, room, result), - Self::GetPath { room, id, result } => Self::get_path(conn, room, id, result), - Self::GetTree { room, root, result } => Self::get_tree(conn, room, root, result), - Self::GetFirstTreeId { room, result } => Self::get_first_tree_id(conn, room, result), - Self::GetLastTreeId { room, result } => Self::get_last_tree_id(conn, room, result), - Self::GetPrevTreeId { room, root, result } => { - Self::get_prev_tree_id(conn, room, root, result) - } - Self::GetNextTreeId { room, root, result } => { - Self::get_next_tree_id(conn, room, root, result) - } - Self::GetOldestMsgId { room, result } => Self::get_oldest_msg_id(conn, room, result), - Self::GetNewestMsgId { room, result } => Self::get_newest_msg_id(conn, room, result), - Self::GetOlderMsgId { room, id, result } => { - Self::get_older_msg_id(conn, room, id, result) - } - Self::GetNewerMsgId { room, id, result } => { - Self::get_newer_msg_id(conn, room, id, result) - } - Self::GetOldestUnseenMsgId { room, result } => { - Self::get_oldest_unseen_msg_id(conn, room, result) - } - Self::GetNewestUnseenMsgId { room, result } => { - Self::get_newest_unseen_msg_id(conn, room, result) - } - Self::GetOlderUnseenMsgId { room, id, result } => { - Self::get_older_unseen_msg_id(conn, room, id, result) - } - Self::GetNewerUnseenMsgId { room, id, result } => { - Self::get_newer_unseen_msg_id(conn, room, id, result) - } - Self::GetUnseenMsgsCount { room, result } => { - Self::get_unseen_msgs_count(conn, room, result) - } - Self::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), - Self::SetOlderSeen { room, id, seen } => Self::set_older_seen(conn, room, id, seen), - Self::GetChunkAtOffset { - room, - amount, - offset, - result, - } => Self::get_chunk_at_offset(conn, room, amount, offset, result), - }; - if let Err(e) = result { - // If an error occurs here, the rest of the UI will likely panic and - // crash soon. By printing this to stderr instead of logging it, we - // can filter it out and read it later. - // TODO Better vault error handling - eprintln!("{e}"); - } - } - - fn get_cookies( - conn: &mut Connection, - result: oneshot::Sender<CookieJar>, - ) -> rusqlite::Result<()> { - let cookies = conn - .prepare( - " - SELECT cookie - FROM euph_cookies - ", - )? - .query_map([], |row| { - let cookie_str: String = row.get(0)?; - Ok(Cookie::from_str(&cookie_str).expect("cookie in db is valid")) - })? - .collect::<rusqlite::Result<Vec<_>>>()?; - - let mut cookie_jar = CookieJar::new(); - for cookie in cookies { - cookie_jar.add_original(cookie); - } - - let _ = result.send(cookie_jar); - Ok(()) - } - - fn set_cookies(conn: &mut Connection, cookies: CookieJar) -> rusqlite::Result<()> { - 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")?; - - let mut insert_cookie = tx.prepare( - " - INSERT INTO euph_cookies (cookie) - VALUES (?) - ", - )?; - for cookie in cookies.iter() { - insert_cookie.execute([format!("{cookie}")])?; - } - drop(insert_cookie); - - tx.commit()?; - Ok(()) - } - - fn get_rooms( - conn: &mut Connection, - result: oneshot::Sender<Vec<String>>, - ) -> rusqlite::Result<()> { - let rooms = conn - .prepare( - " - SELECT room - FROM euph_rooms - ", - )? - .query_map([], |row| row.get(0))? - .collect::<rusqlite::Result<_>>()?; - let _ = result.send(rooms); - Ok(()) - } - - fn join(conn: &mut Connection, room: String, time: Time) -> rusqlite::Result<()> { - conn.execute( - " - INSERT INTO euph_rooms (room, first_joined, last_joined) - VALUES (:room, :time, :time) - ON CONFLICT (room) DO UPDATE - SET last_joined = :time - ", - named_params! {":room": room, ":time": WTime(time)}, - )?; - Ok(()) - } - - fn delete(conn: &mut Connection, room: String) -> rusqlite::Result<()> { - let tx = conn.transaction()?; - - tx.execute( - " - DELETE FROM euph_rooms - WHERE room = ? - ", - [&room], - )?; - - tx.commit()?; - Ok(()) - } - - fn insert_msgs( - tx: &Transaction<'_>, - room: &str, - 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, - 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, - :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 - AND :time < first_joined - )) - ) - ON CONFLICT (room, id) DO UPDATE - SET - room = :room, - id = :id, - parent = :parent, - previous_edit_id = :previous_edit_id, - time = :time, - content = :content, - encryption_key_id = :encryption_key_id, - edited = :edited, - deleted = :deleted, - truncated = :truncated, - - user_id = :user_id, - name = :name, - server_id = :server_id, - server_era = :server_era, - session_id = :session_id, - is_staff = :is_staff, - is_manager = :is_manager, - client_address = :client_address, - real_client_address = :real_client_address - " - )?; - - let own_user_id = own_user_id.as_ref().map(|u| &u.0); - for msg in msgs { - insert_msg.execute(named_params! { - ":room": room, - ":id": WSnowflake(msg.id), - ":parent": msg.parent.map(WSnowflake), - ":previous_edit_id": msg.previous_edit_id.map(WSnowflake), - ":time": WTime(msg.time), - ":content": msg.content, - ":encryption_key_id": msg.encryption_key_id, - ":edited": msg.edited.map(WTime), - ":deleted": msg.deleted.map(WTime), - ":truncated": msg.truncated, - ":user_id": msg.sender.id.0, - ":name": msg.sender.name, - ":server_id": msg.sender.server_id, - ":server_era": msg.sender.server_era, - ":session_id": msg.sender.session_id, - ":is_staff": msg.sender.is_staff, - ":is_manager": msg.sender.is_manager, - ":client_address": msg.sender.client_address, - ":real_client_address": msg.sender.real_client_address, - ":own_user_id": own_user_id, // May be NULL - })?; - } - - Ok(()) - } - - fn add_span( - tx: &Transaction<'_>, - room: &str, - start: Option<Snowflake>, - end: Option<Snowflake>, - ) -> rusqlite::Result<()> { - // Retrieve all spans for the room - let mut spans = tx - .prepare( - " - SELECT start, end - FROM euph_spans - WHERE room = ? - ", - )? - .query_map([room], |row| { - let start = row.get::<_, Option<WSnowflake>>(0)?.map(|s| s.0); - let end = row.get::<_, Option<WSnowflake>>(1)?.map(|s| s.0); - Ok((start, end)) - })? - .collect::<Result<Vec<_>, _>>()?; - - // Add new span and sort spans lexicographically - spans.push((start, end)); - spans.sort_unstable(); - - // Combine overlapping spans (including newly added span) - let mut cur_span: Option<(Option<Snowflake>, Option<Snowflake>)> = None; - let mut result = vec![]; - for mut span in spans { - if let Some(cur_span) = &mut cur_span { - if span.0 <= cur_span.1 { - // Since spans are sorted lexicographically, we know that - // cur_span.0 <= span.0, which means that span starts inside - // of cur_span. - cur_span.1 = cur_span.1.max(span.1); - } else { - // Since span doesn't overlap cur_span, we know that no - // later span will overlap cur_span either. The size of - // cur_span is thus final. - mem::swap(cur_span, &mut span); - result.push(span); - } - } else { - cur_span = Some(span); - } - } - if let Some(cur_span) = cur_span { - result.push(cur_span); - } - - // Delete all spans for the room - tx.execute( - " - DELETE FROM euph_spans - WHERE room = ? - ", - [room], - )?; - - // Re-insert combined spans for the room - let mut stmt = tx.prepare( - " - INSERT INTO euph_spans (room, start, end) - VALUES (?, ?, ?) - ", - )?; - for (start, end) in result { - stmt.execute(params![room, start.map(WSnowflake), end.map(WSnowflake)])?; - } - - Ok(()) - } - - fn add_msg( - conn: &mut Connection, - room: String, - msg: Message, - prev_msg: Option<Snowflake>, - own_user_id: Option<UserId>, - ) -> rusqlite::Result<()> { - let tx = conn.transaction()?; - - let end = msg.id; - Self::insert_msgs(&tx, &room, &own_user_id, vec![msg])?; - Self::add_span(&tx, &room, prev_msg, Some(end))?; - - tx.commit()?; - Ok(()) - } - - fn add_msgs( - conn: &mut Connection, - room: String, - msgs: Vec<Message>, - next_msg_id: Option<Snowflake>, - own_user_id: Option<UserId>, - ) -> rusqlite::Result<()> { - let tx = conn.transaction()?; - - if msgs.is_empty() { - Self::add_span(&tx, &room, None, next_msg_id)?; - } else { - let first_msg_id = msgs.first().unwrap().id; - let last_msg_id = msgs.last().unwrap().id; - - Self::insert_msgs(&tx, &room, &own_user_id, msgs)?; - - let end = next_msg_id.unwrap_or(last_msg_id); - Self::add_span(&tx, &room, Some(first_msg_id), Some(end))?; - } - - tx.commit()?; - Ok(()) - } - - fn get_last_span( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<(Option<Snowflake>, Option<Snowflake>)>>, - ) -> rusqlite::Result<()> { - let span = conn - .prepare( - " - SELECT start, end - FROM euph_spans - WHERE room = ? - ORDER BY start DESC - LIMIT 1 - ", - )? - .query_row([room], |row| { - Ok(( - row.get::<_, Option<WSnowflake>>(0)?.map(|s| s.0), - row.get::<_, Option<WSnowflake>>(1)?.map(|s| s.0), - )) - }) - .optional()?; - let _ = result.send(span); - Ok(()) - } - - fn get_path( - conn: &Connection, - room: String, - id: Snowflake, - result: oneshot::Sender<Path<Snowflake>>, - ) -> rusqlite::Result<()> { - let path = conn - .prepare( - " - WITH RECURSIVE - path (room, id) AS ( - VALUES (?, ?) - UNION - SELECT room, parent - FROM euph_msgs - JOIN path USING (room, id) - ) - SELECT id - FROM path - WHERE id IS NOT NULL - ORDER BY id ASC - ", - )? - .query_map(params![room, WSnowflake(id)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - })? - .collect::<rusqlite::Result<_>>()?; - let path = Path::new(path); - let _ = result.send(path); - Ok(()) - } - - fn get_tree( - conn: &Connection, - room: String, - root: Snowflake, - result: oneshot::Sender<Tree<SmallMessage>>, - ) -> rusqlite::Result<()> { - let msgs = conn - .prepare( - " - WITH RECURSIVE - tree (room, id) AS ( - VALUES (?, ?) - UNION - SELECT euph_msgs.room, euph_msgs.id - FROM euph_msgs - JOIN tree - ON tree.room = euph_msgs.room - AND tree.id = euph_msgs.parent - ) - SELECT id, parent, time, name, content, seen - FROM euph_msgs - JOIN tree USING (room, id) - ORDER BY id ASC - ", - )? - .query_map(params![room, WSnowflake(root)], |row| { - Ok(SmallMessage { - id: row.get::<_, WSnowflake>(0)?.0, - parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| s.0), - time: row.get::<_, WTime>(2)?.0, - nick: row.get(3)?, - content: row.get(4)?, - seen: row.get(5)?, - }) - })? - .collect::<rusqlite::Result<_>>()?; - let tree = Tree::new(root, msgs); - let _ = result.send(tree); - Ok(()) - } - - fn get_first_tree_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_trees - WHERE room = ? - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_last_tree_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_trees - WHERE room = ? - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_prev_tree_id( - conn: &Connection, - room: String, - root: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_trees - WHERE room = ? - AND id < ? - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(root)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_next_tree_id( - conn: &Connection, - room: String, - root: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_trees - WHERE room = ? - AND id > ? - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(root)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_oldest_msg_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_newest_msg_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_older_msg_id( - conn: &Connection, - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND id < ? - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(id)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_newer_msg_id( - conn: &Connection, - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND id > ? - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(id)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_oldest_unseen_msg_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND NOT seen - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_newest_unseen_msg_id( - conn: &Connection, - room: String, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND NOT seen - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row([room], |row| row.get::<_, WSnowflake>(0).map(|s| s.0)) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_older_unseen_msg_id( - conn: &Connection, - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND NOT seen - AND id < ? - ORDER BY id DESC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(id)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_newer_unseen_msg_id( - conn: &Connection, - room: String, - id: Snowflake, - result: oneshot::Sender<Option<Snowflake>>, - ) -> rusqlite::Result<()> { - let tree = conn - .prepare( - " - SELECT id - FROM euph_msgs - WHERE room = ? - AND NOT seen - AND id > ? - ORDER BY id ASC - LIMIT 1 - ", - )? - .query_row(params![room, WSnowflake(id)], |row| { - row.get::<_, WSnowflake>(0).map(|s| s.0) - }) - .optional()?; - let _ = result.send(tree); - Ok(()) - } - - fn get_unseen_msgs_count( - conn: &Connection, - room: String, - result: oneshot::Sender<usize>, - ) -> rusqlite::Result<()> { - let amount = conn - .prepare( - " - SELECT amount - FROM euph_unseen_counts - WHERE room = ? - ", - )? - .query_row(params![room], |row| row.get(0)) - .optional()? - .unwrap_or(0); - let _ = result.send(amount); - Ok(()) - } - - fn set_seen( - conn: &Connection, - room: String, - id: Snowflake, - seen: bool, - ) -> rusqlite::Result<()> { - conn.execute( - " - UPDATE euph_msgs - SET seen = :seen - WHERE room = :room - AND id = :id - ", - named_params! { ":room": room, ":id": WSnowflake(id), ":seen": seen }, - )?; - Ok(()) - } - - fn set_older_seen( - conn: &Connection, - room: String, - id: Snowflake, - seen: bool, - ) -> rusqlite::Result<()> { - conn.execute( - " - UPDATE euph_msgs - SET seen = :seen - WHERE room = :room - AND id <= :id - AND seen != :seen - ", - named_params! { ":room": room, ":id": WSnowflake(id), ":seen": seen }, - )?; - Ok(()) - } - - fn get_chunk_at_offset( - conn: &Connection, - room: String, - amount: usize, - offset: usize, - result: oneshot::Sender<Vec<Message>>, - ) -> rusqlite::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 ? - ", - )?; - - let messages = query - .query_map(params![room, amount, offset], |row| { - Ok(Message { - id: row.get::<_, WSnowflake>(0)?.0, - parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| 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: 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<_>>()?; - let _ = result.send(messages); - Ok(()) - } -} diff --git a/src/vault/migrate.rs b/src/vault/migrate.rs deleted file mode 100644 index cbb4f6b..0000000 --- a/src/vault/migrate.rs +++ /dev/null @@ -1,94 +0,0 @@ -use rusqlite::{Connection, Transaction}; - -pub fn migrate(conn: &mut Connection) -> rusqlite::Result<()> { - let mut tx = conn.transaction()?; - - let user_version: usize = - tx.query_row("SELECT * FROM pragma_user_version", [], |r| r.get(0))?; - - let total = MIGRATIONS.len(); - assert!(user_version <= total, "malformed database schema"); - for (i, migration) in MIGRATIONS.iter().enumerate().skip(user_version) { - println!("Migrating vault from {} to {} (out of {})", i, i + 1, total); - migration(&mut tx)?; - } - - tx.pragma_update(None, "user_version", total)?; - tx.commit() -} - -const MIGRATIONS: [fn(&mut Transaction<'_>) -> rusqlite::Result<()>; 2] = [m1, m2]; - -fn m1(tx: &mut Transaction<'_>) -> rusqlite::Result<()> { - 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<'_>) -> rusqlite::Result<()> { - 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); - ", - ) -}