diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 4660d0f..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,75 +0,0 @@ -# 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 deleted file mode 100644 index 4e428aa..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 3f9ce8c..9b81f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,254 +4,19 @@ 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. 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 +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 ## 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 @@ -259,12 +24,10 @@ 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 @@ -272,18 +35,15 @@ 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 @@ -296,12 +56,10 @@ 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 deleted file mode 100644 index 82a7242..0000000 --- a/CONFIG.md +++ /dev/null @@ -1,711 +0,0 @@ -# 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 2f45a5a..4a7be31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,226 +1,105 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -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" +version = 3 [[package]] name = "ahash" -version = "0.8.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" dependencies = [ - "cfg-if", + "getrandom", "once_cell", "version_check", - "zerocopy 0.7.35", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" dependencies = [ "memchr", ] -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "1485d4d2cc45e7b201ee3767015c96faa5904387c9d87c6efdd0fb511f12d305" [[package]] name = "async-trait" -version = "0.1.87" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" +checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" -version = "1.4.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] -name = "aws-lc-rs" -version = "1.12.6" +name = "base64" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabb68eb3a7aa08b46fddfd59a3d55c978243557a90ab804769f7e20e67d2b01" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bbe221bbf523b625a4dd8585c7f38166e31167ec2ca98051dbcb4c3b6e825d2" -dependencies = [ - "bindgen", - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn", - "which", -] +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] name = "bitflags" -version = "2.9.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" dependencies = [ "generic-array", ] [[package]] -name = "bytes" -version = "1.10.1" +name = "bumpalo" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" [[package]] -name = "caseless" -version = "0.2.2" +name = "byteorder" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" [[package]] name = "cc" -version = "1.2.16" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" [[package]] name = "cfg-if" @@ -228,46 +107,31 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" -version = "4.5.32" +version = "3.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" +checksum = "29e724a68d9319343bb3328c9cc2dfde263f4b3142ee1059a9980580171c954b" dependencies = [ - "clap_builder", + "atty", + "bitflags", "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 = "4.5.32" +version = "3.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "13547f7012c01ab4a0e8f8967730ada8f9fdf419e8b6c792788f39cf4e46eefa" dependencies = [ "heck", + "proc-macro-error", "proc-macro2", "quote", "syn", @@ -275,30 +139,18 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "cmake" -version = "0.1.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" dependencies = [ - "cc", + "os_str_bytes", ] -[[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - [[package]] name = "cookie" -version = "0.18.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" dependencies = [ "time", "version_check", @@ -306,9 +158,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" dependencies = [ "core-foundation-sys", "libc", @@ -316,92 +168,55 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.7" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" [[package]] name = "cove" -version = "0.9.3" +version = "0.3.0" 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.17" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "dc948ebb96241bb40ab73effeb80d9f93afaad49359d159a5e61be51619fe813" dependencies = [ "libc", ] [[package]] name = "crossterm" -version = "0.28.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" dependencies = [ "bitflags", "crossterm_winapi", + "libc", "mio", "parking_lot", - "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -409,9 +224,9 @@ dependencies = [ [[package]] name = "crossterm_winapi" -version = "0.9.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" dependencies = [ "winapi", ] @@ -426,26 +241,11 @@ dependencies = [ "typenum", ] -[[package]] -name = "data-encoding" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" dependencies = [ "block-buffer", "crypto-common", @@ -453,36 +253,29 @@ dependencies = [ [[package]] name = "directories" -version = "6.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.5.0" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" dependencies = [ "libc", - "option-ext", "redox_users", - "windows-sys 0.59.0", + "winapi", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "edit" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f364860e764787163c8c8f58231003839be31276e821e2ad2092ddf496b1aa09" +checksum = "c562aa71f7bc691fde4c6bf5f93ae5a5298b617c2eb44c76c87832299a17fbb4" dependencies = [ "tempfile", "which", @@ -490,51 +283,29 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" [[package]] name = "euphoxide" -version = "0.6.1" -source = "git+https://github.com/Garmelon/euphoxide.git?tag=v0.6.1#7a292c429ad44aa6aa52fc381e3168841d6303b0" +version = "0.1.0" +source = "git+https://github.com/Garmelon/euphoxide.git?rev=01a442c1f0695bd11b8f54db406b3a3a03d61983#01a442c1f0695bd11b8f54db406b3a3a03d61983" dependencies = [ - "async-trait", - "caseless", - "clap", - "cookie", - "futures-util", - "jiff", - "log", + "futures", "serde", "serde_json", + "thiserror", + "time", "tokio", - "tokio-stream", "tokio-tungstenite", - "unicode-normalization", ] [[package]] name = "fallible-iterator" -version = "0.3.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" [[package]] name = "fallible-streaming-iterator" @@ -544,9 +315,12 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] [[package]] name = "fnv" @@ -555,38 +329,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "fs_extra" -version = "1.3.0" +name = "form_urlencoded" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] [[package]] -name = "futures-core" -version = "0.3.31" +name = "futures" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "ab30e97ab6aacfe635fad58f22c2bb06c8b685f7421eb1e064a729e2a5f481fa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] -name = "futures-sink" -version = "0.3.31" +name = "futures-channel" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "2bfc52cbddcfd745bf1740338492bb0bd83d76c67b445f91c5fb29fae29ecaa1" dependencies = [ "futures-core", "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.23" +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" + +[[package]] +name = "futures-sink" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0bae1fe9752cf7fd9b0064c674ae63f97b37bc714d745cbde0afb7ec4e6765" + +[[package]] +name = "futures-task" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842fc63b931f4056a24d59de13fb1272134ce261816e063e634ad0c15cdc5306" + +[[package]] +name = "futures-util" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0828a5471e340229c11c77ca80017937ce3c58cb788a17e5f1c2d5c485a9577" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -594,9 +405,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", @@ -604,83 +415,53 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ "ahash", ] -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - [[package]] name = "hashlink" -version = "0.9.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" dependencies = [ - "hashbrown 0.14.5", + "hashbrown", ] [[package]] name = "heck" -version = "0.5.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" [[package]] -name = "home" -version = "0.5.11" +name = "hermit-abi" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" dependencies = [ - "windows-sys 0.59.0", + "libc", ] [[package]] name = "http" -version = "1.3.1" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", @@ -689,185 +470,83 @@ dependencies = [ [[package]] name = "httparse" -version = "1.10.1" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +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", +] [[package]] name = "indexmap" -version = "2.8.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ - "equivalent", - "hashbrown 0.15.2", + "autocfg", + "hashbrown", ] [[package]] -name = "is-docker" -version = "0.2.0" +name = "instant" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" dependencies = [ - "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", + "cfg-if", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" [[package]] -name = "jiff" -version = "0.2.4" +name = "js-sys" +version = "0.3.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" dependencies = [ - "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", + "wasm-bindgen", ] [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" - -[[package]] -name = "libloading" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" -dependencies = [ - "cfg-if", - "windows-targets", -] - -[[package]] -name = "libredox" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" -dependencies = [ - "bitflags", - "libc", -] +checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "9f0455f2c1bc9a7caa792907026e469c1d91761fb0ea37cbb16427c77280cf35" dependencies = [ "cc", "pkg-config", "vcpkg", ] -[[package]] -name = "linkify" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" dependencies = [ "autocfg", "scopeguard", @@ -875,120 +554,79 @@ dependencies = [ [[package]] name = "log" -version = "0.4.26" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "memchr" -version = "2.7.4" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "mio" -version = "1.0.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys", ] [[package]] -name = "nom" -version = "7.1.3" +name = "num_cpus" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" dependencies = [ - "memchr", - "minimal-lexical", + "hermit-abi", + "libc", ] [[package]] -name = "num-conv" -version = "0.1.0" +name = "num_threads" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", + "libc", ] [[package]] name = "once_cell" -version = "1.21.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] -name = "option-ext" -version = "0.2.0" +name = "os_str_bytes" +version = "6.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] +checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", "parking_lot_core", @@ -996,28 +634,28 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-sys", ] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "percent-encoding" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" [[package]] name = "pin-utils" @@ -1027,84 +665,74 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "portable-atomic" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" [[package]] name = "ppv-lite86" -version = "0.2.21" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ - "zerocopy 0.8.23", + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", ] [[package]] -name = "prettyplease" -version = "0.2.31" +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ "proc-macro2", - "syn", + "quote", + "version_check", ] [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.9.0" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", "rand_chacha", "rand_core", - "zerocopy 0.8.23", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -1112,50 +740,38 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.3.1", + "getrandom", ] [[package]] name = "redox_syscall" -version = "0.5.10" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" dependencies = [ "bitflags", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" dependencies = [ - "getrandom 0.2.15", - "libredox", + "getrandom", + "redox_syscall", "thiserror", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -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" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" dependencies = [ "aho-corasick", "memchr", @@ -1164,29 +780,39 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.6.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +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", +] [[package]] name = "ring" -version = "0.17.14" +version = "0.16.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" dependencies = [ "cc", - "cfg-if", - "getrandom 0.2.15", "libc", + "once_cell", + "spin", "untrusted", - "windows-sys 0.52.0", + "web-sys", + "winapi", ] [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" dependencies = [ "bitflags", "fallible-iterator", @@ -1197,115 +823,76 @@ dependencies = [ "time", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" +checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" dependencies = [ - "aws-lc-rs", "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "ring", + "sct", + "webpki", ] [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" dependencies = [ "openssl-probe", - "rustls-pki-types", + "rustls-pemfile", "schannel", "security-framework", ] [[package]] -name = "rustls-pki-types" -version = "1.11.0" +name = "rustls-pemfile" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" - -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55" dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", + "base64", ] [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" dependencies = [ - "windows-sys 0.59.0", + "lazy_static", + "windows-sys", ] [[package]] name = "scopeguard" -version = "1.2.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] [[package]] name = "security-framework" -version = "3.2.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" dependencies = [ "bitflags", "core-foundation", @@ -1316,9 +903,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" dependencies = [ "core-foundation-sys", "libc", @@ -1326,87 +913,51 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "serde_either" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "689643f4e7826ffcd227d2cc166bfdf5869750191ffe9fd593531e6ba351f2fb" -dependencies = [ - "serde", - "serde-value", -] - [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" dependencies = [ "itoa", - "memchr", "ryu", "serde", ] [[package]] -name = "serde_spanned" -version = "0.6.8" +name = "sha-1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" -dependencies = [ - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" dependencies = [ "cfg-if", "cpufeatures", "digest", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" dependencies = [ "libc", "signal-hook-registry", @@ -1414,9 +965,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" dependencies = [ "libc", "mio", @@ -1425,55 +976,55 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" dependencies = [ "libc", ] [[package]] name = "slab" -version = "0.4.9" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" dependencies = [ "autocfg", ] [[package]] name = "smallvec" -version = "1.14.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" [[package]] name = "socket2" -version = "0.5.8" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" dependencies = [ "libc", - "windows-sys 0.52.0", + "winapi", ] [[package]] -name = "strsim" -version = "0.11.1" +name = "spin" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] -name = "subtle" -version = "2.6.1" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "2.0.100" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" dependencies = [ "proc-macro2", "quote", @@ -1482,31 +1033,47 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" dependencies = [ + "cfg-if", "fastrand", - "getrandom 0.3.1", - "once_cell", - "rustix 1.0.2", - "windows-sys 0.59.0", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", ] [[package]] -name = "thiserror" -version = "2.0.12" +name = "termcolor" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" dependencies = [ "proc-macro2", "quote", @@ -1515,73 +1082,64 @@ dependencies = [ [[package]] name = "time" -version = "0.3.39" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" +checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45" dependencies = [ - "deranged", "itoa", - "num-conv", - "powerfmt", + "libc", + "num_threads", "serde", - "time-core", "time-macros", ] -[[package]] -name = "time-core" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" - [[package]] name = "time-macros" -version = "0.2.20" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" -dependencies = [ - "num-conv", - "time-core", -] +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.44.1" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "7a8325f63a7d4774dd041e363b2409ed1c5cbbd0f867795e661df066b2b0a581" dependencies = [ - "backtrace", + "autocfg", "bytes", "libc", + "memchr", "mio", + "num_cpus", + "once_cell", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "winapi", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" dependencies = [ "proc-macro2", "quote", @@ -1590,81 +1148,36 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ "rustls", "tokio", -] - -[[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", + "webpki", ] [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "f714dd15bead90401d77e04243611caec13726c2408afd5b31901dfcdcb3b181" dependencies = [ "futures-util", "log", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tungstenite", -] - -[[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", + "webpki", ] [[package]] name = "toss" -version = "0.3.4" -source = "git+https://github.com/Garmelon/toss.git?tag=v0.3.4#57aa8c59308f6f0aa82bde415a42b56c3d6f7c4d" +version = "0.1.0" +source = "git+https://github.com/Garmelon/toss.git?rev=45ece466c235cce6e998bbd404f915cad3628c8c#45ece466c235cce6e998bbd404f915cad3628c8c" dependencies = [ - "async-trait", "crossterm", "unicode-linebreak", "unicode-segmentation", @@ -1673,67 +1186,90 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "e27992fd6a8c29ee7eef28fc78349aa244134e10ad447ce3b9f0ac0ed0fa4ce0" dependencies = [ + "base64", + "byteorder", "bytes", - "data-encoding", "http", "httparse", "log", "rand", "rustls", - "rustls-pki-types", - "sha1", + "sha-1", "thiserror", + "url", "utf-8", + "webpki", ] [[package]] name = "typenum" -version = "1.18.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" [[package]] name = "unicode-linebreak" -version = "0.1.5" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +dependencies = [ + "regex", +] [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] name = "untrusted" -version = "0.9.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +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", +] [[package]] name = "utf-8" @@ -1741,21 +1277,6 @@ 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" @@ -1764,9 +1285,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" -version = "0.9.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "wasi" @@ -1775,24 +1296,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasi" -version = "0.13.3+wasi-0.2.2" +name = "wasm-bindgen" +version = "0.2.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" dependencies = [ - "wit-bindgen-rt", + "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", ] [[package]] name = "which" -version = "4.4.2" +version = "4.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae" dependencies = [ "either", - "home", - "once_cell", - "rustix 0.38.44", + "lazy_static", + "libc", ] [[package]] @@ -1811,6 +1396,15 @@ 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" @@ -1819,146 +1413,43 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" 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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -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" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" diff --git a/Cargo.toml b/Cargo.toml index 33f245f..aebfd23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,72 +1,43 @@ -[workspace] -resolver = "3" -members = ["cove", "cove-*"] +[package] +name = "cove" +version = "0.3.0" +edition = "2021" -[workspace.package] -version = "0.9.3" -edition = "2024" +[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.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.time] +version = "0.3.13" +features = ["macros", "formatting", "parsing", "serde"] -[workspace.dependencies.euphoxide] +[dependencies.tokio-tungstenite] +version = "0.17.2" +features = ["rustls-tls-native-roots"] + +[dependencies.euphoxide] git = "https://github.com/Garmelon/euphoxide.git" -tag = "v0.6.1" -features = ["bot"] +rev = "01a442c1f0695bd11b8f54db406b3a3a03d61983" -[workspace.dependencies.toss] +# [patch."https://github.com/Garmelon/euphoxide.git"] +# euphoxide = { path = "../euphoxide/" } + +[dependencies.toss] git = "https://github.com/Garmelon/toss.git" -tag = "v0.3.4" +rev = "45ece466c235cce6e998bbd404f915cad3628c8c" -[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 +# [patch."https://github.com/Garmelon/toss.git"] +# toss = { path = "../toss/" } diff --git a/README.md b/README.md index 22fef83..7cdfd17 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,62 @@ # cove -Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded +Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded real-time chat platform. ![A very meta screenshot](screenshot.png) -It runs on Linux, Windows, and macOS. +It runs on Linux, Windows and macOS. -## Installing cove +## Manual installation -Download a binary of your choice from the -[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest). +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. -## Using cove +### 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 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 @@ -22,12 +66,3 @@ 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 deleted file mode 100644 index e69de29..0000000 diff --git a/cove-config/Cargo.toml b/cove-config/Cargo.toml deleted file mode 100644 index 9102bfd..0000000 --- a/cove-config/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[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 deleted file mode 100644 index 35f6074..0000000 --- a/cove-config/src/doc.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! 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 deleted file mode 100644 index 5ed0fb5..0000000 --- a/cove-config/src/euph.rs +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 47c171c..0000000 --- a/cove-config/src/keys.rs +++ /dev/null @@ -1,427 +0,0 @@ -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 deleted file mode 100644 index 0cb6cc7..0000000 --- a/cove-config/src/lib.rs +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 5005be2..0000000 --- a/cove-input/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[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 deleted file mode 100644 index 8d2fdf1..0000000 --- a/cove-input/src/keys.rs +++ /dev/null @@ -1,252 +0,0 @@ -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 deleted file mode 100644 index f6b2e92..0000000 --- a/cove-input/src/lib.rs +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 6c01b7d..0000000 --- a/cove-macro/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[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 deleted file mode 100644 index afec84d..0000000 --- a/cove-macro/src/document.rs +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 832bfd3..0000000 --- a/cove-macro/src/key_group.rs +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index c655f2a..0000000 --- a/cove-macro/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index d73b7ca..0000000 --- a/cove-macro/src/util.rs +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 3a60a5d..0000000 --- a/cove/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[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/cove/src/euph/highlight.rs b/cove/src/euph/highlight.rs deleted file mode 100644 index 1c9abd0..0000000 --- a/cove/src/euph/highlight.rs +++ /dev/null @@ -1,211 +0,0 @@ -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 deleted file mode 100644 index a4e29cf..0000000 --- a/cove/src/euph/room.rs +++ /dev/null @@ -1,316 +0,0 @@ -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 deleted file mode 100644 index 5db1790..0000000 --- a/cove/src/euph/small_message.rs +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index ea1782a..0000000 --- a/cove/src/euph/util.rs +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 80db7b6..0000000 --- a/cove/src/export.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! 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 deleted file mode 100644 index 9c16e46..0000000 --- a/cove/src/export/json.rs +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 2ca6687..0000000 --- a/cove/src/export/text.rs +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index 940e1a9..0000000 --- a/cove/src/logger.rs +++ /dev/null @@ -1,245 +0,0 @@ -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 deleted file mode 100644 index bb5834c..0000000 --- a/cove/src/macros.rs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 51bc502..0000000 --- a/cove/src/main.rs +++ /dev/null @@ -1,253 +0,0 @@ -// 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/cove/src/ui.rs b/cove/src/ui.rs deleted file mode 100644 index 5ebd540..0000000 --- a/cove/src/ui.rs +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index 1116935..0000000 --- a/cove/src/ui/chat.rs +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index 8360e83..0000000 --- a/cove/src/ui/chat/blocks.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! 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 deleted file mode 100644 index 87bd8fc..0000000 --- a/cove/src/ui/chat/cursor.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! 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 deleted file mode 100644 index a619e7c..0000000 --- a/cove/src/ui/chat/renderer.rs +++ /dev/null @@ -1,328 +0,0 @@ -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 deleted file mode 100644 index d9905fc..0000000 --- a/cove/src/ui/chat/tree.rs +++ /dev/null @@ -1,480 +0,0 @@ -//! 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 deleted file mode 100644 index 225191b..0000000 --- a/cove/src/ui/chat/tree/renderer.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! 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 deleted file mode 100644 index a8a1305..0000000 --- a/cove/src/ui/chat/tree/scroll.rs +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index dd7fa89..0000000 --- a/cove/src/ui/chat/tree/widgets.rs +++ /dev/null @@ -1,214 +0,0 @@ -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 deleted file mode 100644 index e0e2fe5..0000000 --- a/cove/src/ui/chat/widgets.rs +++ /dev/null @@ -1,117 +0,0 @@ -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/cove/src/ui/euph/account.rs b/cove/src/ui/euph/account.rs deleted file mode 100644 index 7aa776f..0000000 --- a/cove/src/ui/euph/account.rs +++ /dev/null @@ -1,195 +0,0 @@ -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 deleted file mode 100644 index 15f8fe1..0000000 --- a/cove/src/ui/euph/auth.rs +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index b3c4e0e..0000000 --- a/cove/src/ui/euph/inspect.rs +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index c64830d..0000000 --- a/cove/src/ui/euph/links.rs +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 707e992..0000000 --- a/cove/src/ui/euph/nick.rs +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 8fbdb7b..0000000 --- a/cove/src/ui/euph/nick_list.rs +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index c434fb6..0000000 --- a/cove/src/ui/euph/popup.rs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 7e8ff99..0000000 --- a/cove/src/ui/euph/room.rs +++ /dev/null @@ -1,708 +0,0 @@ -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 deleted file mode 100644 index daedc16..0000000 --- a/cove/src/ui/key_bindings.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! 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 deleted file mode 100644 index c3d6a40..0000000 --- a/cove/src/ui/rooms.rs +++ /dev/null @@ -1,658 +0,0 @@ -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 deleted file mode 100644 index 83a359e..0000000 --- a/cove/src/ui/rooms/connect.rs +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index baa96b1..0000000 --- a/cove/src/ui/rooms/delete.rs +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index b358588..0000000 --- a/cove/src/ui/util.rs +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index c00d26e..0000000 --- a/cove/src/ui/widgets.rs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 3d7c6c8..0000000 --- a/cove/src/ui/widgets/list.rs +++ /dev/null @@ -1,361 +0,0 @@ -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 deleted file mode 100644 index 559e283..0000000 --- a/cove/src/ui/widgets/popup.rs +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index c6a572c..0000000 --- a/cove/src/util.rs +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 05bd1a5..0000000 --- a/cove/src/vault.rs +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 4a4109e..0000000 --- a/cove/src/vault/euph.rs +++ /dev/null @@ -1,1243 +0,0 @@ -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 deleted file mode 100644 index cc85c2c..0000000 --- a/cove/src/vault/migrate.rs +++ /dev/null @@ -1,224 +0,0 @@ -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/cove/src/version.rs b/cove/src/version.rs deleted file mode 100644 index 2a4c731..0000000 --- a/cove/src/version.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub const NAME: &str = env!("CARGO_PKG_NAME"); -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/cove/src/euph.rs b/src/euph.rs similarity index 72% rename from cove/src/euph.rs rename to src/euph.rs index 77bf1db..ab93753 100644 --- a/cove/src/euph.rs +++ b/src/euph.rs @@ -1,9 +1,7 @@ -pub use highlight::*; -pub use room::*; -pub use small_message::*; -pub use util::*; - -mod highlight; mod room; mod small_message; mod util; + +pub use room::*; +pub use small_message::*; +pub use util::*; diff --git a/src/euph/room.rs b/src/euph/room.rs new file mode 100644 index 0000000..324196f --- /dev/null +++ b/src/euph/room.rs @@ -0,0 +1,496 @@ +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 new file mode 100644 index 0000000..4aa45c2 --- /dev/null +++ b/src/euph/small_message.rs @@ -0,0 +1,177 @@ +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 new file mode 100644 index 0000000..05eb467 --- /dev/null +++ b/src/euph/util.rs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..093ec26 --- /dev/null +++ b/src/export.rs @@ -0,0 +1,121 @@ +//! 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 new file mode 100644 index 0000000..fbec86b --- /dev/null +++ b/src/export/json.rs @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..236728c --- /dev/null +++ b/src/export/text.rs @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..eb4b10b --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,189 @@ +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 new file mode 100644 index 0000000..36372d7 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..0d8115a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,134 @@ +#![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/cove/src/store.rs b/src/store.rs similarity index 63% rename from cove/src/store.rs rename to src/store.rs index b7031c1..24b95d0 100644 --- a/cove/src/store.rs +++ b/src/store.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, fmt::Debug, hash::Hash, vec}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; +use std::vec; use async_trait::async_trait; @@ -8,10 +11,6 @@ pub trait Msg { fn parent(&self) -> Option<Self::Id>; fn seen(&self) -> bool; - fn nick_emoji(&self) -> Option<String> { - None - } - fn last_possible_id() -> Self::Id; } @@ -28,12 +27,12 @@ impl<I> Path<I> { self.0.iter().take(self.0.len() - 1) } - pub fn first(&self) -> &I { - self.0.first().expect("path is empty") + pub fn push(&mut self, segment: I) { + self.0.push(segment) } - pub fn into_first(self) -> I { - self.0.into_iter().next().expect("path is empty") + pub fn first(&self) -> &I { + self.0.first().expect("path is not empty") } } @@ -131,26 +130,23 @@ impl<M: Msg> Tree<M> { } } -#[allow(dead_code)] #[async_trait] pub trait MsgStore<M: Msg> { - type Error; - async fn path(&self, id: &M::Id) -> Result<Path<M::Id>, Self::Error>; - async fn msg(&self, id: &M::Id) -> Result<Option<M>, Self::Error>; - async fn tree(&self, root_id: &M::Id) -> Result<Tree<M>, Self::Error>; - async fn first_root_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn last_root_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn prev_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn next_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn oldest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn newest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn older_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn newer_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn oldest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn newest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>; - async fn older_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn newer_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>; - async fn unseen_msgs_count(&self) -> Result<usize, Self::Error>; - 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>; + async fn path(&self, id: &M::Id) -> Path<M::Id>; + async fn tree(&self, tree_id: &M::Id) -> Tree<M>; + async fn first_tree_id(&self) -> Option<M::Id>; + async fn last_tree_id(&self) -> Option<M::Id>; + async fn prev_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>; + async fn next_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>; + async fn oldest_msg_id(&self) -> Option<M::Id>; + async fn newest_msg_id(&self) -> Option<M::Id>; + async fn older_msg_id(&self, id: &M::Id) -> Option<M::Id>; + async fn newer_msg_id(&self, id: &M::Id) -> Option<M::Id>; + async fn oldest_unseen_msg_id(&self) -> Option<M::Id>; + async fn newest_unseen_msg_id(&self) -> Option<M::Id>; + async fn older_unseen_msg_id(&self, id: &M::Id) -> Option<M::Id>; + async fn newer_unseen_msg_id(&self, id: &M::Id) -> Option<M::Id>; + 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); } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..cc2035d --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,318 @@ +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 new file mode 100644 index 0000000..d4736de --- /dev/null +++ b/src/ui/chat.rs @@ -0,0 +1,147 @@ +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 new file mode 100644 index 0000000..1389d43 --- /dev/null +++ b/src/ui/chat/blocks.rs @@ -0,0 +1,170 @@ +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 new file mode 100644 index 0000000..4087094 --- /dev/null +++ b/src/ui/chat/tree.rs @@ -0,0 +1,424 @@ +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 new file mode 100644 index 0000000..aba8bc3 --- /dev/null +++ b/src/ui/chat/tree/cursor.rs @@ -0,0 +1,458 @@ +//! 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 new file mode 100644 index 0000000..7e41f00 --- /dev/null +++ b/src/ui/chat/tree/layout.rs @@ -0,0 +1,564 @@ +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 new file mode 100644 index 0000000..69b98ec --- /dev/null +++ b/src/ui/chat/tree/tree_blocks.rs @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..2ba3d14 --- /dev/null +++ b/src/ui/chat/tree/widgets.rs @@ -0,0 +1,162 @@ +// 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 new file mode 100644 index 0000000..d512102 --- /dev/null +++ b/src/ui/chat/tree/widgets/indent.rs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..8197afd --- /dev/null +++ b/src/ui/chat/tree/widgets/seen.rs @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..0976197 --- /dev/null +++ b/src/ui/chat/tree/widgets/time.rs @@ -0,0 +1,25 @@ +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/cove/src/ui/euph.rs b/src/ui/euph.rs similarity index 75% rename from cove/src/ui/euph.rs rename to src/ui/euph.rs index 9ca6d15..b18bd8b 100644 --- a/cove/src/ui/euph.rs +++ b/src/ui/euph.rs @@ -1,7 +1,5 @@ mod account; mod auth; -mod inspect; -mod links; mod nick; mod nick_list; mod popup; diff --git a/src/ui/euph/account.rs b/src/ui/euph/account.rs new file mode 100644 index 0000000..546ce2c --- /dev/null +++ b/src/ui/euph/account.rs @@ -0,0 +1,220 @@ +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 new file mode 100644 index 0000000..7767df0 --- /dev/null +++ b/src/ui/euph/auth.rs @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..513e0e4 --- /dev/null +++ b/src/ui/euph/nick.rs @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000..d54a58a --- /dev/null +++ b/src/ui/euph/nick_list.rs @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..878177a --- /dev/null +++ b/src/ui/euph/popup.rs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..78baa80 --- /dev/null +++ b/src/ui/euph/room.rs @@ -0,0 +1,509 @@ +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 new file mode 100644 index 0000000..2d1eb23 --- /dev/null +++ b/src/ui/input.rs @@ -0,0 +1,149 @@ +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 new file mode 100644 index 0000000..59d21bb --- /dev/null +++ b/src/ui/rooms.rs @@ -0,0 +1,379 @@ +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 new file mode 100644 index 0000000..e14e5d9 --- /dev/null +++ b/src/ui/util.rs @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..f9ebba1 --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1,37 @@ +// 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 new file mode 100644 index 0000000..4990bcf --- /dev/null +++ b/src/ui/widgets/background.rs @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..fd32a9c --- /dev/null +++ b/src/ui/widgets/border.rs @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..205c5c1 --- /dev/null +++ b/src/ui/widgets/cursor.rs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..ff4a183 --- /dev/null +++ b/src/ui/widgets/editor.rs @@ -0,0 +1,542 @@ +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 new file mode 100644 index 0000000..40ff3bf --- /dev/null +++ b/src/ui/widgets/empty.rs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..96f398c --- /dev/null +++ b/src/ui/widgets/float.rs @@ -0,0 +1,65 @@ +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 new file mode 100644 index 0000000..04d01c0 --- /dev/null +++ b/src/ui/widgets/join.rs @@ -0,0 +1,241 @@ +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 new file mode 100644 index 0000000..7c5e659 --- /dev/null +++ b/src/ui/widgets/layer.rs @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..ab175f0 --- /dev/null +++ b/src/ui/widgets/list.rs @@ -0,0 +1,381 @@ +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 new file mode 100644 index 0000000..74a7e29 --- /dev/null +++ b/src/ui/widgets/padding.rs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..96ce7c2 --- /dev/null +++ b/src/ui/widgets/popup.rs @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..15f5577 --- /dev/null +++ b/src/ui/widgets/resize.rs @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000..9fcc5df --- /dev/null +++ b/src/ui/widgets/rules.rs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..7cab2bb --- /dev/null +++ b/src/ui/widgets/text.rs @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..66b52c2 --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..119baee --- /dev/null +++ b/src/vault/euph.rs @@ -0,0 +1,1330 @@ +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 new file mode 100644 index 0000000..cbb4f6b --- /dev/null +++ b/src/vault/migrate.rs @@ -0,0 +1,94 @@ +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); + ", + ) +} diff --git a/cove/src/vault/prepare.rs b/src/vault/prepare.rs similarity index 63% rename from cove/src/vault/prepare.rs rename to src/vault/prepare.rs index 8bbcb2b..c990e26 100644 --- a/cove/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -1,32 +1,28 @@ 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 (domain, room, id) + PRIMARY KEY (room, id) ) STRICT; - INSERT INTO euph_trees (domain, room, id) - SELECT domain, room, id + INSERT INTO euph_trees (room, id) + SELECT room, id FROM euph_msgs WHERE parent IS NULL UNION - SELECT domain, room, parent + SELECT room, parent FROM euph_msgs WHERE parent IS NOT NULL AND NOT EXISTS( SELECT * FROM euph_msgs AS parents - WHERE parents.domain = euph_msgs.domain - AND parents.room = euph_msgs.room + WHERE parents.room = euph_msgs.room AND parents.id = euph_msgs.parent ); @@ -34,16 +30,15 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { AFTER DELETE ON main.euph_rooms BEGIN DELETE FROM euph_trees - WHERE domain = old.domain - AND room = old.room; + WHERE 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 (domain, room, id) - VALUES (new.domain, new.room, new.id); + INSERT OR IGNORE INTO euph_trees (room, id) + VALUES (new.room, new.id); END; CREATE TEMPORARY TRIGGER et_insert_msg_with_parent @@ -51,18 +46,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { WHEN new.parent IS NOT NULL BEGIN DELETE FROM euph_trees - WHERE domain = new.domain - AND room = new.room + WHERE room = new.room AND id = new.id; - INSERT OR IGNORE INTO euph_trees (domain, room, id) + INSERT OR IGNORE INTO euph_trees (room, id) SELECT * - FROM (VALUES (new.domain, new.room, new.parent)) + FROM (VALUES (new.room, new.parent)) WHERE NOT EXISTS( SELECT * FROM euph_msgs - WHERE domain = new.domain - AND room = new.room + WHERE room = new.room AND id = new.parent AND parent IS NOT NULL ); @@ -74,37 +67,35 @@ 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 (domain, room) + PRIMARY KEY (room) ) STRICT; -- There must be an entry for every existing room. - INSERT INTO euph_unseen_counts (domain, room, amount) - SELECT domain, room, 0 + INSERT INTO euph_unseen_counts (room, amount) + SELECT room, 0 FROM euph_rooms; - INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount) - SELECT domain, room, COUNT(*) + INSERT OR REPLACE INTO euph_unseen_counts (room, amount) + SELECT room, COUNT(*) FROM euph_msgs WHERE NOT seen - GROUP BY domain, room; + GROUP BY room; CREATE TEMPORARY TRIGGER euc_insert_room AFTER INSERT ON main.euph_rooms BEGIN - INSERT INTO euph_unseen_counts (domain, room, amount) - VALUES (new.domain, new.room, 0); + INSERT INTO euph_unseen_counts (room, amount) + VALUES (new.room, 0); END; CREATE TEMPORARY TRIGGER euc_delete_room AFTER DELETE ON main.euph_rooms BEGIN DELETE FROM euph_unseen_counts - WHERE domain = old.domain - AND room = old.room; + WHERE room = old.room; END; CREATE TEMPORARY TRIGGER euc_insert_msg @@ -113,8 +104,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { BEGIN UPDATE euph_unseen_counts SET amount = amount + 1 - WHERE domain = new.domain - AND room = new.room; + WHERE room = new.room; END; CREATE TEMPORARY TRIGGER euc_update_msg @@ -123,8 +113,7 @@ 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 domain = new.domain - AND room = new.room; + WHERE room = new.room; END; ", )?;