Compare commits
212 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10214f3369 | |||
| 2ca6190d97 | |||
| 67e77c8880 | |||
| b70d7548da | |||
| 732d462775 | |||
| 40de073799 | |||
| 8b928184e8 | |||
| ca0f0b6c31 | |||
| 74fbf386b2 | |||
| a17630aeaa | |||
| 496cdde18d | |||
| 6157ca5088 | |||
| 30c344031a | |||
| 4cf6a15577 | |||
| b207e91c25 | |||
| 676c92752d | |||
| cc436bbb3a | |||
| 56896a861e | |||
| 03b91ec1cd | |||
| cab37cb633 | |||
| 967293db37 | |||
| 972e4938aa | |||
| b64f56fce5 | |||
| b4c4a89625 | |||
| 9435fbece6 | |||
| 315db43010 | |||
| 24c8c92070 | |||
| bf9a9d640b | |||
| 8040b82ff1 | |||
| 17185ea536 | |||
| 900a686d0d | |||
| 2fa1bec421 | |||
| e750f81b11 | |||
| 866176dab6 | |||
| bf11e055b6 | |||
| 6c884f3077 | |||
| d29e3e6651 | |||
| fbc64de607 | |||
| 816d8f86a3 | |||
| 25d2cc7c98 | |||
| f45e66f572 | |||
| bd43fe060b | |||
| e1ba15cb9e | |||
| edc4219258 | |||
| 55d4321770 | |||
| e80d41cc47 | |||
| f471b9ce00 | |||
| 2ecc482533 | |||
| cff933b0bf | |||
| e43b27acfd | |||
| 461cc37d88 | |||
| 106a047b78 | |||
| 7aba041c9f | |||
| 19242a658e | |||
| db734c5740 | |||
| d3666674b2 | |||
| c2cfa6e527 | |||
| 3a3d42bcf3 | |||
| db529688e9 | |||
| 131b581880 | |||
| 50be653244 | |||
| 998a2f2ffd | |||
| a5b33440c5 | |||
| 133681fc62 | |||
| 37e4c6b845 | |||
| ee7121b04e | |||
| ab81c89854 | |||
| ed7bd3ddb4 | |||
| 6352fcf639 | |||
| a0b029b353 | |||
| dadbb205e5 | |||
| e0948c4f1c | |||
| 88ba77b955 | |||
| 2d2dab11ba | |||
| a8570cf9c5 | |||
| f94eb00dcd | |||
| f555414716 | |||
| 1189e3eb7b | |||
| 43a8b91dca | |||
| c286e0244c | |||
| 956d3013ea | |||
| a1b0e151ff | |||
| 1bf4035c57 | |||
| cc1a2866eb | |||
| 5bbf389dbe | |||
| b302229d7c | |||
| 2deecc2084 | |||
| a522b09f79 | |||
| 4e6e413f3d | |||
| 72310d87f5 | |||
| 85cf99387e | |||
| 970bc07ed9 | |||
| f4967731a1 | |||
| 13a4fa0938 | |||
| 2a10a7a39f | |||
| 6a2f9de85b | |||
| fdbd6e0c55 | |||
| 5995d06cad | |||
| c094f526a5 | |||
| 60fdc40a21 | |||
| 78bbfac2f3 | |||
| 708d66b256 | |||
| 1f1795f111 | |||
| 2bbfca7002 | |||
| da1d23646a | |||
| 6b7ab3584a | |||
| 076c8f1a72 | |||
| c6a1dd8632 | |||
| 39a4f29a2a | |||
| 2983733744 | |||
| 2e039a855c | |||
| bd874e6212 | |||
| 02bfd3ed3d | |||
| 876619454e | |||
| 3b4e41ea4e | |||
| dc0de4354f | |||
| e6585286e3 | |||
| 2afda17d4b | |||
| 101d36cd45 | |||
| 48279e879a | |||
| 5e55389887 | |||
| e4e321c589 | |||
| 325c5e6e5c | |||
| 01c2934fd5 | |||
| 1b831f1b29 | |||
| dd427b7792 | |||
| b6fdc3b7e8 | |||
| 03c7fb567c | |||
| f3efff68f5 | |||
| 98cb1f2cbc | |||
| c04f6a8cb4 | |||
| 14e17730dc | |||
| 591807dd55 | |||
| 9bc6931fae | |||
| 202969c7a9 | |||
| c0a01b7ad4 | |||
| e5960b8eda | |||
| 6b05a2a06d | |||
| 593443f10e | |||
| 1ce31b6677 | |||
| 51b1953207 | |||
| ff33454b9a | |||
| 36c5831b9b | |||
| 64a7e7f518 | |||
| 6ce2afbc9f | |||
| fdc46aa3b8 | |||
| 6c99f7a53a | |||
| 15177a529a | |||
| 6f85995379 | |||
| 458025b8bf | |||
| d29441bf02 | |||
| 9f24cb2de1 | |||
| c53e3c262e | |||
| e1c3a463b2 | |||
| 5a0efd69e4 | |||
| 478e3e767c | |||
| 1276a82e54 | |||
| a1acc26027 | |||
| 17acdd0575 | |||
| 072290511b | |||
| 3fbb9127a6 | |||
| abedc5f194 | |||
| f7f200a608 | |||
| 39026a217d | |||
| cc7dd29af4 | |||
| 3d91d447c7 | |||
| f2c3011888 | |||
| cedeeff10b | |||
| dfb2ef5371 | |||
| 5b5370d2df | |||
| 288a5f97dd | |||
| babdd10fba | |||
| 502ebab132 | |||
| 027bf489b7 | |||
| 3fb774e93e | |||
| 318f7e2a73 | |||
| 164c02243d | |||
| ade7be594e | |||
| a638caadcb | |||
| 8182cc5d38 | |||
| 07b761e0f9 | |||
| 3f18b76c7d | |||
| 21bb87fd45 | |||
| bc8c5968d6 | |||
| e2b75d2f52 | |||
| 6f0088e194 | |||
| b8da97aaa4 | |||
| 31c8453a83 | |||
| f69d88bf4a | |||
| ecc4995397 | |||
| 95068920f1 | |||
| a18ee8e7c0 | |||
| bb4d0fe047 | |||
| d7d25a8390 | |||
| 91d8d7ba97 | |||
| 03766802fd | |||
| e358e2184e | |||
| c7cbd9856b | |||
| d8d3e64776 | |||
| ead4fa7c8a | |||
| adc70ad233 | |||
| d5b6dd9802 | |||
| 8de5bf87af | |||
| 267ef2bee9 | |||
| 07960142e0 | |||
| 3f7ed63064 | |||
| ff9a16d8a3 | |||
| 059ff94aef | |||
| b515ace906 | |||
| d2e3e2aef9 | |||
| 674534dfa4 | |||
| 3f63221594 |
109 changed files with 10954 additions and 8555 deletions
75
.github/workflows/build.yml
vendored
Normal file
75
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# What software is installed by default:
|
||||
# https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
|
||||
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
- macos-13
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up rust
|
||||
run: rustup update
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Test
|
||||
run: cargo test --release
|
||||
|
||||
- name: Record target triple
|
||||
run: rustc -vV | awk '/^host/ { print $2 }' > target/release/host
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cove-${{ matrix.os }}
|
||||
path: |
|
||||
target/release/cove
|
||||
target/release/cove.exe
|
||||
target/release/host
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs:
|
||||
- build
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Zip artifacts
|
||||
run: |
|
||||
chmod +x cove-ubuntu-22.04/cove
|
||||
chmod +x cove-windows-latest/cove.exe
|
||||
chmod +x cove-macos-latest/cove
|
||||
chmod +x cove-macos-13/cove
|
||||
zip -jr "cove-$(cat cove-ubuntu-22.04/host).zip" cove-ubuntu-22.04/cove
|
||||
zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe
|
||||
zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove
|
||||
zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove
|
||||
|
||||
- name: Create new release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: Automated release, see [CHANGELOG.md](CHANGELOG.md) for more details.
|
||||
files: "*.zip"
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -2,7 +2,7 @@
|
|||
"files.insertFinalNewline": true,
|
||||
"rust-analyzer.cargo.features": "all",
|
||||
"rust-analyzer.imports.granularity.enforce": true,
|
||||
"rust-analyzer.imports.granularity.group": "module",
|
||||
"rust-analyzer.imports.granularity.group": "crate",
|
||||
"rust-analyzer.imports.group.enable": true,
|
||||
"evenBetterToml.formatter.columnWidth": 100,
|
||||
}
|
||||
|
|
|
|||
169
CHANGELOG.md
169
CHANGELOG.md
|
|
@ -4,28 +4,168 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
Procedure when bumping the version number:
|
||||
|
||||
1. Update dependencies in a separate commit
|
||||
2. Set version number in `Cargo.toml`
|
||||
3. Add new section in this changelog
|
||||
4. Commit with message `Bump version to X.Y.Z`
|
||||
5. Create tag named `vX.Y.Z`
|
||||
6. Fast-forward branch `latest`
|
||||
7. Push `master`, `latest` and the new tag
|
||||
4. Run `cargo run help-config > CONFIG.md`
|
||||
5. Commit with message `Bump version to X.Y.Z`
|
||||
6. Create tag named `vX.Y.Z`
|
||||
7. Push `master` and the new tag
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Display emoji user id hashes in the nick list
|
||||
- Compile linux binary with older glibc version
|
||||
|
||||
## v0.9.3 - 2025-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Key bindings for emoji-based user id hashing
|
||||
|
||||
### Fixed
|
||||
|
||||
- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms
|
||||
|
||||
## v0.9.2 - 2025-03-14
|
||||
|
||||
### Added
|
||||
|
||||
- `bell_on_mention` config option
|
||||
|
||||
## v0.9.1 - 2025-03-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rendering glitches with unicode-based width estimation
|
||||
|
||||
## v0.9.0 - 2025-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- Unicode-based grapheme width estimation method
|
||||
- `width_estimation_method` config option
|
||||
- `--width-estimation-method` option
|
||||
- Room links are now included in the `I` message links list
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated documentation for `time_zone` config option
|
||||
- When connecting to a room using `n` in the room list, the cursor now moves to that room
|
||||
- Updated list of emoji names
|
||||
|
||||
### Removed
|
||||
|
||||
- Special handling of &rl2dev
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nick color in rare edge cases
|
||||
- Message link list rendering bug
|
||||
|
||||
## v0.8.3 - 2024-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated list of emoji names
|
||||
|
||||
## v0.8.2 - 2024-04-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `json-stream` export format to `json-lines` (see <https://jsonlines.org/>)
|
||||
- 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
|
||||
|
|
@ -33,31 +173,37 @@ Procedure when bumping the version number:
|
|||
- `--verbose` flag
|
||||
|
||||
### Changed
|
||||
|
||||
- Non-export info is now printed to stderr instead of stdout
|
||||
- Recognizes links without scheme (e. g. `euphoria.io` instead of `https://euphoria.io`)
|
||||
- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`)
|
||||
- Rooms waiting for reconnect are no longer sorted to bottom in default sort order
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mentions not being stopped by `>`
|
||||
|
||||
## v0.5.2 - 2023-01-14
|
||||
|
||||
### Added
|
||||
|
||||
- Key binding to open present page
|
||||
|
||||
### Changed
|
||||
|
||||
- Always connect to &rl2dev in ephemeral mode
|
||||
- Reduce amount of messages per &rl2dev log request
|
||||
|
||||
## v0.5.1 - 2022-11-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Increase reconnect delay to one minute
|
||||
- Print errors that occurred while cove was running more compactly
|
||||
|
||||
## v0.5.0 - 2022-09-26
|
||||
|
||||
### Added
|
||||
|
||||
- Key bindings to navigate nick list
|
||||
- Room deletion confirmation popup
|
||||
- Message inspection popup
|
||||
|
|
@ -66,10 +212,12 @@ Procedure when bumping the version number:
|
|||
- `rooms_sort_order` config option
|
||||
|
||||
### Changed
|
||||
|
||||
- Use nick changes to detect sessions for nick list
|
||||
- Support Unicode 15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cursor being visible through popups
|
||||
- Cursor in lists when highlighted item moves off-screen
|
||||
- User disappearing from nick list when only one of their sessions disconnects
|
||||
|
|
@ -77,6 +225,7 @@ Procedure when bumping the version number:
|
|||
## v0.4.0 - 2022-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Config file and `--config` cli option
|
||||
- `data_dir` config option
|
||||
- `ephemeral` config option
|
||||
|
|
@ -92,14 +241,17 @@ Procedure when bumping the version number:
|
|||
- Key bindings to view and open links in a message
|
||||
|
||||
### Changed
|
||||
|
||||
- Some key bindings in the rooms list
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rooms being stuck in "Connecting" state
|
||||
|
||||
## v0.3.0 - 2022-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Account login and logout
|
||||
- Authentication dialog for password-protected rooms
|
||||
- Error popups in rooms when something goes wrong
|
||||
|
|
@ -107,10 +259,12 @@ Procedure when bumping the version number:
|
|||
- Key binding to download more logs
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduced amount of unnecessary redraws
|
||||
- Description of `export` CLI command
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crash when connecting to nonexistent rooms
|
||||
- Crash when connecting to rooms that require authentication
|
||||
- Pasting multi-line strings into the editor
|
||||
|
|
@ -118,15 +272,18 @@ Procedure when bumping the version number:
|
|||
## v0.2.1 - 2022-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Joining new rooms no longer crashes cove
|
||||
- Scrolling when exiting message editor
|
||||
|
||||
## v0.2.0 - 2022-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- New messages are now marked as unseen
|
||||
- Sub-trees can now be folded
|
||||
- Support for pasting text into editors
|
||||
|
|
@ -139,10 +296,12 @@ Procedure when bumping the version number:
|
|||
- Support for exporting multiple/all rooms at once
|
||||
|
||||
### Changed
|
||||
|
||||
- Reorganized export command
|
||||
- Slowed down room history download speed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Chat rendering when deleting and re-joining a room
|
||||
- Spacing in some popups
|
||||
|
||||
|
|
|
|||
711
CONFIG.md
Normal file
711
CONFIG.md
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
# Config file format
|
||||
|
||||
Cove's config file uses the [TOML](https://toml.io/) format.
|
||||
|
||||
Here is an example config that changes a few different options:
|
||||
|
||||
```toml
|
||||
measure_widths = true
|
||||
rooms_sort_order = "importance"
|
||||
|
||||
[euph.servers."euphoria.leet.nu".rooms]
|
||||
welcome.autojoin = true
|
||||
test.username = "badingle"
|
||||
test.force_username = true
|
||||
private.password = "foobar"
|
||||
|
||||
[keys]
|
||||
general.abort = ["esc", "ctrl+c"]
|
||||
general.exit = "ctrl+q"
|
||||
tree.action.fold_tree = "f"
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
|
||||
Key bindings are specified as strings or lists of strings. Each string specifies
|
||||
a main key and zero or more modifier keys. The modifier keys (if any) are listed
|
||||
first, followed by the main key. They are separated by the `+` character and
|
||||
**no** whitespace.
|
||||
|
||||
Examples of key bindings:
|
||||
- `"ctrl+c"`
|
||||
- `"X"` (not `"shift+x"`)
|
||||
- `"space"` or `" "` (both space bar)
|
||||
- `["g", "home"]`
|
||||
- `["K", "ctrl+up"]`
|
||||
- `["f1", "?"]`
|
||||
- `"ctrl+alt+f3"`
|
||||
- `["enter", "any+enter"]` (matches `enter` regardless of modifiers)
|
||||
|
||||
Available main keys:
|
||||
- Any single character that can be typed
|
||||
- `esc`, `enter`, `space`, `tab`, `backtab`
|
||||
- `backspace`, `delete`, `insert`
|
||||
- `left`, `right`, `up`, `down`
|
||||
- `home`, `end`, `pageup`, `pagedown`
|
||||
- `f1`, `f2`, ...
|
||||
|
||||
Available modifiers:
|
||||
- `shift` (must not be used with single characters)
|
||||
- `ctrl`
|
||||
- `alt`
|
||||
- `any` (matches as long as at least one modifier is pressed)
|
||||
|
||||
## Available options
|
||||
|
||||
### `bell_on_mention`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Ring the bell (character 0x07) when you are mentioned in a room.
|
||||
|
||||
### `data_dir`
|
||||
|
||||
**Required:** no
|
||||
**Type:** path
|
||||
**Default:** platform-dependent
|
||||
|
||||
The directory that cove stores its data in when not running in ephemeral
|
||||
mode.
|
||||
|
||||
Relative paths are interpreted relative to the user's home directory.
|
||||
|
||||
See also the `--data-dir` command line option.
|
||||
|
||||
### `ephemeral`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to start in ephemeral mode.
|
||||
|
||||
In ephemeral mode, cove doesn't store any data. It completely ignores
|
||||
any options related to the data dir.
|
||||
|
||||
See also the `--ephemeral` command line option.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.autojoin`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to automatically join this room on startup.
|
||||
|
||||
### `euph.servers.<domain>.rooms.<room>.force_username`
|
||||
|
||||
**Required:** yes
|
||||
**Type:** boolean
|
||||
**Default:** `false`
|
||||
|
||||
If `euph.servers.<domain>.rooms.<room>.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.<domain>.rooms.<room>.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.<domain>.rooms.<room>.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.
|
||||
1585
Cargo.lock
generated
1585
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
107
Cargo.toml
107
Cargo.toml
|
|
@ -1,57 +1,72 @@
|
|||
[package]
|
||||
name = "cove"
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["cove", "cove-*"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.70"
|
||||
async-trait = "0.1.68"
|
||||
clap = { version = "4.2.1", features = ["derive", "deprecated"] }
|
||||
cookie = "0.17.0"
|
||||
crossterm = "0.26.1"
|
||||
directories = "5.0.0"
|
||||
edit = "0.1.4"
|
||||
linkify = "0.9.0"
|
||||
log = { version = "0.4.17", features = ["std"] }
|
||||
once_cell = "1.17.1"
|
||||
open = "4.0.1"
|
||||
parking_lot = "0.12.1"
|
||||
rusqlite = { version = "0.28.0", features = ["bundled", "time"] }
|
||||
serde = { version = "1.0.159", features = ["derive"] }
|
||||
serde_json = "1.0.95"
|
||||
thiserror = "1.0.40"
|
||||
tokio = { version = "1.27.0", features = ["full"] }
|
||||
toml = "0.7.3"
|
||||
unicode-segmentation = "1.10.1"
|
||||
unicode-width = "0.1.10"
|
||||
[workspace.package]
|
||||
version = "0.9.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3.20"
|
||||
features = ["macros", "formatting", "parsing", "serde"]
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.97"
|
||||
async-trait = "0.1.87"
|
||||
clap = { version = "4.5.32", features = ["derive", "deprecated"] }
|
||||
cookie = "0.18.1"
|
||||
crossterm = "0.28.1"
|
||||
directories = "6.0.0"
|
||||
edit = "0.1.5"
|
||||
jiff = "0.2.4"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.26", features = ["std"] }
|
||||
open = "5.3.2"
|
||||
parking_lot = "0.12.3"
|
||||
proc-macro2 = "1.0.94"
|
||||
quote = "1.0.40"
|
||||
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
|
||||
rustls = "0.23.23"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_either = "0.2.1"
|
||||
serde_json = "1.0.140"
|
||||
syn = "2.0.100"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
toml = "0.8.20"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
[dependencies.tokio-tungstenite]
|
||||
version = "0.18.0"
|
||||
features = ["rustls-tls-native-roots"]
|
||||
|
||||
[dependencies.euphoxide]
|
||||
[workspace.dependencies.euphoxide]
|
||||
git = "https://github.com/Garmelon/euphoxide.git"
|
||||
rev = "0f217a6279181b0731216760219e8ff0fa01e449"
|
||||
tag = "v0.6.1"
|
||||
features = ["bot"]
|
||||
|
||||
# [patch."https://github.com/Garmelon/euphoxide.git"]
|
||||
# euphoxide = { path = "../euphoxide/" }
|
||||
|
||||
[dependencies.toss]
|
||||
[workspace.dependencies.toss]
|
||||
git = "https://github.com/Garmelon/toss.git"
|
||||
rev = "0d59116012a51516a821991e2969b1cf4779770f"
|
||||
tag = "v0.3.4"
|
||||
|
||||
# [patch."https://github.com/Garmelon/toss.git"]
|
||||
# toss = { path = "../toss/" }
|
||||
|
||||
[dependencies.vault]
|
||||
[workspace.dependencies.vault]
|
||||
git = "https://github.com/Garmelon/vault.git"
|
||||
tag = "v0.1.0"
|
||||
tag = "v0.4.0"
|
||||
features = ["tokio"]
|
||||
|
||||
# [patch."https://github.com/Garmelon/vault.git"]
|
||||
# vault = { path = "../vault/" }
|
||||
[workspace.lints]
|
||||
rust.unsafe_code = { level = "forbid", priority = 1 }
|
||||
# Lint groups
|
||||
rust.deprecated_safe = "warn"
|
||||
rust.future_incompatible = "warn"
|
||||
rust.keyword_idents = "warn"
|
||||
rust.rust_2018_idioms = "warn"
|
||||
rust.unused = "warn"
|
||||
# Individual lints
|
||||
rust.non_local_definitions = "warn"
|
||||
rust.redundant_imports = "warn"
|
||||
rust.redundant_lifetimes = "warn"
|
||||
rust.single_use_lifetimes = "warn"
|
||||
rust.unit_bindings = "warn"
|
||||
rust.unnameable_types = "warn"
|
||||
rust.unused_crate_dependencies = "warn"
|
||||
rust.unused_import_braces = "warn"
|
||||
rust.unused_lifetimes = "warn"
|
||||
rust.unused_qualifications = "warn"
|
||||
# Clippy
|
||||
clippy.use_self = "warn"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
|
|
|||
160
README.md
160
README.md
|
|
@ -1,11 +1,16 @@
|
|||
# cove
|
||||
|
||||
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded
|
||||
Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
|
||||
real-time chat platform.
|
||||
|
||||

|
||||
|
||||
It runs on Linux, Windows and macOS.
|
||||
It runs on Linux, Windows, and macOS.
|
||||
|
||||
## Installing cove
|
||||
|
||||
Download a binary of your choice from the
|
||||
[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest).
|
||||
|
||||
## Using cove
|
||||
|
||||
|
|
@ -18,156 +23,11 @@ things in) won't automatically shrink. If it takes up too much space, try
|
|||
running `cove gc` and waiting for it to finish. This isn't done automatically
|
||||
because it can take quite a while.
|
||||
|
||||
## Installation
|
||||
## Configuring cove
|
||||
|
||||
At this point, cove is not available via any package manager.
|
||||
|
||||
Cove is available as a Nix Flake. To try it out, you can use
|
||||
```bash
|
||||
$ nix run --override-input nixpkgs nixpkgs github:Garmelon/cove/latest
|
||||
```
|
||||
|
||||
## Manual installation
|
||||
|
||||
This section contains instructions on how to install cove by compiling it yourself.
|
||||
It doesn't assume you know how to program, but it does assume basic familiarity with the command line on your platform of choice.
|
||||
Cove runs in the terminal, after all.
|
||||
|
||||
### Installing rustup
|
||||
|
||||
Cove is written in Rust, so the first step is to install rustup. Either install
|
||||
it from your package manager of choice (if you have one) or use the
|
||||
[installer](https://rustup.rs/).
|
||||
|
||||
Test your installation by running `rustup --version` and `cargo --version`. If
|
||||
rustup is installed correctly, both of these should show a version number.
|
||||
|
||||
Cove is designed on the current version of the stable toolchain. If cove doesn't
|
||||
compile, you can try switching to the stable toolchain and updating it using the
|
||||
following commands:
|
||||
```bash
|
||||
$ rustup default stable
|
||||
$ rustup update
|
||||
```
|
||||
|
||||
### Installing cove
|
||||
|
||||
To install or update to the latest release of cove, run the following command:
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove --branch latest
|
||||
```
|
||||
|
||||
If you like to live dangerously and want to install or update to the latest,
|
||||
bleeding-edge, possibly-broken commit from the repo's main branch, run the
|
||||
following command.
|
||||
|
||||
**Warning:** This could corrupt your vault. Make sure to make a backup before
|
||||
running the command.
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove
|
||||
```
|
||||
|
||||
To install a specific version of cove, run the following command and substitute
|
||||
in the full version you want to install:
|
||||
|
||||
```bash
|
||||
$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0
|
||||
```
|
||||
|
||||
## Config file
|
||||
|
||||
Cove's config file uses the [TOML](https://toml.io/) format.
|
||||
A complete list of config options is available in the [CONFIG.md](CONFIG.md)
|
||||
file or via `cove help-config`.
|
||||
|
||||
When launched, cove prints the location it is loading its config file from. To
|
||||
configure cove, create a config file at that location. This location can be
|
||||
changed via the `--config` command line option.
|
||||
|
||||
The following is a complete list of available options. If a command line option
|
||||
with the same purpose exists, it takes precedence over the option specified in
|
||||
the config file.
|
||||
|
||||
### `data_dir`
|
||||
|
||||
**Type:** String (representing path)
|
||||
**Default:** Platform dependent
|
||||
|
||||
The directory that cove stores its data in when not running in ephemeral mode.
|
||||
|
||||
Relative paths are interpreted relative to the user's home directory.
|
||||
|
||||
See also the `--data-dir` command line option.
|
||||
|
||||
### `ephemeral`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to start in ephemeral mode.
|
||||
|
||||
In ephemeral mode, cove doesn't store any data. It completely ignores any
|
||||
options related to the data dir.
|
||||
|
||||
See also the `--ephemeral` command line option.
|
||||
|
||||
### `offline`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to start in offline mode.
|
||||
|
||||
In offline mode, cove won't automatically join rooms marked via the `autojoin`
|
||||
option on startup. You can still join those rooms manually by pressing `a` in
|
||||
the rooms list.
|
||||
|
||||
See also the `--offline` command line option.
|
||||
|
||||
### `rooms_sort_order`
|
||||
|
||||
**Type:** String, one of `alphabetic`, `importance`
|
||||
**Default:** `alphabetic`
|
||||
|
||||
Initial sort order of rooms list.
|
||||
|
||||
`alphabetic` sorts rooms in alphabetic order.
|
||||
|
||||
`importance` sorts rooms by the following criteria (in descending order of
|
||||
priority):
|
||||
|
||||
1. connected rooms before unconnected rooms
|
||||
2. rooms with unread messages before rooms without
|
||||
3. alphabetic order
|
||||
|
||||
### `euph.rooms.<room>.autojoin`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
|
||||
Whether to automatically join this room on startup.
|
||||
|
||||
### `euph.rooms.<room>.username`
|
||||
|
||||
**Type:** String
|
||||
**Default:** Not set
|
||||
|
||||
If set, cove will set this username upon joining if there is no username
|
||||
associated with the current session.
|
||||
|
||||
### `euph.rooms.<room>.force_username`
|
||||
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
|
||||
If `euph.rooms.<room>.username` is set, this will force cove to set the username
|
||||
even if there is already a different username associated with the current
|
||||
session.
|
||||
|
||||
### `euph.rooms.<room>.password`
|
||||
|
||||
**Type:** String
|
||||
**Default:** Not set
|
||||
|
||||
If set, cove will try once to use this password to authenticate, should the room
|
||||
be password-protected.
|
||||
|
|
|
|||
0
cove-config/CONFIG.md
Normal file
0
cove-config/CONFIG.md
Normal file
15
cove-config/Cargo.toml
Normal file
15
cove-config/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "cove-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cove-input = { path = "../cove-input" }
|
||||
cove-macro = { path = "../cove-macro" }
|
||||
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
267
cove-config/src/doc.rs
Normal file
267
cove-config/src/doc.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
//! Auto-generate markdown documentation.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use cove_input::KeyBinding;
|
||||
pub use cove_macro::Document;
|
||||
use serde::Serialize;
|
||||
|
||||
const MARKDOWN_INTRODUCTION: &str = r#"# Config file format
|
||||
|
||||
Cove's config file uses the [TOML](https://toml.io/) format.
|
||||
|
||||
Here is an example config that changes a few different options:
|
||||
|
||||
```toml
|
||||
measure_widths = true
|
||||
rooms_sort_order = "importance"
|
||||
|
||||
[euph.servers."euphoria.leet.nu".rooms]
|
||||
welcome.autojoin = true
|
||||
test.username = "badingle"
|
||||
test.force_username = true
|
||||
private.password = "foobar"
|
||||
|
||||
[keys]
|
||||
general.abort = ["esc", "ctrl+c"]
|
||||
general.exit = "ctrl+q"
|
||||
tree.action.fold_tree = "f"
|
||||
```
|
||||
|
||||
## Key bindings
|
||||
|
||||
Key bindings are specified as strings or lists of strings. Each string specifies
|
||||
a main key and zero or more modifier keys. The modifier keys (if any) are listed
|
||||
first, followed by the main key. They are separated by the `+` character and
|
||||
**no** whitespace.
|
||||
|
||||
Examples of key bindings:
|
||||
- `"ctrl+c"`
|
||||
- `"X"` (not `"shift+x"`)
|
||||
- `"space"` or `" "` (both space bar)
|
||||
- `["g", "home"]`
|
||||
- `["K", "ctrl+up"]`
|
||||
- `["f1", "?"]`
|
||||
- `"ctrl+alt+f3"`
|
||||
- `["enter", "any+enter"]` (matches `enter` regardless of modifiers)
|
||||
|
||||
Available main keys:
|
||||
- Any single character that can be typed
|
||||
- `esc`, `enter`, `space`, `tab`, `backtab`
|
||||
- `backspace`, `delete`, `insert`
|
||||
- `left`, `right`, `up`, `down`
|
||||
- `home`, `end`, `pageup`, `pagedown`
|
||||
- `f1`, `f2`, ...
|
||||
|
||||
Available modifiers:
|
||||
- `shift` (must not be used with single characters)
|
||||
- `ctrl`
|
||||
- `alt`
|
||||
- `any` (matches as long as at least one modifier is pressed)
|
||||
|
||||
## Available options
|
||||
"#;
|
||||
|
||||
pub fn toml_value_as_markdown<T: Serialize>(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<bool>,
|
||||
pub r#type: Option<String>,
|
||||
pub values: Option<Vec<String>>,
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
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<String, Box<Doc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WrapInfo {
|
||||
pub inner: Option<Box<Doc>>,
|
||||
pub metavar: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Doc {
|
||||
pub description: Option<String>,
|
||||
|
||||
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<Entry> {
|
||||
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<I: Document> Document for Option<I> {
|
||||
fn doc() -> Doc {
|
||||
let mut doc = I::doc();
|
||||
assert_eq!(doc.value_info.required, Some(true));
|
||||
doc.value_info.required = Some(false);
|
||||
doc
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: Document> Document for HashMap<String, I> {
|
||||
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
|
||||
}
|
||||
}
|
||||
47
cove-config/src/euph.rs
Normal file
47
cove-config/src/euph.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::doc::Document;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RoomsSortOrder {
|
||||
#[default]
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
// TODO Mark favourite rooms via printable ascii characters
|
||||
#[derive(Debug, Clone, Default, Deserialize, Document)]
|
||||
pub struct EuphRoom {
|
||||
/// Whether to automatically join this room on startup.
|
||||
#[serde(default)]
|
||||
pub autojoin: bool,
|
||||
|
||||
/// If set, cove will set this username upon joining if there is no username
|
||||
/// associated with the current session.
|
||||
pub username: Option<String>,
|
||||
|
||||
/// If `euph.servers.<domain>.rooms.<room>.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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct EuphServer {
|
||||
#[document(metavar = "room")]
|
||||
pub rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Euph {
|
||||
#[document(metavar = "domain")]
|
||||
pub servers: HashMap<String, EuphServer>,
|
||||
}
|
||||
427
cove-config/src/keys.rs
Normal file
427
cove-config/src/keys.rs
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
use cove_input::{KeyBinding, KeyGroup, KeyGroupInfo};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::doc::Document;
|
||||
|
||||
macro_rules! default_bindings {
|
||||
( $(
|
||||
pub mod $mod:ident { $(
|
||||
pub fn $name:ident => [ $($key:expr),* ];
|
||||
)* }
|
||||
)*) => {
|
||||
mod default { $(
|
||||
pub mod $mod { $(
|
||||
pub fn $name() -> ::cove_input::KeyBinding {
|
||||
::cove_input::KeyBinding::new().with_keys([ $($key),* ]).unwrap()
|
||||
}
|
||||
)* }
|
||||
)* }
|
||||
};
|
||||
}
|
||||
|
||||
default_bindings! {
|
||||
pub mod general {
|
||||
pub fn exit => ["ctrl+c"];
|
||||
pub fn abort => ["esc"];
|
||||
pub fn confirm => ["enter"];
|
||||
pub fn focus => ["tab"];
|
||||
pub fn help => ["f1"];
|
||||
pub fn log => ["f12"];
|
||||
}
|
||||
|
||||
pub mod scroll {
|
||||
pub fn up_line => ["ctrl+y"];
|
||||
pub fn down_line => ["ctrl+e"];
|
||||
pub fn up_half => ["ctrl+u"];
|
||||
pub fn down_half => ["ctrl+d"];
|
||||
pub fn up_full => ["ctrl+b", "pageup"];
|
||||
pub fn down_full => ["ctrl+f", "pagedown"];
|
||||
pub fn center_cursor => ["z"];
|
||||
}
|
||||
|
||||
pub mod cursor {
|
||||
pub fn up => ["k", "up"];
|
||||
pub fn down => ["j", "down"];
|
||||
pub fn to_top => ["g", "home"];
|
||||
pub fn to_bottom => ["G", "end"];
|
||||
}
|
||||
|
||||
pub mod editor_cursor {
|
||||
pub fn left => ["ctrl+b","left"];
|
||||
pub fn right => ["ctrl+f", "right"];
|
||||
pub fn left_word => ["alt+b", "ctrl+left"];
|
||||
pub fn right_word => ["alt+f", "ctrl+right"];
|
||||
pub fn start => ["ctrl+a", "home"];
|
||||
pub fn end => ["ctrl+e", "end"];
|
||||
pub fn up => ["up"];
|
||||
pub fn down => ["down"];
|
||||
}
|
||||
|
||||
pub mod editor_action {
|
||||
pub fn backspace => ["ctrl+h", "backspace"];
|
||||
pub fn delete => ["ctrl+d", "delete"];
|
||||
pub fn clear => ["ctrl+l"];
|
||||
pub fn external => ["ctrl+x", "alt+e"];
|
||||
}
|
||||
|
||||
pub mod rooms_action {
|
||||
pub fn connect => ["c"];
|
||||
pub fn connect_all => ["C"];
|
||||
pub fn disconnect => ["d"];
|
||||
pub fn disconnect_all => ["D"];
|
||||
pub fn connect_autojoin => ["a"];
|
||||
pub fn disconnect_non_autojoin => ["A"];
|
||||
pub fn new => ["n"];
|
||||
pub fn delete => ["X"];
|
||||
pub fn change_sort_order => ["s"];
|
||||
}
|
||||
|
||||
pub mod room_action {
|
||||
pub fn authenticate => ["a"];
|
||||
pub fn nick => ["n"];
|
||||
pub fn more_messages => ["m"];
|
||||
pub fn account => ["A"];
|
||||
}
|
||||
|
||||
pub mod tree_cursor {
|
||||
pub fn to_above_sibling => ["K", "ctrl+up"];
|
||||
pub fn to_below_sibling => ["J", "ctrl+down"];
|
||||
pub fn to_parent => ["p"];
|
||||
pub fn to_root => ["P"];
|
||||
pub fn to_older_message => ["h", "left"];
|
||||
pub fn to_newer_message => ["l", "right"];
|
||||
pub fn to_older_unseen_message => ["H", "ctrl+left"];
|
||||
pub fn to_newer_unseen_message => ["L", "ctrl+right"];
|
||||
}
|
||||
|
||||
pub mod tree_action {
|
||||
pub fn reply => ["r"];
|
||||
pub fn reply_alternate => ["R"];
|
||||
pub fn new_thread => ["t"];
|
||||
pub fn fold_tree => [" "];
|
||||
pub fn toggle_seen => ["s"];
|
||||
pub fn mark_visible_seen => ["S"];
|
||||
pub fn mark_older_seen => ["ctrl+s"];
|
||||
pub fn info => ["i"];
|
||||
pub fn links => ["I"];
|
||||
pub fn toggle_nick_emoji => ["e"];
|
||||
pub fn increase_caesar => ["c"];
|
||||
pub fn decrease_caesar => ["C"];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// General.
|
||||
pub struct General {
|
||||
/// Quit cove.
|
||||
#[serde(default = "default::general::exit")]
|
||||
pub exit: KeyBinding,
|
||||
/// Abort/close.
|
||||
#[serde(default = "default::general::abort")]
|
||||
pub abort: KeyBinding,
|
||||
/// Confirm.
|
||||
#[serde(default = "default::general::confirm")]
|
||||
pub confirm: KeyBinding,
|
||||
/// Advance focus.
|
||||
#[serde(default = "default::general::focus")]
|
||||
pub focus: KeyBinding,
|
||||
/// Show this help.
|
||||
#[serde(default = "default::general::help")]
|
||||
pub help: KeyBinding,
|
||||
/// Show log.
|
||||
#[serde(default = "default::general::log")]
|
||||
pub log: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Scrolling.
|
||||
pub struct Scroll {
|
||||
/// Scroll up one line.
|
||||
#[serde(default = "default::scroll::up_line")]
|
||||
pub up_line: KeyBinding,
|
||||
/// Scroll down one line.
|
||||
#[serde(default = "default::scroll::down_line")]
|
||||
pub down_line: KeyBinding,
|
||||
/// Scroll up half a screen.
|
||||
#[serde(default = "default::scroll::up_half")]
|
||||
pub up_half: KeyBinding,
|
||||
/// Scroll down half a screen.
|
||||
#[serde(default = "default::scroll::down_half")]
|
||||
pub down_half: KeyBinding,
|
||||
/// Scroll up a full screen.
|
||||
#[serde(default = "default::scroll::up_full")]
|
||||
pub up_full: KeyBinding,
|
||||
/// Scroll down a full screen.
|
||||
#[serde(default = "default::scroll::down_full")]
|
||||
pub down_full: KeyBinding,
|
||||
/// Center cursor.
|
||||
#[serde(default = "default::scroll::center_cursor")]
|
||||
pub center_cursor: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Cursor movement.
|
||||
pub struct Cursor {
|
||||
/// Move up.
|
||||
#[serde(default = "default::cursor::up")]
|
||||
pub up: KeyBinding,
|
||||
/// Move down.
|
||||
#[serde(default = "default::cursor::down")]
|
||||
pub down: KeyBinding,
|
||||
/// Move to top.
|
||||
#[serde(default = "default::cursor::to_top")]
|
||||
pub to_top: KeyBinding,
|
||||
/// Move to bottom.
|
||||
#[serde(default = "default::cursor::to_bottom")]
|
||||
pub to_bottom: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Editor cursor movement.
|
||||
pub struct EditorCursor {
|
||||
/// Move left.
|
||||
#[serde(default = "default::editor_cursor::left")]
|
||||
pub left: KeyBinding,
|
||||
/// Move right.
|
||||
#[serde(default = "default::editor_cursor::right")]
|
||||
pub right: KeyBinding,
|
||||
/// Move left a word.
|
||||
#[serde(default = "default::editor_cursor::left_word")]
|
||||
pub left_word: KeyBinding,
|
||||
/// Move right a word.
|
||||
#[serde(default = "default::editor_cursor::right_word")]
|
||||
pub right_word: KeyBinding,
|
||||
/// Move to start of line.
|
||||
#[serde(default = "default::editor_cursor::start")]
|
||||
pub start: KeyBinding,
|
||||
/// Move to end of line.
|
||||
#[serde(default = "default::editor_cursor::end")]
|
||||
pub end: KeyBinding,
|
||||
/// Move up.
|
||||
#[serde(default = "default::editor_cursor::up")]
|
||||
pub up: KeyBinding,
|
||||
/// Move down.
|
||||
#[serde(default = "default::editor_cursor::down")]
|
||||
pub down: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Editor actions.
|
||||
pub struct EditorAction {
|
||||
/// Delete before cursor.
|
||||
#[serde(default = "default::editor_action::backspace")]
|
||||
pub backspace: KeyBinding,
|
||||
/// Delete after cursor.
|
||||
#[serde(default = "default::editor_action::delete")]
|
||||
pub delete: KeyBinding,
|
||||
/// Clear editor contents.
|
||||
#[serde(default = "default::editor_action::clear")]
|
||||
pub clear: KeyBinding,
|
||||
/// Edit in external editor.
|
||||
#[serde(default = "default::editor_action::external")]
|
||||
pub external: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Editor {
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub cursor: EditorCursor,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub action: EditorAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Room list actions.
|
||||
pub struct RoomsAction {
|
||||
/// Connect to selected room.
|
||||
#[serde(default = "default::rooms_action::connect")]
|
||||
pub connect: KeyBinding,
|
||||
/// Connect to all rooms.
|
||||
#[serde(default = "default::rooms_action::connect_all")]
|
||||
pub connect_all: KeyBinding,
|
||||
/// Disconnect from selected room.
|
||||
#[serde(default = "default::rooms_action::disconnect")]
|
||||
pub disconnect: KeyBinding,
|
||||
/// Disconnect from all rooms.
|
||||
#[serde(default = "default::rooms_action::disconnect_all")]
|
||||
pub disconnect_all: KeyBinding,
|
||||
/// Connect to all autojoin rooms.
|
||||
#[serde(default = "default::rooms_action::connect_autojoin")]
|
||||
pub connect_autojoin: KeyBinding,
|
||||
/// Disconnect from all non-autojoin rooms.
|
||||
#[serde(default = "default::rooms_action::disconnect_non_autojoin")]
|
||||
pub disconnect_non_autojoin: KeyBinding,
|
||||
/// Connect to new room.
|
||||
#[serde(default = "default::rooms_action::new")]
|
||||
pub new: KeyBinding,
|
||||
/// Delete room.
|
||||
#[serde(default = "default::rooms_action::delete")]
|
||||
pub delete: KeyBinding,
|
||||
/// Change sort order.
|
||||
#[serde(default = "default::rooms_action::change_sort_order")]
|
||||
pub change_sort_order: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Rooms {
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub action: RoomsAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Room actions.
|
||||
pub struct RoomAction {
|
||||
/// Authenticate.
|
||||
#[serde(default = "default::room_action::authenticate")]
|
||||
pub authenticate: KeyBinding,
|
||||
/// Change nick.
|
||||
#[serde(default = "default::room_action::nick")]
|
||||
pub nick: KeyBinding,
|
||||
/// Download more messages.
|
||||
#[serde(default = "default::room_action::more_messages")]
|
||||
pub more_messages: KeyBinding,
|
||||
/// Manage account.
|
||||
#[serde(default = "default::room_action::account")]
|
||||
pub account: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Room {
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub action: RoomAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Tree cursor movement.
|
||||
pub struct TreeCursor {
|
||||
/// Move to above sibling.
|
||||
#[serde(default = "default::tree_cursor::to_above_sibling")]
|
||||
pub to_above_sibling: KeyBinding,
|
||||
/// Move to below sibling.
|
||||
#[serde(default = "default::tree_cursor::to_below_sibling")]
|
||||
pub to_below_sibling: KeyBinding,
|
||||
/// Move to parent.
|
||||
#[serde(default = "default::tree_cursor::to_parent")]
|
||||
pub to_parent: KeyBinding,
|
||||
/// Move to root.
|
||||
#[serde(default = "default::tree_cursor::to_root")]
|
||||
pub to_root: KeyBinding,
|
||||
/// Move to older message.
|
||||
#[serde(default = "default::tree_cursor::to_older_message")]
|
||||
pub to_older_message: KeyBinding,
|
||||
/// Move to newer message.
|
||||
#[serde(default = "default::tree_cursor::to_newer_message")]
|
||||
pub to_newer_message: KeyBinding,
|
||||
/// Move to older unseen message.
|
||||
#[serde(default = "default::tree_cursor::to_older_unseen_message")]
|
||||
pub to_older_unseen_message: KeyBinding,
|
||||
/// Move to newer unseen message.
|
||||
#[serde(default = "default::tree_cursor::to_newer_unseen_message")]
|
||||
pub to_newer_unseen_message: KeyBinding,
|
||||
// TODO Bindings inspired by vim's ()/[]/{} bindings?
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Document, KeyGroup)]
|
||||
/// Tree actions.
|
||||
pub struct TreeAction {
|
||||
/// Reply to message, inline if possible.
|
||||
#[serde(default = "default::tree_action::reply")]
|
||||
pub reply: KeyBinding,
|
||||
/// Reply opposite to normal reply.
|
||||
#[serde(default = "default::tree_action::reply_alternate")]
|
||||
pub reply_alternate: KeyBinding,
|
||||
/// Start a new thread.
|
||||
#[serde(default = "default::tree_action::new_thread")]
|
||||
pub new_thread: KeyBinding,
|
||||
/// Fold current message's subtree.
|
||||
#[serde(default = "default::tree_action::fold_tree")]
|
||||
pub fold_tree: KeyBinding,
|
||||
/// Toggle current message's seen status.
|
||||
#[serde(default = "default::tree_action::toggle_seen")]
|
||||
pub toggle_seen: KeyBinding,
|
||||
/// Mark all visible messages as seen.
|
||||
#[serde(default = "default::tree_action::mark_visible_seen")]
|
||||
pub mark_visible_seen: KeyBinding,
|
||||
/// Mark all older messages as seen.
|
||||
#[serde(default = "default::tree_action::mark_older_seen")]
|
||||
pub mark_older_seen: KeyBinding,
|
||||
/// Inspect selected element.
|
||||
#[serde(default = "default::tree_action::info")]
|
||||
pub inspect: KeyBinding,
|
||||
/// List links found in message.
|
||||
#[serde(default = "default::tree_action::links")]
|
||||
pub links: KeyBinding,
|
||||
/// Toggle agent id based nick emoji.
|
||||
#[serde(default = "default::tree_action::toggle_nick_emoji")]
|
||||
pub toggle_nick_emoji: KeyBinding,
|
||||
/// Increase caesar cipher rotation.
|
||||
#[serde(default = "default::tree_action::increase_caesar")]
|
||||
pub increase_caesar: KeyBinding,
|
||||
/// Decrease caesar cipher rotation.
|
||||
#[serde(default = "default::tree_action::decrease_caesar")]
|
||||
pub decrease_caesar: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Tree {
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub cursor: TreeCursor,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub action: TreeAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Keys {
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub general: General,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub scroll: Scroll,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub cursor: Cursor,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub editor: Editor,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub rooms: Rooms,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub room: Room,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub tree: Tree,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
pub fn groups(&self) -> Vec<KeyGroupInfo<'_>> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
}
|
||||
158
cove-config/src/lib.rs
Normal file
158
cove-config/src/lib.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use doc::Document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::{euph::*, keys::*};
|
||||
|
||||
pub mod doc;
|
||||
mod euph;
|
||||
mod keys;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("failed to read config file")]
|
||||
Io(#[from] io::Error),
|
||||
#[error("failed to parse config file")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Document)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WidthEstimationMethod {
|
||||
#[default]
|
||||
Legacy,
|
||||
Unicode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Document)]
|
||||
pub struct Config {
|
||||
/// The directory that cove stores its data in when not running in ephemeral
|
||||
/// mode.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the user's home directory.
|
||||
///
|
||||
/// See also the `--data-dir` command line option.
|
||||
#[document(default = "platform-dependent")]
|
||||
pub data_dir: Option<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub euph: Euph,
|
||||
|
||||
#[serde(default)]
|
||||
#[document(no_default)]
|
||||
pub keys: Keys,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, Error> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
18
cove-input/Cargo.toml
Normal file
18
cove-input/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "cove-input"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cove-macro = { path = "../cove-macro" }
|
||||
|
||||
crossterm.workspace = true
|
||||
edit.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_either.workspace = true
|
||||
thiserror.workspace = true
|
||||
toss.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
252
cove-input/src/keys.rs
Normal file
252
cove-input/src/keys.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
use std::{fmt, num::ParseIntError, str::FromStr};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
use serde_either::SingleOrVec;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseKeysError {
|
||||
#[error("no key code specified")]
|
||||
NoKeyCode,
|
||||
#[error("unknown key code: {0:?}")]
|
||||
UnknownKeyCode(String),
|
||||
#[error("invalid function key number: {0}")]
|
||||
InvalidFNumber(#[from] ParseIntError),
|
||||
#[error("unknown modifier: {0:?}")]
|
||||
UnknownModifier(String),
|
||||
#[error("modifier {0} conflicts with previous modifier")]
|
||||
ConflictingModifier(String),
|
||||
}
|
||||
|
||||
fn conflicts_with_shift(code: KeyCode) -> bool {
|
||||
match code {
|
||||
KeyCode::Char(' ') => false,
|
||||
KeyCode::Char(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KeyPress {
|
||||
pub code: KeyCode,
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub any: bool,
|
||||
}
|
||||
|
||||
impl KeyPress {
|
||||
fn parse_key_code(code: &str) -> Result<Self, ParseKeysError> {
|
||||
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<Self, Self::Err> {
|
||||
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
format!("{self}").serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for KeyPress {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(|e| D::Error::custom(format!("{e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyBinding(Vec<KeyPress>);
|
||||
|
||||
impl KeyBinding {
|
||||
pub fn new() -> Self {
|
||||
Self(vec![])
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> &[KeyPress] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn with_key(self, key: &str) -> Result<Self, ParseKeysError> {
|
||||
self.with_keys([key])
|
||||
}
|
||||
|
||||
pub fn with_keys<'a, I>(mut self, keys: I) -> Result<Self, ParseKeysError>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
if self.0.len() == 1 {
|
||||
self.0[0].serialize(serializer)
|
||||
} else {
|
||||
self.0.serialize(serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for KeyBinding {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
Ok(match SingleOrVec::<KeyPress>::deserialize(deserializer)? {
|
||||
SingleOrVec::Single(key) => Self(vec![key]),
|
||||
SingleOrVec::Vec(keys) => Self(keys),
|
||||
})
|
||||
}
|
||||
}
|
||||
102
cove-input/src/lib.rs
Normal file
102
cove-input/src/lib.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use std::{io, sync::Arc};
|
||||
|
||||
pub use cove_macro::KeyGroup;
|
||||
use crossterm::event::{Event, KeyEvent, KeyEventKind};
|
||||
use parking_lot::FairMutex;
|
||||
use toss::{Frame, Terminal, WidthDb};
|
||||
|
||||
pub use crate::keys::*;
|
||||
|
||||
mod keys;
|
||||
|
||||
pub struct KeyBindingInfo<'a> {
|
||||
pub name: &'static str,
|
||||
pub binding: &'a KeyBinding,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// A group of related key bindings.
|
||||
pub trait KeyGroup {
|
||||
const DESCRIPTION: &'static str;
|
||||
|
||||
fn bindings(&self) -> Vec<KeyBindingInfo<'_>>;
|
||||
}
|
||||
|
||||
pub struct KeyGroupInfo<'a> {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub bindings: Vec<KeyBindingInfo<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> KeyGroupInfo<'a> {
|
||||
pub fn new<G: KeyGroup>(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<FairMutex<()>>,
|
||||
}
|
||||
|
||||
impl<'a> InputEvent<'a> {
|
||||
pub fn new(
|
||||
event: Event,
|
||||
terminal: &'a mut Terminal,
|
||||
crossterm_lock: Arc<FairMutex<()>>,
|
||||
) -> 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<KeyEvent> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
15
cove-macro/Cargo.toml
Normal file
15
cove-macro/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "cove-macro"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2.workspace = true
|
||||
quote.workspace = true
|
||||
syn.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
152
cove-macro/src/document.rs
Normal file
152
cove-macro/src/document.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Data, DataEnum, DataStruct, DeriveInput, Field, Ident, LitStr, spanned::Spanned};
|
||||
|
||||
use crate::util::{self, SerdeDefault};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldInfo {
|
||||
description: Option<String>,
|
||||
metavar: Option<LitStr>,
|
||||
default: Option<LitStr>,
|
||||
serde_default: Option<SerdeDefault>,
|
||||
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<Self> {
|
||||
let mut result = Self::default();
|
||||
result.initialize_from_field(field)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_struct(ident: Ident, data: DataStruct) -> syn::Result<TokenStream> {
|
||||
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<TokenStream> {
|
||||
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 = <String as crate::doc::Document>::doc();
|
||||
doc.value_info.values = Some(vec![ #( #values ),* ]);
|
||||
doc
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
74
cove-macro/src/key_group.rs
Normal file
74
cove-macro/src/key_group.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Data, DeriveInput, spanned::Spanned};
|
||||
|
||||
use crate::util;
|
||||
|
||||
fn decapitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
if let Some(char) = chars.next() {
|
||||
char.to_lowercase().chain(chars).collect()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
|
||||
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 )*
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
23
cove-macro/src/lib.rs
Normal file
23
cove-macro/src/lib.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
mod document;
|
||||
mod key_group;
|
||||
mod util;
|
||||
|
||||
#[proc_macro_derive(Document, attributes(document))]
|
||||
pub fn derive_document(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
match document::derive_impl(input) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(err) => err.into_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro_derive(KeyGroup)]
|
||||
pub fn derive_group(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
match key_group::derive_impl(input) {
|
||||
Ok(tokens) => tokens.into(),
|
||||
Err(err) => err.into_compile_error().into(),
|
||||
}
|
||||
}
|
||||
117
cove-macro/src/util.rs
Normal file
117
cove-macro/src/util.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
use proc_macro2::{Span, TokenStream};
|
||||
use quote::quote;
|
||||
use syn::{
|
||||
Attribute, Expr, ExprLit, ExprPath, Field, Lit, LitStr, Path, Token, Type, parse::Parse,
|
||||
punctuated::Punctuated,
|
||||
};
|
||||
|
||||
pub fn bail<T>(span: Span, message: &str) -> syn::Result<T> {
|
||||
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<LitStr> {
|
||||
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<String> {
|
||||
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<Expr>,
|
||||
}
|
||||
|
||||
impl Parse for AttributeArgument {
|
||||
fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
|
||||
let path = Path::parse(input)?;
|
||||
let value = if input.peek(Token![=]) {
|
||||
input.parse::<Token![=]>()?;
|
||||
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<Vec<AttributeArgument>> {
|
||||
let mut attr_args = vec![];
|
||||
|
||||
for attr in attributes.iter().filter(|attr| attr.path().is_ident(path)) {
|
||||
let args =
|
||||
attr.parse_args_with(Punctuated::<AttributeArgument, Token![,]>::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<Option<SerdeDefault>> {
|
||||
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)
|
||||
}
|
||||
32
cove/Cargo.toml
Normal file
32
cove/Cargo.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "cove"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cove-config = { path = "../cove-config" }
|
||||
cove-input = { path = "../cove-input" }
|
||||
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
clap.workspace = true
|
||||
cookie.workspace = true
|
||||
crossterm.workspace = true
|
||||
directories.workspace = true
|
||||
euphoxide.workspace = true
|
||||
jiff.workspace = true
|
||||
linkify.workspace = true
|
||||
log.workspace = true
|
||||
open.workspace = true
|
||||
parking_lot.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toss.workspace = true
|
||||
unicode-width.workspace = true
|
||||
vault.workspace = true
|
||||
rustls.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
|
||||
pub use highlight::*;
|
||||
pub use room::*;
|
||||
pub use small_message::*;
|
||||
pub use util::*;
|
||||
|
||||
mod highlight;
|
||||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
211
cove/src/euph/highlight.rs
Normal file
211
cove/src/euph/highlight.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::euph::util;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpanType {
|
||||
Mention,
|
||||
Room,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
fn nick_char(ch: char) -> bool {
|
||||
// Closely following the heim mention regex:
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
||||
// `>` has been experimentally confirmed to delimit mentions as well.
|
||||
match ch {
|
||||
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
||||
_ => !ch.is_whitespace(),
|
||||
}
|
||||
}
|
||||
|
||||
fn room_char(ch: char) -> bool {
|
||||
// Basically just \w, see also
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
||||
ch.is_ascii_alphanumeric() || ch == '_'
|
||||
}
|
||||
|
||||
struct SpanFinder<'a> {
|
||||
content: &'a str,
|
||||
|
||||
span: Option<(SpanType, usize)>,
|
||||
room_or_mention_possible: bool,
|
||||
|
||||
result: Vec<(SpanType, Range<usize>)>,
|
||||
}
|
||||
|
||||
impl<'a> SpanFinder<'a> {
|
||||
fn is_valid_span(&self, span: SpanType, range: Range<usize>) -> 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<usize>)> {
|
||||
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<usize>)> {
|
||||
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<usize>)],
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,21 @@
|
|||
// TODO Stop if room does not exist (e. g. 404)
|
||||
use std::{convert::Infallible, time::Duration};
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use euphoxide::api::packet::ParsedPacket;
|
||||
use euphoxide::api::{
|
||||
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time,
|
||||
UserId,
|
||||
use euphoxide::{
|
||||
api::{
|
||||
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply,
|
||||
Time, UserId, packet::ParsedPacket,
|
||||
},
|
||||
bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig},
|
||||
conn::{self, ConnTx, Joined},
|
||||
};
|
||||
use euphoxide::bot::instance::{Event, Instance, InstanceConfig, Snapshot};
|
||||
use euphoxide::conn::{self, ConnTx};
|
||||
use log::{debug, error, info, warn};
|
||||
use tokio::select;
|
||||
use tokio::sync::oneshot;
|
||||
use log::{debug, info, warn};
|
||||
use tokio::{select, sync::oneshot};
|
||||
|
||||
use crate::macros::{logging_unwrap, ok_or_return};
|
||||
use crate::vault::EuphRoomVault;
|
||||
use crate::{macros::logging_unwrap, vault::EuphRoomVault};
|
||||
|
||||
const LOG_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
Disconnected,
|
||||
|
|
@ -35,6 +32,13 @@ impl State {
|
|||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn joined(&self) -> Option<&Joined> {
|
||||
match self {
|
||||
Self::Connected(_, conn::State::Joined(joined)) => Some(joined),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -65,19 +69,13 @@ impl Room {
|
|||
where
|
||||
F: Fn(Event) + std::marker::Send + Sync + 'static,
|
||||
{
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. Cove should not keep retrying log
|
||||
// requests when hitting that limit, so &rl2dev is always opened in
|
||||
// ephemeral mode.
|
||||
let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev";
|
||||
|
||||
Self {
|
||||
vault,
|
||||
ephemeral,
|
||||
ephemeral: vault.vault().vault().ephemeral(),
|
||||
instance: instance_config.build(on_event),
|
||||
state: State::Disconnected,
|
||||
last_msg_id: None,
|
||||
log_request_canary: None,
|
||||
vault,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +104,7 @@ impl Room {
|
|||
self.last_msg_id = None;
|
||||
self.log_request_canary = None;
|
||||
}
|
||||
Event::Connected(_, Snapshot { conn_tx, state }) => {
|
||||
Event::Connected(_, ConnSnapshot { conn_tx, state }) => {
|
||||
if !self.ephemeral {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.log_request_canary = Some(tx);
|
||||
|
|
@ -125,9 +123,10 @@ impl Room {
|
|||
|
||||
let cookies = &*self.instance.config().server.cookies;
|
||||
let cookies = cookies.lock().unwrap().clone();
|
||||
logging_unwrap!(self.vault.vault().set_cookies(cookies).await);
|
||||
let domain = self.vault.room().domain.clone();
|
||||
logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await);
|
||||
}
|
||||
Event::Packet(_, packet, Snapshot { conn_tx, state }) => {
|
||||
Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => {
|
||||
self.state = State::Connected(conn_tx, state);
|
||||
self.on_packet(packet).await;
|
||||
}
|
||||
|
|
@ -137,7 +136,6 @@ impl Room {
|
|||
self.log_request_canary = None;
|
||||
}
|
||||
Event::Stopped(_) => {
|
||||
// TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization
|
||||
self.state = State::Stopped;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,15 +181,9 @@ impl Room {
|
|||
None => None,
|
||||
};
|
||||
|
||||
debug!("{}: requesting logs", vault.room());
|
||||
debug!("{:?}: requesting logs", vault.room());
|
||||
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. By reducing the amount of messages
|
||||
// in each log request, we can get closer to this point. Since &rl2dev
|
||||
// is fairly low in activity, this should be fine.
|
||||
let n = if vault.room() == "rl2dev" { 50 } else { 1000 };
|
||||
|
||||
let _ = conn_tx.send(Log { n, before }).await;
|
||||
let _ = conn_tx.send(Log { n: 1000, before }).await;
|
||||
// The code handling incoming events and replies also handles
|
||||
// `LogReply`s, so we don't need to do anything special here.
|
||||
}
|
||||
|
|
@ -209,7 +201,9 @@ impl Room {
|
|||
|
||||
async fn on_packet(&mut self, packet: ParsedPacket) {
|
||||
let room_name = &self.instance.config().room;
|
||||
let data = ok_or_return!(&packet.content);
|
||||
let Ok(data) = &packet.content else {
|
||||
return;
|
||||
};
|
||||
match data {
|
||||
Data::BounceEvent(_) => {}
|
||||
Data::DisconnectEvent(_) => {}
|
||||
102
cove/src/euph/small_message.rs
Normal file
102
cove/src/euph/small_message.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time, UserId};
|
||||
use jiff::Timestamp;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::{store::Msg, ui::ChatMsg};
|
||||
|
||||
use super::util;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmallMessage {
|
||||
pub id: MessageId,
|
||||
pub parent: Option<MessageId>,
|
||||
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::Id> {
|
||||
self.parent
|
||||
}
|
||||
|
||||
fn seen(&self) -> bool {
|
||||
self.seen
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id {
|
||||
MessageId(Snowflake::MAX)
|
||||
}
|
||||
|
||||
fn nick_emoji(&self) -> Option<String> {
|
||||
Some(util::user_id_emoji(&self.user_id))
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMsg for SmallMessage {
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,27 @@
|
|||
use crossterm::style::{Color, ContentStyle, Stylize};
|
||||
use euphoxide::Emoji;
|
||||
use once_cell::sync::Lazy;
|
||||
use toss::styled::Styled;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
pub static EMOJI: Lazy<Emoji> = Lazy::new(Emoji::load);
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::{Emoji, api::UserId};
|
||||
use toss::{Style, Styled};
|
||||
|
||||
pub static EMOJI: LazyLock<Emoji> = LazyLock::new(Emoji::load);
|
||||
|
||||
pub static EMOJI_LIST: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
let mut list = EMOJI
|
||||
.0
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
list.sort_unstable();
|
||||
list
|
||||
});
|
||||
|
||||
/// Convert HSL to RGB following [this approach from wikipedia][1].
|
||||
///
|
||||
|
|
@ -42,15 +60,37 @@ pub fn nick_color(nick: &str) -> (u8, u8, u8) {
|
|||
hsl_to_rgb(hue, 1.0, 0.72)
|
||||
}
|
||||
|
||||
pub fn nick_style(nick: &str, base: ContentStyle) -> ContentStyle {
|
||||
pub fn nick_style(nick: &str, base: Style) -> Style {
|
||||
let (r, g, b) = nick_color(nick);
|
||||
base.bold().with(Color::Rgb { r, g, b })
|
||||
}
|
||||
|
||||
pub fn style_nick(nick: &str, base: ContentStyle) -> Styled {
|
||||
pub fn style_nick(nick: &str, base: Style) -> Styled {
|
||||
Styled::new(EMOJI.replace(nick), nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_nick_exact(nick: &str, base: ContentStyle) -> Styled {
|
||||
pub fn style_nick_exact(nick: &str, base: Style) -> Styled {
|
||||
Styled::new(nick, nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_mention(mention: &str, base: Style) -> Styled {
|
||||
let nick = mention
|
||||
.strip_prefix('@')
|
||||
.expect("mention must start with @");
|
||||
Styled::new(EMOJI.replace(mention), nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_mention_exact(mention: &str, base: Style) -> Styled {
|
||||
let nick = mention
|
||||
.strip_prefix('@')
|
||||
.expect("mention must start with @");
|
||||
Styled::new(mention, nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn user_id_emoji(user_id: &UserId) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
user_id.0.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()];
|
||||
emoji.clone()
|
||||
}
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
//! Export logs from the vault to plain text files.
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, BufWriter, Write},
|
||||
};
|
||||
|
||||
use crate::vault::{EuphRoomVault, EuphVault, RoomIdentifier};
|
||||
|
||||
mod json;
|
||||
mod text;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
|
||||
use crate::vault::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
pub enum Format {
|
||||
/// Human-readable tree-structured messages.
|
||||
Text,
|
||||
/// Array of message objects in the same format as the euphoria API uses.
|
||||
Json,
|
||||
/// Message objects in the same format as the euphoria API uses, one per line.
|
||||
JsonStream,
|
||||
/// Message objects in the same format as the euphoria API uses, one per
|
||||
/// line (https://jsonlines.org/).
|
||||
JsonLines,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
|
|
@ -23,14 +26,15 @@ impl Format {
|
|||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
Self::JsonStream => "json stream",
|
||||
Self::JsonLines => "json lines",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "txt",
|
||||
Self::Json | Self::JsonStream => "json",
|
||||
Self::Json => "json",
|
||||
Self::JsonLines => "jsonl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +47,10 @@ pub struct Args {
|
|||
#[arg(long, short)]
|
||||
all: bool,
|
||||
|
||||
/// Domain to resolve the room names with.
|
||||
#[arg(long, short, default_value = "euphoria.leet.nu")]
|
||||
domain: String,
|
||||
|
||||
/// Format of the output file.
|
||||
#[arg(long, short, value_enum, default_value_t = Format::Text)]
|
||||
format: Format,
|
||||
|
|
@ -74,7 +82,7 @@ async fn export_room<W: Write>(
|
|||
match format {
|
||||
Format::Text => text::export(vault, out).await?,
|
||||
Format::Json => json::export(vault, out).await?,
|
||||
Format::JsonStream => json::export_stream(vault, out).await?,
|
||||
Format::JsonLines => json::export_lines(vault, out).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -85,7 +93,12 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
let rooms = if args.all {
|
||||
let mut rooms = vault.rooms().await?;
|
||||
let mut rooms = vault
|
||||
.rooms()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|id| id.name)
|
||||
.collect::<Vec<_>>();
|
||||
rooms.sort_unstable();
|
||||
rooms
|
||||
} else {
|
||||
|
|
@ -101,14 +114,14 @@ pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
|
|||
for room in rooms {
|
||||
if args.out == "-" {
|
||||
eprintln!("Exporting &{room} as {} to stdout", args.format.name());
|
||||
let vault = vault.room(room);
|
||||
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
|
||||
let mut stdout = BufWriter::new(io::stdout());
|
||||
export_room(&vault, &mut stdout, args.format).await?;
|
||||
stdout.flush()?;
|
||||
} else {
|
||||
let out = format_out(&args.out, &room, args.format);
|
||||
eprintln!("Exporting &{room} as {} to {out}", args.format.name());
|
||||
let vault = vault.room(room);
|
||||
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
|
||||
let mut file = BufWriter::new(File::create(out)?);
|
||||
export_room(&vault, &mut file, args.format).await?;
|
||||
file.flush()?;
|
||||
|
|
@ -37,7 +37,7 @@ pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export_stream<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
pub async fn export_lines<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
let mut total = 0;
|
||||
let mut last_msg_id = None;
|
||||
loop {
|
||||
|
|
@ -1,16 +1,11 @@
|
|||
use std::io::Write;
|
||||
|
||||
use euphoxide::api::MessageId;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::euph::SmallMessage;
|
||||
use crate::store::Tree;
|
||||
use crate::vault::EuphRoomVault;
|
||||
use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault};
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] =
|
||||
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
|
||||
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
const TIME_EMPTY: &str = " ";
|
||||
|
||||
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
|
||||
|
|
@ -67,11 +62,7 @@ fn write_msg<W: Write>(
|
|||
|
||||
for (i, line) in msg.content.lines().enumerate() {
|
||||
if i == 0 {
|
||||
let time = msg
|
||||
.time
|
||||
.0
|
||||
.format(TIME_FORMAT)
|
||||
.expect("time can be formatted");
|
||||
let time = msg.time.as_timestamp().strftime(TIME_FORMAT);
|
||||
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
|
||||
} else {
|
||||
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
use std::{convert::Infallible, sync::Arc, vec};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::Timestamp;
|
||||
use log::{Level, LevelFilter, Log};
|
||||
use parking_lot::Mutex;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::styled::Styled;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Path, Tree};
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::{
|
||||
store::{Msg, MsgStore, Path, Tree},
|
||||
ui::ChatMsg,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogMsg {
|
||||
id: usize,
|
||||
time: OffsetDateTime,
|
||||
time: Timestamp,
|
||||
level: Level,
|
||||
content: String,
|
||||
}
|
||||
|
|
@ -42,17 +42,17 @@ impl Msg for LogMsg {
|
|||
}
|
||||
|
||||
impl ChatMsg for LogMsg {
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time
|
||||
fn time(&self) -> Option<Timestamp> {
|
||||
Some(self.time)
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
let nick_style = match self.level {
|
||||
Level::Error => ContentStyle::default().bold().red(),
|
||||
Level::Warn => ContentStyle::default().bold().yellow(),
|
||||
Level::Info => ContentStyle::default().bold().green(),
|
||||
Level::Debug => ContentStyle::default().bold().blue(),
|
||||
Level::Trace => ContentStyle::default().bold().magenta(),
|
||||
Level::Error => Style::new().bold().red(),
|
||||
Level::Warn => Style::new().bold().yellow(),
|
||||
Level::Info => Style::new().bold().green(),
|
||||
Level::Debug => Style::new().bold().blue(),
|
||||
Level::Trace => Style::new().bold().magenta(),
|
||||
};
|
||||
let nick = Styled::new(format!("{}", self.level), nick_style);
|
||||
let content = Styled::new_plain(&self.content);
|
||||
|
|
@ -209,7 +209,7 @@ impl Log for Logger {
|
|||
let mut guard = self.messages.lock();
|
||||
let msg = LogMsg {
|
||||
id: guard.len(),
|
||||
time: OffsetDateTime::now_utc(),
|
||||
time: Timestamp::now(),
|
||||
level: record.level(),
|
||||
content: format!("<{}> {}", record.target(), record.args()),
|
||||
};
|
||||
12
cove/src/macros.rs
Normal file
12
cove/src/macros.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
macro_rules! logging_unwrap {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
log::error!("{err}");
|
||||
panic!("{err}");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use logging_unwrap;
|
||||
253
cove/src/main.rs
Normal file
253
cove/src/main.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
// TODO Remove unnecessary Debug impls and compare compile times
|
||||
// TODO Invoke external notification command?
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use cove_config::{Config, doc::Document};
|
||||
use directories::{BaseDirs, ProjectDirs};
|
||||
use log::info;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::Terminal;
|
||||
|
||||
use crate::{
|
||||
logger::Logger,
|
||||
ui::Ui,
|
||||
vault::Vault,
|
||||
version::{NAME, VERSION},
|
||||
};
|
||||
|
||||
mod euph;
|
||||
mod export;
|
||||
mod logger;
|
||||
mod macros;
|
||||
mod store;
|
||||
mod ui;
|
||||
mod util;
|
||||
mod vault;
|
||||
mod version;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
enum Command {
|
||||
/// Run the client interactively (default).
|
||||
Run,
|
||||
/// Export room logs as plain text files.
|
||||
Export(export::Args),
|
||||
/// Compact and clean up vault.
|
||||
Gc,
|
||||
/// Clear euphoria session cookies.
|
||||
ClearCookies {
|
||||
/// Clear cookies for a specific domain only.
|
||||
#[arg(long, short)]
|
||||
domain: Option<String>,
|
||||
},
|
||||
/// 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<PathBuf>,
|
||||
|
||||
/// Path to a directory for cove to store its data in.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the current directory.
|
||||
#[arg(long, short)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// If set, cove won't store data permanently.
|
||||
#[arg(long, short)]
|
||||
ephemeral: bool,
|
||||
|
||||
/// If set, cove will ignore the autojoin config option.
|
||||
#[arg(long, short)]
|
||||
offline: bool,
|
||||
|
||||
/// Method for estimating the width of characters as displayed by the
|
||||
/// terminal emulator.
|
||||
#[arg(long, short)]
|
||||
width_estimation_method: Option<WidthEstimationMethod>,
|
||||
|
||||
/// Measure the width of characters as displayed by the terminal emulator
|
||||
/// instead of guessing the width.
|
||||
#[arg(long, short)]
|
||||
measure_widths: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
fn 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<Vault> {
|
||||
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<String>,
|
||||
) -> 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());
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::vec;
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash, vec};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
|
|
@ -11,6 +8,10 @@ pub trait Msg {
|
|||
fn parent(&self) -> Option<Self::Id>;
|
||||
fn seen(&self) -> bool;
|
||||
|
||||
fn nick_emoji(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id;
|
||||
}
|
||||
|
||||
|
|
@ -27,12 +28,12 @@ impl<I> Path<I> {
|
|||
self.0.iter().take(self.0.len() - 1)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, segment: I) {
|
||||
self.0.push(segment)
|
||||
pub fn first(&self) -> &I {
|
||||
self.0.first().expect("path is empty")
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &I {
|
||||
self.0.first().expect("path is not empty")
|
||||
pub fn into_first(self) -> I {
|
||||
self.0.into_iter().next().expect("path is empty")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +131,7 @@ impl<M: Msg> Tree<M> {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[async_trait]
|
||||
pub trait MsgStore<M: Msg> {
|
||||
type Error;
|
||||
|
|
@ -1,37 +1,56 @@
|
|||
use std::{
|
||||
convert::Infallible,
|
||||
io,
|
||||
sync::{Arc, Weak},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use cove_config::Config;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::tz::TimeZone;
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::{
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender, error::TryRecvError},
|
||||
task,
|
||||
};
|
||||
use toss::{Terminal, WidgetExt, widgets::BoxedAsync};
|
||||
|
||||
use crate::{
|
||||
logger::{LogMsg, Logger},
|
||||
macros::logging_unwrap,
|
||||
util::InfallibleExt,
|
||||
vault::Vault,
|
||||
};
|
||||
|
||||
pub use self::chat::ChatMsg;
|
||||
use self::{chat::ChatState, rooms::Rooms, widgets::ListState};
|
||||
|
||||
mod chat;
|
||||
mod euph;
|
||||
mod input;
|
||||
mod key_bindings;
|
||||
mod rooms;
|
||||
mod util;
|
||||
mod widgets;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::task;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logger::{LogMsg, Logger};
|
||||
use crate::macros::{logging_unwrap, ok_or_return, some_or_return};
|
||||
use crate::vault::Vault;
|
||||
|
||||
pub use self::chat::ChatMsg;
|
||||
use self::chat::ChatState;
|
||||
use self::input::{key, InputEvent, KeyBindingsList};
|
||||
use self::rooms::Rooms;
|
||||
use self::widgets::layer::Layer;
|
||||
use self::widgets::list::ListState;
|
||||
use self::widgets::BoxedWidget;
|
||||
|
||||
/// Time to spend batch processing events before redrawing the screen.
|
||||
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
|
||||
|
||||
/// Error for anything that can go wrong while rendering.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UiError {
|
||||
#[error("{0}")]
|
||||
Vault(#[from] vault::tokio::Error<rusqlite::Error>),
|
||||
#[error("{0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
impl From<Infallible> for UiError {
|
||||
fn from(value: Infallible) -> Self {
|
||||
Err(value).infallible()
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::large_enum_variant)]
|
||||
pub enum UiEvent {
|
||||
GraphemeWidthsChanged,
|
||||
LogChanged,
|
||||
|
|
@ -51,16 +70,17 @@ enum Mode {
|
|||
Log,
|
||||
}
|
||||
|
||||
// TODO Add Error for anything that can go wrong while rendering
|
||||
|
||||
pub struct Ui {
|
||||
config: &'static Config,
|
||||
event_tx: UnboundedSender<UiEvent>,
|
||||
|
||||
mode: Mode,
|
||||
|
||||
rooms: Rooms,
|
||||
log_chat: ChatState<LogMsg, Logger>,
|
||||
key_bindings_list: Option<ListState<Infallible>>,
|
||||
|
||||
key_bindings_visible: bool,
|
||||
key_bindings_list: ListState<Infallible>,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
|
|
@ -68,6 +88,7 @@ impl Ui {
|
|||
|
||||
pub async fn run(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
terminal: &mut Terminal,
|
||||
vault: Vault,
|
||||
logger: Logger,
|
||||
|
|
@ -93,11 +114,13 @@ impl Ui {
|
|||
// On the other hand, if the crossterm_event_task stops for any reason,
|
||||
// the rest of the UI is also shut down and the client stops.
|
||||
let mut ui = Self {
|
||||
config,
|
||||
event_tx: event_tx.clone(),
|
||||
mode: Mode::Main,
|
||||
rooms: Rooms::new(config, vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger),
|
||||
key_bindings_list: None,
|
||||
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger, tz),
|
||||
key_bindings_visible: false,
|
||||
key_bindings_list: ListState::new(),
|
||||
};
|
||||
tokio::select! {
|
||||
e = ui.run_main(terminal, event_rx, crossterm_lock) => e?,
|
||||
|
|
@ -110,13 +133,17 @@ impl Ui {
|
|||
fn poll_crossterm_events(
|
||||
tx: UnboundedSender<UiEvent>,
|
||||
lock: Weak<FairMutex<()>>,
|
||||
) -> crossterm::Result<()> {
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
let lock = some_or_return!(lock.upgrade(), Ok(()));
|
||||
let Some(lock) = lock.upgrade() else {
|
||||
return Ok(());
|
||||
};
|
||||
let _guard = lock.lock();
|
||||
if crossterm::event::poll(Self::POLL_DURATION)? {
|
||||
let event = crossterm::event::read()?;
|
||||
ok_or_return!(tx.send(UiEvent::Term(event)), Ok(()));
|
||||
if tx.send(UiEvent::Term(event)).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +153,12 @@ impl Ui {
|
|||
event_tx: &UnboundedSender<UiEvent>,
|
||||
) {
|
||||
loop {
|
||||
some_or_return!(logger_rx.recv().await);
|
||||
ok_or_return!(event_tx.send(UiEvent::LogChanged));
|
||||
if logger_rx.recv().await.is_none() {
|
||||
return;
|
||||
}
|
||||
if event_tx.send(UiEvent::LogChanged).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,27 +167,28 @@ impl Ui {
|
|||
terminal: &mut Terminal,
|
||||
mut event_rx: UnboundedReceiver<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()?;
|
||||
) -> Result<(), UiError> {
|
||||
let mut redraw = true;
|
||||
|
||||
loop {
|
||||
// 1. Measure grapheme widths if required
|
||||
if terminal.measuring_required() {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.measure_widths()?;
|
||||
ok_or_return!(self.event_tx.send(UiEvent::GraphemeWidthsChanged), Ok(()));
|
||||
// Redraw if necessary
|
||||
if redraw {
|
||||
redraw = false;
|
||||
terminal.present_async_widget(self.widget().await).await?;
|
||||
|
||||
if terminal.measuring_required() {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.measure_widths()?;
|
||||
if self.event_tx.send(UiEvent::GraphemeWidthsChanged).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle events (in batches)
|
||||
let mut event = match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => return Ok(()),
|
||||
// Handle events (in batches)
|
||||
let Some(mut event) = event_rx.recv().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut redraw = false;
|
||||
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
|
||||
loop {
|
||||
match self.handle_event(terminal, &crossterm_lock, event).await {
|
||||
|
|
@ -173,49 +205,23 @@ impl Ui {
|
|||
Err(TryRecvError::Disconnected) => return Ok(()),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Render and present final state
|
||||
if redraw {
|
||||
terminal.autoresize()?;
|
||||
terminal.frame().reset();
|
||||
self.widget().await.render(terminal.frame()).await;
|
||||
terminal.present()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn widget(&mut self) -> BoxedWidget {
|
||||
async fn widget(&mut self) -> BoxedAsync<'_, UiError> {
|
||||
let widget = match self.mode {
|
||||
Mode::Main => self.rooms.widget().await,
|
||||
Mode::Log => self.log_chat.widget(String::new(), true).into(),
|
||||
Mode::Log => self.log_chat.widget(String::new(), true),
|
||||
};
|
||||
|
||||
if let Some(key_bindings_list) = &self.key_bindings_list {
|
||||
let mut bindings = KeyBindingsList::new(key_bindings_list);
|
||||
self.list_key_bindings(&mut bindings).await;
|
||||
Layer::new(vec![widget, bindings.widget()]).into()
|
||||
if self.key_bindings_visible {
|
||||
let popup = key_bindings::widget(&mut self.key_bindings_list, self.config);
|
||||
popup.desync().above(widget).boxed_async()
|
||||
} else {
|
||||
widget
|
||||
}
|
||||
}
|
||||
|
||||
fn show_key_bindings(&mut self) {
|
||||
if self.key_bindings_list.is_none() {
|
||||
self.key_bindings_list = Some(ListState::new())
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("ctrl+c", "quit cove");
|
||||
bindings.binding("F1, ?", "show this menu");
|
||||
bindings.binding("F12", "toggle log");
|
||||
bindings.empty();
|
||||
match self.mode {
|
||||
Mode::Main => self.rooms.list_key_bindings(bindings).await,
|
||||
Mode::Log => self.log_chat.list_key_bindings(bindings, false).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
|
|
@ -228,7 +234,7 @@ impl Ui {
|
|||
UiEvent::LogChanged => EventHandleResult::Continue,
|
||||
UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw,
|
||||
UiEvent::Term(event) => {
|
||||
self.handle_term_event(terminal, crossterm_lock, event)
|
||||
self.handle_term_event(terminal, crossterm_lock.clone(), event)
|
||||
.await
|
||||
}
|
||||
UiEvent::Euph(event) => {
|
||||
|
|
@ -244,74 +250,62 @@ impl Ui {
|
|||
async fn handle_term_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
crossterm_lock: Arc<FairMutex<()>>,
|
||||
event: crossterm::event::Event,
|
||||
) -> EventHandleResult {
|
||||
let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue);
|
||||
let mut event = InputEvent::new(event, terminal, crossterm_lock);
|
||||
let keys = &self.config.keys;
|
||||
|
||||
if let key!(Ctrl + 'c') = event {
|
||||
// Exit unconditionally on ctrl+c. Previously, shift+q would also
|
||||
// unconditionally exit, but that interfered with typing text in
|
||||
// inline editors.
|
||||
if event.matches(&keys.general.exit) {
|
||||
return EventHandleResult::Stop;
|
||||
}
|
||||
|
||||
// Key bindings list overrides any other bindings if visible
|
||||
if let Some(key_bindings_list) = &mut self.key_bindings_list {
|
||||
match event {
|
||||
key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None,
|
||||
key!('k') | key!(Up) => key_bindings_list.scroll_up(1),
|
||||
key!('j') | key!(Down) => key_bindings_list.scroll_down(1),
|
||||
_ => return EventHandleResult::Continue,
|
||||
if self.key_bindings_visible {
|
||||
if event.matches(&keys.general.abort) || event.matches(&keys.general.help) {
|
||||
self.key_bindings_visible = false;
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
if key_bindings::handle_input_event(&mut self.key_bindings_list, &mut event, keys) {
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
// ... and does not let anything below the popup receive events
|
||||
return EventHandleResult::Continue;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.help) {
|
||||
self.key_bindings_visible = true;
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
|
||||
match event {
|
||||
key!(F 1) => {
|
||||
self.key_bindings_list = Some(ListState::new());
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
key!(F 12) => {
|
||||
self.mode = match self.mode {
|
||||
Mode::Main => Mode::Log,
|
||||
Mode::Log => Mode::Main,
|
||||
};
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut handled = match self.mode {
|
||||
match self.mode {
|
||||
Mode::Main => {
|
||||
self.rooms
|
||||
.handle_input_event(terminal, crossterm_lock, &event)
|
||||
.await
|
||||
if event.matches(&keys.general.log) {
|
||||
self.mode = Mode::Log;
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
|
||||
if self.rooms.handle_input_event(&mut event, keys).await {
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
}
|
||||
Mode::Log => {
|
||||
if event.matches(&keys.general.abort) || event.matches(&keys.general.log) {
|
||||
self.mode = Mode::Main;
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
|
||||
let reaction = self
|
||||
.log_chat
|
||||
.handle_input_event(terminal, crossterm_lock, &event, false)
|
||||
.handle_input_event(&mut event, keys, false)
|
||||
.await;
|
||||
let reaction = logging_unwrap!(reaction);
|
||||
reaction.handled()
|
||||
}
|
||||
};
|
||||
|
||||
// Pressing '?' should only open the key bindings list if it doesn't
|
||||
// interfere with any part of the main UI, such as entering text in a
|
||||
// text editor.
|
||||
if !handled {
|
||||
if let key!('?') = event {
|
||||
self.show_key_bindings();
|
||||
handled = true;
|
||||
if reaction.handled() {
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if handled {
|
||||
EventHandleResult::Redraw
|
||||
} else {
|
||||
EventHandleResult::Continue
|
||||
}
|
||||
EventHandleResult::Continue
|
||||
}
|
||||
}
|
||||
186
cove/src/ui/chat.rs
Normal file
186
cove/src/ui/chat.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
use toss::{
|
||||
Styled, WidgetExt,
|
||||
widgets::{BoxedAsync, EditorState},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
util,
|
||||
};
|
||||
|
||||
use super::UiError;
|
||||
|
||||
use self::{cursor::Cursor, tree::TreeViewState};
|
||||
|
||||
mod blocks;
|
||||
mod cursor;
|
||||
mod renderer;
|
||||
mod tree;
|
||||
mod widgets;
|
||||
|
||||
pub trait ChatMsg {
|
||||
fn time(&self) -> Option<Timestamp>;
|
||||
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<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
|
||||
cursor: Cursor<M::Id>,
|
||||
editor: EditorState,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
|
||||
mode: Mode,
|
||||
tree: TreeViewState<M, S>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||
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<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
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<S::Error>,
|
||||
{
|
||||
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<Reaction<M>, 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<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)
|
||||
}
|
||||
}
|
||||
214
cove/src/ui/chat/blocks.rs
Normal file
214
cove/src/ui/chat/blocks.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Common rendering logic.
|
||||
|
||||
use std::collections::{VecDeque, vec_deque};
|
||||
|
||||
use toss::widgets::Predrawn;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Range<T> {
|
||||
pub top: T,
|
||||
pub bottom: T,
|
||||
}
|
||||
|
||||
impl<T> Range<T> {
|
||||
pub fn new(top: T, bottom: T) -> Self {
|
||||
Self { top, bottom }
|
||||
}
|
||||
}
|
||||
|
||||
impl Range<i32> {
|
||||
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: Id,
|
||||
widget: Predrawn,
|
||||
focus: Range<i32>,
|
||||
can_be_cursor: bool,
|
||||
}
|
||||
|
||||
impl<Id> Block<Id> {
|
||||
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<i32>) {
|
||||
assert!(0 <= focus.top);
|
||||
assert!(focus.top <= focus.bottom);
|
||||
assert!(focus.bottom <= self.height());
|
||||
self.focus = focus;
|
||||
}
|
||||
|
||||
pub fn focus(&self, range: Range<i32>) -> Range<i32> {
|
||||
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<Id> {
|
||||
blocks: VecDeque<Block<Id>>,
|
||||
range: Range<i32>,
|
||||
end: Range<bool>,
|
||||
}
|
||||
|
||||
impl<Id> Blocks<Id> {
|
||||
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<i32> {
|
||||
self.range
|
||||
}
|
||||
|
||||
pub fn end(&self) -> Range<bool> {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_, Id> {
|
||||
Iter {
|
||||
iter: self.blocks.iter(),
|
||||
range: self.range,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_iter(self) -> IntoIter<Id> {
|
||||
IntoIter {
|
||||
iter: self.blocks.into_iter(),
|
||||
range: self.range,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_block(&self, id: &Id) -> Option<(Range<i32>, &Block<Id>)>
|
||||
where
|
||||
Id: Eq,
|
||||
{
|
||||
self.iter().find(|(_, block)| block.id == *id)
|
||||
}
|
||||
|
||||
pub fn push_top(&mut self, block: Block<Id>) {
|
||||
assert!(!self.end.top);
|
||||
self.range.top -= block.height();
|
||||
self.blocks.push_front(block);
|
||||
}
|
||||
|
||||
pub fn push_bottom(&mut self, block: Block<Id>) {
|
||||
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<Id>>,
|
||||
range: Range<i32>,
|
||||
}
|
||||
|
||||
impl<'a, Id> Iterator for Iter<'a, Id> {
|
||||
type Item = (Range<i32>, &'a Block<Id>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<Id> DoubleEndedIterator for Iter<'_, Id> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
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<Id> {
|
||||
iter: vec_deque::IntoIter<Block<Id>>,
|
||||
range: Range<i32>,
|
||||
}
|
||||
|
||||
impl<Id> Iterator for IntoIter<Id> {
|
||||
type Item = (Range<i32>, Block<Id>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<Id> DoubleEndedIterator for IntoIter<Id> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
527
cove/src/ui/chat/cursor.rs
Normal file
527
cove/src/ui/chat/cursor.rs
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//! Common cursor movement logic.
|
||||
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Cursor<Id> {
|
||||
Bottom,
|
||||
Msg(Id),
|
||||
Editor {
|
||||
coming_from: Option<Id>,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
Pseudo {
|
||||
coming_from: Option<Id>,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<Id: Clone + Eq + Hash> Cursor<Id> {
|
||||
fn find_parent<M>(tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
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<M, S>(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M>(folded: &HashSet<Id>, tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
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<M>(folded: &HashSet<Id>, tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
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<M, S>(
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
// 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<M, S>(
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(
|
||||
&self,
|
||||
store: &S,
|
||||
) -> Result<Option<Option<M::Id>>, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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<M, S>(
|
||||
&self,
|
||||
store: &S,
|
||||
) -> Result<Option<Option<M::Id>>, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
328
cove/src/ui/chat/renderer.rs
Normal file
328
cove/src/ui/chat/renderer.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
use std::cmp::Ordering;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use toss::Size;
|
||||
|
||||
use super::blocks::{Blocks, Range};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Renderer<Id> {
|
||||
type Error;
|
||||
|
||||
fn size(&self) -> Size;
|
||||
fn scrolloff(&self) -> i32;
|
||||
|
||||
fn blocks(&self) -> &Blocks<Id>;
|
||||
fn blocks_mut(&mut self) -> &mut Blocks<Id>;
|
||||
|
||||
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<Id, R>(r: &R) -> Range<i32>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &R) -> Range<i32>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<i32>, object: Range<i32>) -> 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<i32>, object: Range<i32>) -> bool {
|
||||
overlap_delta(area, object) == 0
|
||||
}
|
||||
|
||||
/// Move the object such that it overlaps the area.
|
||||
fn overlap(area: Range<i32>, object: Range<i32>) -> Range<i32> {
|
||||
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<i32>, object: Range<i32>) -> 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<Id, R>(r: &mut R, top: i32) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
loop {
|
||||
let blocks = r.blocks();
|
||||
if blocks.end().top || blocks.range().top <= top {
|
||||
break;
|
||||
}
|
||||
|
||||
r.expand_top().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expand_downwards_until<Id, R>(r: &mut R, bottom: i32) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R, id: &Id) -> Result<(), R::Error>
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R, id: &Id, top: i32) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R, id: &Id)
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R, id: &Id) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R, id: &Id) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
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<Id>,
|
||||
{
|
||||
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()),
|
||||
}
|
||||
}
|
||||
480
cove/src/ui/chat/tree.rs
Normal file
480
cove/src/ui/chat/tree.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
//! Rendering messages as full trees.
|
||||
|
||||
// TODO Focusing on sub-trees
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb, widgets::EditorState};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{UiError, util},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
|
||||
use super::{ChatMsg, Reaction, cursor::Cursor};
|
||||
|
||||
use self::renderer::{TreeContext, TreeRenderer};
|
||||
|
||||
mod renderer;
|
||||
mod scroll;
|
||||
mod widgets;
|
||||
|
||||
pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
tz: TimeZone,
|
||||
|
||||
last_size: Size,
|
||||
last_nick: String,
|
||||
last_cursor: Cursor<M::Id>,
|
||||
last_cursor_top: i32,
|
||||
last_visible_msgs: Vec<M::Id>,
|
||||
|
||||
folded: HashSet<M::Id>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||
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<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
) -> Result<bool, S::Error>
|
||||
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<bool, S::Error> {
|
||||
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<M::Id>,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
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<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
can_compose: bool,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error>
|
||||
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<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
coming_from: Option<M::Id>,
|
||||
parent: Option<M::Id>,
|
||||
) -> Reaction<M> {
|
||||
// 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<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, 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<M::Id>,
|
||||
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<M>> {
|
||||
state: &'a mut TreeViewState<M, S>,
|
||||
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
|
||||
nick: String,
|
||||
focused: bool,
|
||||
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> AsyncWidget<UiError> for TreeView<'_, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
UiError: From<S::Error>,
|
||||
{
|
||||
async fn size(
|
||||
&self,
|
||||
_widthdb: &mut WidthDb,
|
||||
_max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, UiError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
523
cove/src/ui/chat/tree/renderer.rs
Normal file
523
cove/src/ui/chat/tree/renderer.rs
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
//! A [`Renderer`] for message trees.
|
||||
|
||||
use std::{collections::HashSet, convert::Infallible};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Size, Widget, WidthDb,
|
||||
widgets::{EditorState, Empty, Predrawn, Resize},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore, Tree},
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::{
|
||||
blocks::{Block, Blocks, Range},
|
||||
cursor::Cursor,
|
||||
renderer::{self, Renderer, overlaps},
|
||||
},
|
||||
},
|
||||
util::InfallibleExt,
|
||||
};
|
||||
|
||||
use super::widgets;
|
||||
|
||||
/// When rendering messages as full trees, special ids and zero-height messages
|
||||
/// are used for robust scrolling behaviour.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum TreeBlockId<Id> {
|
||||
/// 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<Id: Clone> TreeBlockId<Id> {
|
||||
pub fn from_cursor(cursor: &Cursor<Id>) -> 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<Id> = Block<TreeBlockId<Id>>;
|
||||
type TreeBlocks<Id> = Blocks<TreeBlockId<Id>>;
|
||||
|
||||
pub struct TreeContext<Id> {
|
||||
pub size: Size,
|
||||
pub nick: String,
|
||||
pub focused: bool,
|
||||
pub nick_emoji: bool,
|
||||
pub caesar: i8,
|
||||
pub last_cursor: Cursor<Id>,
|
||||
pub last_cursor_top: i32,
|
||||
}
|
||||
|
||||
pub struct TreeRenderer<'a, M: Msg, S: MsgStore<M>> {
|
||||
context: TreeContext<M::Id>,
|
||||
|
||||
store: &'a S,
|
||||
tz: &'a TimeZone,
|
||||
folded: &'a mut HashSet<M::Id>,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
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<M::Id>,
|
||||
/// 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<M::Id>,
|
||||
|
||||
blocks: TreeBlocks<M::Id>,
|
||||
}
|
||||
|
||||
impl<'a, M, S> TreeRenderer<'a, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
/// You must call [`Self::prepare_blocks_for_drawing`] immediately after
|
||||
/// calling this function.
|
||||
pub fn new(
|
||||
context: TreeContext<M::Id>,
|
||||
store: &'a S,
|
||||
tz: &'a TimeZone,
|
||||
folded: &'a mut HashSet<M::Id>,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
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<W>(widget: W, size: Size, widthdb: &mut WidthDb) -> Predrawn
|
||||
where
|
||||
W: Widget<Infallible>,
|
||||
{
|
||||
Predrawn::new(Resize::new(widget).with_max_width(size.width), widthdb).infallible()
|
||||
}
|
||||
|
||||
fn zero_height_block(&mut self, parent: Option<&M::Id>) -> TreeBlock<M::Id> {
|
||||
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<M::Id> {
|
||||
let id = match parent {
|
||||
Some(parent) => TreeBlockId::After(parent.clone()),
|
||||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
let widget = widgets::editor::<M>(
|
||||
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<M::Id> {
|
||||
let id = match parent {
|
||||
Some(parent) => TreeBlockId::After(parent.clone()),
|
||||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
let widget = widgets::pseudo::<M>(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<usize>,
|
||||
) -> TreeBlock<M::Id> {
|
||||
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<usize>,
|
||||
) -> TreeBlock<M::Id> {
|
||||
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<M::Id> {
|
||||
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<M>,
|
||||
indent: usize,
|
||||
msg_id: &M::Id,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) {
|
||||
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<M>) -> TreeBlocks<M::Id> {
|
||||
let mut blocks = Blocks::new(0);
|
||||
self.layout_subtree(&tree, 0, tree.root(), &mut blocks);
|
||||
blocks
|
||||
}
|
||||
|
||||
async fn root_id(&self, id: &TreeBlockId<M::Id>) -> Result<Option<M::Id>, 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<M::Id>,
|
||||
root_id: &Option<M::Id>,
|
||||
) -> 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<M::Id>, second: Option<M::Id>) -> 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<M::Id>,
|
||||
last_cursor_top: &mut i32,
|
||||
last_visible_msgs: &mut Vec<M::Id>,
|
||||
) {
|
||||
*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<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> + use<M, S> {
|
||||
let area = renderer::visible_area(&self);
|
||||
self.blocks
|
||||
.into_iter()
|
||||
.filter(move |(range, block)| overlaps(area, block.focus(*range)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> Renderer<TreeBlockId<M::Id>> for TreeRenderer<'_, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + 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<M::Id> {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
fn blocks_mut(&mut self) -> &mut TreeBlocks<M::Id> {
|
||||
&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(())
|
||||
}
|
||||
}
|
||||
88
cove/src/ui/chat/tree/scroll.rs
Normal file
88
cove/src/ui/chat/tree/scroll.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use toss::{WidthDb, widgets::EditorState};
|
||||
|
||||
use crate::{
|
||||
store::{Msg, MsgStore},
|
||||
ui::{ChatMsg, chat::cursor::Cursor},
|
||||
};
|
||||
|
||||
use super::{
|
||||
TreeViewState,
|
||||
renderer::{TreeContext, TreeRenderer},
|
||||
};
|
||||
|
||||
impl<M, S> TreeViewState<M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
fn last_context(&self) -> TreeContext<M::Id> {
|
||||
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<M::Id>,
|
||||
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<M::Id>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
214
cove/src/ui/chat/tree/widgets.rs
Normal file
214
cove/src/ui/chat/tree/widgets.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::tz::TimeZone;
|
||||
use toss::{
|
||||
Style, Styled, WidgetExt,
|
||||
widgets::{Boxed, EditorState, Join2, Join4, Join5, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
store::Msg,
|
||||
ui::{
|
||||
ChatMsg,
|
||||
chat::widgets::{Indent, Seen, Time},
|
||||
},
|
||||
util,
|
||||
};
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
pub fn style_placeholder() -> Style {
|
||||
Style::new().dark_grey()
|
||||
}
|
||||
|
||||
fn style_time(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::new().black().on_white()
|
||||
} else {
|
||||
Style::new().grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_indent(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::new().black().on_white()
|
||||
} else {
|
||||
Style::new().dark_grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_caesar() -> Style {
|
||||
Style::new().green()
|
||||
}
|
||||
|
||||
fn style_info() -> Style {
|
||||
Style::new().italic().dark_grey()
|
||||
}
|
||||
|
||||
fn style_editor_highlight() -> Style {
|
||||
Style::new().black().on_cyan()
|
||||
}
|
||||
|
||||
fn style_pseudo_highlight() -> Style {
|
||||
Style::new().black().on_yellow()
|
||||
}
|
||||
|
||||
pub fn msg<M: Msg + ChatMsg>(
|
||||
highlighted: bool,
|
||||
tz: TimeZone,
|
||||
indent: usize,
|
||||
msg: &M,
|
||||
nick_emoji: bool,
|
||||
caesar: i8,
|
||||
folded_info: Option<usize>,
|
||||
) -> 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<usize>,
|
||||
) -> 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()
|
||||
}
|
||||
117
cove/src/ui/chat/widgets.rs
Normal file
117
cove/src/ui/chat/widgets.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use jiff::Zoned;
|
||||
use toss::{
|
||||
Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Boxed, Empty, Text},
|
||||
};
|
||||
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
pub const INDENT_STR: &str = "│ ";
|
||||
pub const INDENT_WIDTH: usize = 2;
|
||||
|
||||
pub struct Indent {
|
||||
level: usize,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Indent {
|
||||
pub fn new(level: usize, style: Style) -> Self {
|
||||
Self { level, style }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Widget<E> for Indent {
|
||||
fn size(
|
||||
&self,
|
||||
_widthdb: &mut WidthDb,
|
||||
_max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
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<Zoned>, 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<E> Widget<E> for Time {
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
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<E> Widget<E> for Seen {
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
Ok(self.0.size(widthdb, max_width, max_height).infallible())
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
self.0.draw(frame).infallible();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
195
cove/src/ui/euph/account.rs
Normal file
195
cove/src/ui/euph/account.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{api::PersonalAccountView, conn};
|
||||
use toss::{
|
||||
Style, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join3, Join4, Join5, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Email,
|
||||
Password,
|
||||
}
|
||||
|
||||
pub struct LoggedOut {
|
||||
focus: Focus,
|
||||
email: EditorState,
|
||||
password: EditorState,
|
||||
}
|
||||
|
||||
impl LoggedOut {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
focus: Focus::Email,
|
||||
email: EditorState::new(),
|
||||
password: EditorState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(&mut self) -> impl Widget<UiError> {
|
||||
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<UiError> + 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<UiError> {
|
||||
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<Room>,
|
||||
) -> 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
|
||||
}
|
||||
}
|
||||
45
cove/src/ui/euph/auth.rs
Normal file
45
cove/src/ui/euph/auth.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use toss::{Widget, widgets::EditorState};
|
||||
|
||||
use crate::{
|
||||
euph::Room,
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
pub fn new() -> EditorState {
|
||||
EditorState::new()
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
|
||||
Popup::new(
|
||||
editor.widget().with_hidden_default_placeholder(),
|
||||
"Enter password",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
event: &mut InputEvent<'_>,
|
||||
keys: &Keys,
|
||||
room: &Option<Room>,
|
||||
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
|
||||
}
|
||||
|
|
@ -1,41 +1,46 @@
|
|||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use euphoxide::api::{Message, NickEvent, SessionView};
|
||||
use euphoxide::conn::SessionInfo;
|
||||
use toss::styled::Styled;
|
||||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::{Message, NickEvent, SessionView},
|
||||
conn::SessionInfo,
|
||||
};
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
macro_rules! line {
|
||||
( $text:ident, $name:expr, $val:expr ) => {
|
||||
$text = $text
|
||||
.then($name, ContentStyle::default().cyan())
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {}\n", $val));
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, debug ) => {
|
||||
$text = $text
|
||||
.then($name, ContentStyle::default().cyan())
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {:?}\n", $val));
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, optional ) => {
|
||||
if let Some(val) = $val {
|
||||
$text = $text
|
||||
.then($name, ContentStyle::default().cyan())
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {val}\n"));
|
||||
} else {
|
||||
$text = $text
|
||||
.then($name, ContentStyle::default().cyan())
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(" ")
|
||||
.then("none", ContentStyle::default().italic().grey())
|
||||
.then("none", Style::new().italic().grey())
|
||||
.then_plain("\n");
|
||||
}
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, yes or no ) => {
|
||||
$text = $text
|
||||
.then($name, ContentStyle::default().cyan())
|
||||
.then_plain(if $val { " yes\n" } else { " no\n" });
|
||||
$text = $text.then($name, Style::new().cyan()).then_plain(if $val {
|
||||
" yes\n"
|
||||
} else {
|
||||
" no\n"
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -86,8 +91,8 @@ fn message_lines(mut text: Styled, msg: &Message) -> Styled {
|
|||
text
|
||||
}
|
||||
|
||||
pub fn session_widget(session: &SessionInfo) -> BoxedWidget {
|
||||
let heading_style = ContentStyle::default().bold();
|
||||
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let text = match session {
|
||||
SessionInfo::Full(session) => {
|
||||
|
|
@ -100,11 +105,11 @@ pub fn session_widget(session: &SessionInfo) -> BoxedWidget {
|
|||
}
|
||||
};
|
||||
|
||||
Popup::new(Text::new(text)).title("Inspect session").build()
|
||||
Popup::new(Text::new(text), "Inspect session")
|
||||
}
|
||||
|
||||
pub fn message_widget(msg: &Message) -> BoxedWidget {
|
||||
let heading_style = ContentStyle::default().bold();
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> + use<> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let mut text = Styled::new("Message", heading_style).then_plain("\n");
|
||||
|
||||
|
|
@ -117,21 +122,13 @@ pub fn message_widget(msg: &Message) -> BoxedWidget {
|
|||
|
||||
text = session_view_lines(text, &msg.sender);
|
||||
|
||||
Popup::new(Text::new(text)).title("Inspect message").build()
|
||||
Popup::new(Text::new(text), "Inspect message")
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close");
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Close,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(event: &InputEvent) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::Close,
|
||||
_ => EventResult::NotHandled,
|
||||
pub fn handle_input_event(event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult {
|
||||
if event.matches(&keys.general.abort) {
|
||||
return PopupResult::Close;
|
||||
}
|
||||
|
||||
PopupResult::NotHandled
|
||||
}
|
||||
192
cove/src/ui/euph/links.rs
Normal file
192
cove/src/ui/euph/links.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
use cove_config::{Config, Keys};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::{event::KeyCode, style::Stylize};
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Join2, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
euph::{self, SpanType},
|
||||
ui::{
|
||||
UiError, key_bindings, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Link {
|
||||
Url(String),
|
||||
Room(String),
|
||||
}
|
||||
|
||||
pub struct LinksState {
|
||||
config: &'static Config,
|
||||
links: Vec<Link>,
|
||||
list: ListState<usize>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
Self {
|
||||
config,
|
||||
links,
|
||||
list: ListState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
47
cove/src/ui/euph/nick.rs
Normal file
47
cove/src/ui/euph/nick.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use euphoxide::conn::Joined;
|
||||
use toss::{Style, Widget, widgets::EditorState};
|
||||
|
||||
use crate::{
|
||||
euph::{self, Room},
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
};
|
||||
|
||||
use super::popup::PopupResult;
|
||||
|
||||
pub fn new(joined: Joined) -> EditorState {
|
||||
EditorState::with_initial_text(joined.session.name)
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> {
|
||||
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<Room>,
|
||||
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
|
||||
}
|
||||
222
cove/src/ui/euph/nick_list.rs
Normal file
222
cove/src/ui/euph/nick_list.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
use std::iter;
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::{
|
||||
api::{NickEvent, SessionId, SessionType, SessionView, UserId},
|
||||
conn::{Joined, SessionInfo},
|
||||
};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Background, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
euph,
|
||||
ui::{
|
||||
UiError,
|
||||
widgets::{ListBuilder, ListState},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn widget<'a>(
|
||||
list: &'a mut ListState<SessionId>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
nick_emoji: bool,
|
||||
) -> impl Widget<UiError> + 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<Text>>,
|
||||
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<Text>>,
|
||||
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<Text>>,
|
||||
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()
|
||||
}
|
||||
});
|
||||
}
|
||||
40
cove/src/ui/euph/popup.rs
Normal file
40
cove/src/ui/euph/popup.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use std::io;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{Style, Styled, Widget, widgets::Text};
|
||||
|
||||
use crate::ui::{UiError, widgets::Popup};
|
||||
|
||||
pub enum RoomPopup {
|
||||
Error { description: String, reason: String },
|
||||
}
|
||||
|
||||
impl RoomPopup {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> + 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<UiError> + 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 },
|
||||
}
|
||||
708
cove/src/ui/euph/room.rs
Normal file
708
cove/src/ui/euph/room.rs
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use cove_config::{Config, Keys};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::{Data, Message, MessageId, PacketType, SessionId, packet::ParsedPacket},
|
||||
bot::instance::{ConnSnapshot, Event, ServerConfig},
|
||||
conn::{self, Joined, Joining, SessionInfo},
|
||||
};
|
||||
use jiff::tz::TimeZone;
|
||||
use tokio::sync::{
|
||||
mpsc,
|
||||
oneshot::{self, error::TryRecvError},
|
||||
};
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{BoxedAsync, EditorState, Join2, Layer, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
euph::{self, SpanType},
|
||||
macros::logging_unwrap,
|
||||
ui::{
|
||||
UiError, UiEvent,
|
||||
chat::{ChatState, Reaction},
|
||||
util,
|
||||
widgets::ListState,
|
||||
},
|
||||
vault::{EuphRoomVault, RoomIdentifier},
|
||||
};
|
||||
|
||||
use super::{
|
||||
account::AccountUiState,
|
||||
auth, inspect,
|
||||
links::LinksState,
|
||||
nick, nick_list,
|
||||
popup::{PopupResult, RoomPopup},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Chat,
|
||||
NickList,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum State {
|
||||
Normal,
|
||||
Auth(EditorState),
|
||||
Nick(EditorState),
|
||||
Account(AccountUiState),
|
||||
Links(LinksState),
|
||||
InspectMessage(Message),
|
||||
InspectSession(SessionInfo),
|
||||
}
|
||||
|
||||
type EuphChatState = ChatState<euph::SmallMessage, EuphRoomVault>;
|
||||
|
||||
pub struct EuphRoom {
|
||||
config: &'static Config,
|
||||
server_config: ServerConfig,
|
||||
room_config: cove_config::EuphRoom,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
room: Option<euph::Room>,
|
||||
|
||||
focus: Focus,
|
||||
state: State,
|
||||
popups: VecDeque<RoomPopup>,
|
||||
|
||||
chat: EuphChatState,
|
||||
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
|
||||
|
||||
nick_list: ListState<SessionId>,
|
||||
|
||||
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<UiEvent>,
|
||||
) -> 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<UiError> + 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<UiError> + Send + Sync + 'static,
|
||||
nick_list: &'a mut ListState<SessionId>,
|
||||
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<UiError> + 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<bool> for RoomResult {
|
||||
fn from(value: bool) -> Self {
|
||||
match value {
|
||||
true => Self::Handled,
|
||||
false => Self::NotHandled,
|
||||
}
|
||||
}
|
||||
}
|
||||
126
cove/src/ui/key_bindings.rs
Normal file
126
cove/src/ui/key_bindings.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! A scrollable popup showing the current key bindings.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use cove_config::{Config, Keys};
|
||||
use cove_input::{InputEvent, KeyBinding, KeyBindingInfo, KeyGroupInfo};
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{Either2, Join2, Padding, Text},
|
||||
};
|
||||
|
||||
use super::{
|
||||
UiError, util,
|
||||
widgets::{ListBuilder, ListState, Popup},
|
||||
};
|
||||
|
||||
type Line = Either2<Text, Join2<Padding<Text>, 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<Infallible>,
|
||||
config: &Config,
|
||||
) -> impl Widget<UiError> + 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<Infallible>,
|
||||
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
|
||||
}
|
||||
658
cove/src/ui/rooms.rs
Normal file
658
cove/src/ui/rooms.rs
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet, hash_map::Entry},
|
||||
iter,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use cove_config::{Config, Keys, RoomsSortOrder};
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::{
|
||||
api::SessionType,
|
||||
bot::instance::{Event, ServerConfig},
|
||||
conn::{self, Joined},
|
||||
};
|
||||
use jiff::tz::TimeZone;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{BellState, BoxedAsync, Empty, Join2, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
euph,
|
||||
macros::logging_unwrap,
|
||||
vault::{EuphVault, RoomIdentifier, Vault},
|
||||
version::{NAME, VERSION},
|
||||
};
|
||||
|
||||
use super::{
|
||||
UiError, UiEvent,
|
||||
euph::room::{EuphRoom, RoomResult},
|
||||
key_bindings, util,
|
||||
widgets::{ListBuilder, ListState},
|
||||
};
|
||||
|
||||
use self::{
|
||||
connect::{ConnectResult, ConnectState},
|
||||
delete::{DeleteResult, DeleteState},
|
||||
};
|
||||
|
||||
mod connect;
|
||||
mod delete;
|
||||
|
||||
enum State {
|
||||
ShowList,
|
||||
ShowRoom(RoomIdentifier),
|
||||
Connect(ConnectState),
|
||||
Delete(DeleteState),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Order {
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn from_rooms_sort_order(order: RoomsSortOrder) -> Self {
|
||||
match order {
|
||||
RoomsSortOrder::Alphabet => Self::Alphabet,
|
||||
RoomsSortOrder::Importance => Self::Importance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EuphServer {
|
||||
config: ServerConfig,
|
||||
next_instance_id: usize,
|
||||
}
|
||||
|
||||
impl EuphServer {
|
||||
async fn new(vault: &EuphVault, domain: String) -> Self {
|
||||
let cookies = logging_unwrap!(vault.cookies(domain.clone()).await);
|
||||
let config = ServerConfig::default()
|
||||
.domain(domain)
|
||||
.cookies(Arc::new(Mutex::new(cookies)))
|
||||
.timeout(Duration::from_secs(10));
|
||||
|
||||
Self {
|
||||
config,
|
||||
next_instance_id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rooms {
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
state: State,
|
||||
|
||||
list: ListState<RoomIdentifier>,
|
||||
order: Order,
|
||||
bell: BellState,
|
||||
|
||||
euph_servers: HashMap<String, EuphServer>,
|
||||
euph_rooms: HashMap<RoomIdentifier, EuphRoom>,
|
||||
}
|
||||
|
||||
impl Rooms {
|
||||
pub async fn new(
|
||||
config: &'static Config,
|
||||
tz: TimeZone,
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> 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<String, EuphServer>,
|
||||
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::<HashSet<_>>();
|
||||
|
||||
// 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<String> {
|
||||
match state {
|
||||
None | Some(euph::State::Stopped) => None,
|
||||
Some(euph::State::Disconnected) => Some("waiting".to_string()),
|
||||
Some(euph::State::Connecting) => Some("connecting".to_string()),
|
||||
Some(euph::State::Connected(_, connected)) => match connected {
|
||||
conn::State::Joining(joining) if joining.bounce.is_some() => {
|
||||
Some("auth required".to_string())
|
||||
}
|
||||
conn::State::Joining(_) => Some("joining".to_string()),
|
||||
conn::State::Joined(joined) => Some(Self::format_pbln(joined)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unseen_msgs(unseen: usize) -> Option<String> {
|
||||
if unseen == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{unseen}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled {
|
||||
let unseen_style = 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<RoomIdentifier, EuphRoom>,
|
||||
) {
|
||||
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<RoomIdentifier>,
|
||||
order: Order,
|
||||
euph_rooms: &HashMap<RoomIdentifier, EuphRoom>,
|
||||
) -> impl Widget<UiError> + 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
|
||||
}
|
||||
}
|
||||
123
cove/src/ui/rooms/connect.rs
Normal file
123
cove/src/ui/rooms/connect.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Join3, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Name,
|
||||
Domain,
|
||||
}
|
||||
|
||||
impl Focus {
|
||||
fn advance(self) -> Self {
|
||||
match self {
|
||||
Self::Name => Self::Domain,
|
||||
Self::Domain => Self::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConnectState {
|
||||
focus: Focus,
|
||||
name: EditorState,
|
||||
domain: EditorState,
|
||||
}
|
||||
|
||||
pub enum ConnectResult {
|
||||
Close,
|
||||
Connect(RoomIdentifier),
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl ConnectState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
focus: Focus::Name,
|
||||
name: EditorState::new(),
|
||||
domain: EditorState::with_initial_text("euphoria.leet.nu".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> ConnectResult {
|
||||
if event.matches(&keys.general.abort) {
|
||||
return ConnectResult::Close;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.focus) {
|
||||
self.focus = self.focus.advance();
|
||||
return ConnectResult::Handled;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.confirm) {
|
||||
let id = RoomIdentifier {
|
||||
domain: self.domain.text().to_string(),
|
||||
name: self.name.text().to_string(),
|
||||
};
|
||||
if !id.domain.is_empty() && !id.name.is_empty() {
|
||||
return ConnectResult::Connect(id);
|
||||
}
|
||||
}
|
||||
|
||||
let handled = match self.focus {
|
||||
Focus::Name => {
|
||||
util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char)
|
||||
}
|
||||
Focus::Domain => {
|
||||
util::handle_editor_input_event(&mut self.domain, event, keys, |c| c != '\n')
|
||||
}
|
||||
};
|
||||
|
||||
if handled {
|
||||
return ConnectResult::Handled;
|
||||
}
|
||||
|
||||
ConnectResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
90
cove/src/ui/rooms/delete.rs
Normal file
90
cove/src/ui/rooms/delete.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::style::Stylize;
|
||||
use toss::{
|
||||
Style, Styled, Widget, WidgetExt,
|
||||
widgets::{EditorState, Empty, Join2, Text},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ui::{UiError, util, widgets::Popup},
|
||||
vault::RoomIdentifier,
|
||||
};
|
||||
|
||||
pub struct DeleteState {
|
||||
id: RoomIdentifier,
|
||||
name: EditorState,
|
||||
}
|
||||
|
||||
pub enum DeleteResult {
|
||||
Close,
|
||||
Delete(RoomIdentifier),
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl DeleteState {
|
||||
pub fn new(id: RoomIdentifier) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: EditorState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> DeleteResult {
|
||||
if event.matches(&keys.general.abort) {
|
||||
return DeleteResult::Close;
|
||||
}
|
||||
|
||||
if event.matches(&keys.general.confirm) && self.name.text() == self.id.name {
|
||||
return DeleteResult::Delete(self.id.clone());
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(&mut self.name, event, keys, util::is_room_char) {
|
||||
return DeleteResult::Handled;
|
||||
}
|
||||
|
||||
DeleteResult::Unhandled
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
196
cove/src/ui/util.rs
Normal file
196
cove/src/ui/util.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
use cove_config::Keys;
|
||||
use cove_input::InputEvent;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use toss::widgets::EditorState;
|
||||
|
||||
use super::widgets::ListState;
|
||||
|
||||
/// Test if a character is allowed to be typed in a room name.
|
||||
pub fn is_room_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
//////////
|
||||
// List //
|
||||
//////////
|
||||
|
||||
pub fn handle_list_input_event<Id: Clone>(
|
||||
list: &mut ListState<Id>,
|
||||
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::<String>();
|
||||
|
||||
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::<String>();
|
||||
editor.insert_str(event.widthdb(), &text);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
5
cove/src/ui/widgets.rs
Normal file
5
cove/src/ui/widgets.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub use self::list::*;
|
||||
pub use self::popup::*;
|
||||
|
||||
mod list;
|
||||
mod popup;
|
||||
361
cove/src/ui/widgets/list.rs
Normal file
361
cove/src/ui/widgets/list.rs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
use std::vec;
|
||||
|
||||
use toss::{Frame, Pos, Size, Widget, WidthDb};
|
||||
|
||||
#[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)]
|
||||
pub struct ListState<Id> {
|
||||
/// 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<Cursor<Id>>,
|
||||
|
||||
/// Height of the list when it was last rendered.
|
||||
last_height: u16,
|
||||
|
||||
/// Rows when the list was last rendered.
|
||||
last_rows: Vec<Option<Id>>,
|
||||
}
|
||||
|
||||
impl<Id> ListState<Id> {
|
||||
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<Id: Clone> ListState<Id> {
|
||||
fn first_selectable(&self) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn last_selectable(&self) -> Option<Cursor<Id>> {
|
||||
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<Cursor<Id>> {
|
||||
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<Cursor<Id>> {
|
||||
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<Cursor<Id>> {
|
||||
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<Cursor<Id>> {
|
||||
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<Id>) {
|
||||
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<Id: Clone + Eq> ListState<Id> {
|
||||
fn selectable_of_id(&self, id: &Id) -> Option<Cursor<Id>> {
|
||||
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<Id>,
|
||||
widget: Box<dyn FnOnce(bool) -> W + 'a>,
|
||||
}
|
||||
|
||||
pub struct ListBuilder<'a, Id, W> {
|
||||
rows: Vec<UnrenderedRow<'a, Id, W>>,
|
||||
}
|
||||
|
||||
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<Id>) -> 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<Id>,
|
||||
rows: Vec<W>,
|
||||
}
|
||||
|
||||
impl<Id, E, W> Widget<E> for List<'_, Id, W>
|
||||
where
|
||||
Id: Clone + Eq,
|
||||
W: Widget<E>,
|
||||
{
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
54
cove/src/ui/widgets/popup.rs
Normal file
54
cove/src/ui/widgets/popup.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use toss::{
|
||||
Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb,
|
||||
widgets::{Background, Border, Desync, Float, Layer2, Padding, Text},
|
||||
};
|
||||
|
||||
type Body<I> = Background<Border<Padding<I>>>;
|
||||
type Title = Float<Padding<Background<Padding<Text>>>>;
|
||||
|
||||
pub struct Popup<I>(Float<Layer2<Body<I>, Desync<Title>>>);
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
70
cove/src/util.rs
Normal file
70
cove/src/util.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use std::{convert::Infallible, env};
|
||||
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
pub trait InfallibleExt {
|
||||
type Inner;
|
||||
|
||||
fn infallible(self) -> Self::Inner;
|
||||
}
|
||||
|
||||
impl<T> InfallibleExt for Result<T, Infallible> {
|
||||
type Inner = T;
|
||||
|
||||
fn infallible(self) -> T {
|
||||
self.expect("infallible")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a [`TimeZone`] specified by the `TZ` environment varible, or by the
|
||||
/// provided string if the environment variable does not exist.
|
||||
///
|
||||
/// If a string is provided, it is interpreted in the same format that the `TZ`
|
||||
/// environment variable uses.
|
||||
///
|
||||
/// If no `TZ` environment variable could be found and no string is provided,
|
||||
/// the system local time (or UTC on Windows) is used.
|
||||
pub fn load_time_zone(tz_string: Option<&str>) -> Result<TimeZone, jiff::Error> {
|
||||
let env_string = env::var("TZ").ok();
|
||||
let tz_string = env_string.as_ref().map(|s| s as &str).or(tz_string);
|
||||
|
||||
let Some(tz_string) = tz_string else {
|
||||
return Ok(TimeZone::system());
|
||||
};
|
||||
|
||||
if tz_string == "localtime" {
|
||||
return Ok(TimeZone::system());
|
||||
}
|
||||
|
||||
if let Some(tz_string) = tz_string.strip_prefix(':') {
|
||||
return TimeZone::get(tz_string);
|
||||
}
|
||||
|
||||
// The time zone is either a manually specified string or a file in the tz
|
||||
// database. We'll try to parse it as a manually specified string first
|
||||
// because that doesn't require a fs lookup.
|
||||
if let Ok(tz) = TimeZone::posix(tz_string) {
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
TimeZone::get(tz_string)
|
||||
}
|
||||
|
||||
pub fn caesar(text: &str, by: i8) -> String {
|
||||
let by = by.rem_euclid(26) as u8;
|
||||
text.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_lowercase() {
|
||||
let c = c as u8 - b'a';
|
||||
let c = (c + by) % 26;
|
||||
(c + b'a') as char
|
||||
} else if c.is_ascii_uppercase() {
|
||||
let c = c as u8 - b'A';
|
||||
let c = (c + by) % 26;
|
||||
(c + b'A') as char
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
use std::{fs, path::Path};
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vault::{Action, tokio::TokioVault};
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier};
|
||||
|
||||
mod euph;
|
||||
mod migrate;
|
||||
mod prepare;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vault::tokio::TokioVault;
|
||||
use vault::Action;
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Vault {
|
||||
tokio_vault: TokioVault,
|
||||
|
|
@ -20,9 +18,10 @@ pub struct Vault {
|
|||
struct GcAction;
|
||||
|
||||
impl Action for GcAction {
|
||||
type Result = ();
|
||||
type Output = ();
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn run(self, conn: &mut Connection) -> rusqlite::Result<Self::Result> {
|
||||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
conn.execute_batch("ANALYZE; VACUUM;")
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +35,7 @@ impl Vault {
|
|||
self.tokio_vault.stop().await;
|
||||
}
|
||||
|
||||
pub async fn gc(&self) -> vault::tokio::Result<()> {
|
||||
pub async fn gc(&self) -> Result<(), vault::tokio::Error<rusqlite::Error>> {
|
||||
self.tokio_vault.execute(GcAction).await
|
||||
}
|
||||
|
||||
|
|
@ -49,8 +48,6 @@ fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result
|
|||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "trusted_schema", false)?;
|
||||
|
||||
eprintln!("Opening vault");
|
||||
|
||||
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
|
||||
Ok(Vault {
|
||||
tokio_vault,
|
||||
File diff suppressed because it is too large
Load diff
224
cove/src/vault/migrate.rs
Normal file
224
cove/src/vault/migrate.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
use rusqlite::Transaction;
|
||||
use vault::Migration;
|
||||
|
||||
pub const MIGRATIONS: [Migration; 3] = [m1, m2, m3];
|
||||
|
||||
fn eprint_status(nr: usize, total: usize) {
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
}
|
||||
|
||||
fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE TABLE euph_rooms (
|
||||
room TEXT NOT NULL PRIMARY KEY,
|
||||
first_joined INT NOT NULL,
|
||||
last_joined INT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_msgs (
|
||||
-- Message
|
||||
room TEXT NOT NULL,
|
||||
id INT NOT NULL,
|
||||
parent INT,
|
||||
previous_edit_id INT,
|
||||
time INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
encryption_key_id TEXT,
|
||||
edited INT,
|
||||
deleted INT,
|
||||
truncated INT NOT NULL,
|
||||
|
||||
-- SessionView
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_era TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
is_staff INT NOT NULL,
|
||||
is_manager INT NOT NULL,
|
||||
client_address TEXT,
|
||||
real_client_address TEXT,
|
||||
|
||||
PRIMARY KEY (room, id),
|
||||
FOREIGN KEY (room) REFERENCES euph_rooms (room)
|
||||
ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_spans (
|
||||
room TEXT NOT NULL,
|
||||
start INT,
|
||||
end INT,
|
||||
|
||||
UNIQUE (room, start, end),
|
||||
FOREIGN KEY (room) REFERENCES euph_rooms (room)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (start IS NULL OR end IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_cookies (
|
||||
cookie TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_id_parent
|
||||
ON euph_msgs (room, id, parent);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_parent_id
|
||||
ON euph_msgs (room, parent, id);
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
tx.execute_batch(
|
||||
"
|
||||
ALTER TABLE euph_msgs
|
||||
ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE;
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_id_seen
|
||||
ON euph_msgs (room, id, seen);
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
fn m3(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprint_status(nr, total);
|
||||
println!(" This migration might take quite a while.");
|
||||
println!(" Aborting it will not corrupt your vault.");
|
||||
|
||||
// Rooms should be identified not just via their name but also their domain.
|
||||
// The domain should be required but there should be no default value.
|
||||
//
|
||||
// To accomplish this, we need to recreate and repopulate all euph related
|
||||
// tables because SQLite's ALTER TABLE is not powerful enough.
|
||||
|
||||
eprintln!(" Preparing tables...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
DROP INDEX euph_idx_msgs_room_id_parent;
|
||||
DROP INDEX euph_idx_msgs_room_parent_id;
|
||||
DROP INDEX euph_idx_msgs_room_id_seen;
|
||||
|
||||
ALTER TABLE euph_rooms RENAME TO old_euph_rooms;
|
||||
ALTER TABLE euph_msgs RENAME TO old_euph_msgs;
|
||||
ALTER TABLE euph_spans RENAME TO old_euph_spans;
|
||||
ALTER TABLE euph_cookies RENAME TO old_euph_cookies;
|
||||
|
||||
CREATE TABLE euph_rooms (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
first_joined INT NOT NULL,
|
||||
last_joined INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (domain, room)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_msgs (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
seen INT NOT NULL,
|
||||
|
||||
-- Message
|
||||
id INT NOT NULL,
|
||||
parent INT,
|
||||
previous_edit_id INT,
|
||||
time INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
encryption_key_id TEXT,
|
||||
edited INT,
|
||||
deleted INT,
|
||||
truncated INT NOT NULL,
|
||||
|
||||
-- SessionView
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_era TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
is_staff INT NOT NULL,
|
||||
is_manager INT NOT NULL,
|
||||
client_address TEXT,
|
||||
real_client_address TEXT,
|
||||
|
||||
PRIMARY KEY (domain, room, id),
|
||||
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
|
||||
ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_spans (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
start INT,
|
||||
end INT,
|
||||
|
||||
UNIQUE (domain, room, start, end),
|
||||
FOREIGN KEY (domain, room) REFERENCES euph_rooms (domain, room)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (start IS NULL OR end IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_cookies (
|
||||
domain TEXT NOT NULL,
|
||||
cookie TEXT NOT NULL
|
||||
) STRICT;
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Migrating data...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
INSERT INTO euph_rooms (domain, room, first_joined, last_joined)
|
||||
SELECT 'euphoria.io', room, first_joined, last_joined
|
||||
FROM old_euph_rooms;
|
||||
|
||||
INSERT INTO euph_msgs (
|
||||
domain, room, seen,
|
||||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
)
|
||||
SELECT
|
||||
'euphoria.io', room, seen,
|
||||
id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated,
|
||||
user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address
|
||||
FROM old_euph_msgs;
|
||||
|
||||
INSERT INTO euph_spans (domain, room, start, end)
|
||||
SELECT 'euphoria.io', room, start, end
|
||||
FROM old_euph_spans;
|
||||
|
||||
INSERT INTO euph_cookies (domain, cookie)
|
||||
SELECT 'euphoria.io', cookie
|
||||
FROM old_euph_cookies;
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Recreating indexes...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE INDEX euph_idx_msgs_domain_room_id_parent
|
||||
ON euph_msgs (domain, room, id, parent);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_domain_room_parent_id
|
||||
ON euph_msgs (domain, room, parent, id);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_domain_room_id_seen
|
||||
ON euph_msgs (domain, room, id, seen);
|
||||
",
|
||||
)?;
|
||||
|
||||
eprintln!(" Cleaning up loose ends...");
|
||||
tx.execute_batch(
|
||||
"
|
||||
DROP TABLE old_euph_rooms;
|
||||
DROP TABLE old_euph_msgs;
|
||||
DROP TABLE old_euph_spans;
|
||||
DROP TABLE old_euph_cookies;
|
||||
|
||||
ANALYZE;
|
||||
",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,28 +1,32 @@
|
|||
use rusqlite::Connection;
|
||||
|
||||
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
eprintln!("Preparing vault");
|
||||
|
||||
// Cache ids of tree roots.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_trees (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
id INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (room, id)
|
||||
PRIMARY KEY (domain, room, id)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO euph_trees (room, id)
|
||||
SELECT room, id
|
||||
INSERT INTO euph_trees (domain, room, id)
|
||||
SELECT domain, room, id
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NULL
|
||||
UNION
|
||||
SELECT room, parent
|
||||
SELECT domain, room, parent
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NOT NULL
|
||||
AND NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs AS parents
|
||||
WHERE parents.room = euph_msgs.room
|
||||
WHERE parents.domain = euph_msgs.domain
|
||||
AND parents.room = euph_msgs.room
|
||||
AND parents.id = euph_msgs.parent
|
||||
);
|
||||
|
||||
|
|
@ -30,15 +34,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE room = old.room;
|
||||
WHERE domain = old.domain
|
||||
AND room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_without_parent
|
||||
AFTER INSERT ON main.euph_msgs
|
||||
WHEN new.parent IS NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
VALUES (new.room, new.id);
|
||||
INSERT OR IGNORE INTO euph_trees (domain, room, id)
|
||||
VALUES (new.domain, new.room, new.id);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
|
||||
|
|
@ -46,16 +51,18 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
WHEN new.parent IS NOT NULL
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE room = new.room
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room
|
||||
AND id = new.id;
|
||||
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
INSERT OR IGNORE INTO euph_trees (domain, room, id)
|
||||
SELECT *
|
||||
FROM (VALUES (new.room, new.parent))
|
||||
FROM (VALUES (new.domain, new.room, new.parent))
|
||||
WHERE NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs
|
||||
WHERE room = new.room
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room
|
||||
AND id = new.parent
|
||||
AND parent IS NOT NULL
|
||||
);
|
||||
|
|
@ -67,35 +74,37 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_unseen_counts (
|
||||
domain TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (room)
|
||||
PRIMARY KEY (domain, room)
|
||||
) STRICT;
|
||||
|
||||
-- There must be an entry for every existing room.
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, 0
|
||||
INSERT INTO euph_unseen_counts (domain, room, amount)
|
||||
SELECT domain, room, 0
|
||||
FROM euph_rooms;
|
||||
|
||||
INSERT OR REPLACE INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, COUNT(*)
|
||||
INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount)
|
||||
SELECT domain, room, COUNT(*)
|
||||
FROM euph_msgs
|
||||
WHERE NOT seen
|
||||
GROUP BY room;
|
||||
GROUP BY domain, room;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_room
|
||||
AFTER INSERT ON main.euph_rooms
|
||||
BEGIN
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
VALUES (new.room, 0);
|
||||
INSERT INTO euph_unseen_counts (domain, room, amount)
|
||||
VALUES (new.domain, new.room, 0);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_delete_room
|
||||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_unseen_counts
|
||||
WHERE room = old.room;
|
||||
WHERE domain = old.domain
|
||||
AND room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_msg
|
||||
|
|
@ -104,7 +113,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = amount + 1
|
||||
WHERE room = new.room;
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_update_msg
|
||||
|
|
@ -113,7 +123,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
|||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
|
||||
WHERE room = new.room;
|
||||
WHERE domain = new.domain
|
||||
AND room = new.room;
|
||||
END;
|
||||
",
|
||||
)?;
|
||||
2
cove/src/version.rs
Normal file
2
cove/src/version.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub const NAME: &str = env!("CARGO_PKG_NAME");
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
63
flake.lock
generated
63
flake.lock
generated
|
|
@ -1,63 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1678901627,
|
||||
"narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"naersk": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1679567394,
|
||||
"narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "88cd22380154a2c36799fe8098888f0f59861a15",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1680643271,
|
||||
"narHash": "sha256-m76rYcvqs+NzTyETfxh1o/9gKdBuJ/Hl+PI/kp73mDw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "246567a3ad88e3119c2001e2fe78be233474cde0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"naersk": "naersk",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
21
flake.nix
21
flake.nix
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
description = "TUI client for euphoria.io, a threaded real-time chat platform";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
naersk.url = "github:nix-community/naersk";
|
||||
naersk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, naersk }: flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
naersk' = pkgs.callPackage naersk { };
|
||||
in
|
||||
rec {
|
||||
packages.default = naersk'.buildPackage { src = ./.; };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::macros::ok_or_return;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RoomsSortOrder {
|
||||
#[default]
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct EuphRoom {
|
||||
// TODO Mark favourite rooms via printable ascii characters
|
||||
#[serde(default)]
|
||||
pub autojoin: bool,
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_username: bool,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Euph {
|
||||
pub rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
pub data_dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
#[serde(default)]
|
||||
pub offline: bool,
|
||||
#[serde(default)]
|
||||
pub rooms_sort_order: RoomsSortOrder,
|
||||
// TODO Invoke external notification command?
|
||||
pub euph: Euph,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let content = ok_or_return!(fs::read_to_string(path), Self::default());
|
||||
match toml::from_str(&content) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading config file: {err}");
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn euph_room(&self, name: &str) -> EuphRoom {
|
||||
self.euph.rooms.get(name).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
use std::mem;
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use euphoxide::api::{MessageId, Snowflake, Time};
|
||||
use time::OffsetDateTime;
|
||||
use toss::styled::Styled;
|
||||
|
||||
use crate::store::Msg;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::util;
|
||||
|
||||
fn nick_char(ch: char) -> bool {
|
||||
// Closely following the heim mention regex:
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
||||
// `>` has been experimentally confirmed to delimit mentions as well.
|
||||
match ch {
|
||||
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
||||
_ => !ch.is_whitespace(),
|
||||
}
|
||||
}
|
||||
|
||||
fn room_char(ch: char) -> bool {
|
||||
// Basically just \w, see also
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
||||
ch.is_ascii_alphanumeric() || ch == '_'
|
||||
}
|
||||
|
||||
enum Span {
|
||||
Nothing,
|
||||
Mention,
|
||||
Room,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
struct Highlighter<'a> {
|
||||
content: &'a str,
|
||||
base_style: ContentStyle,
|
||||
exact: bool,
|
||||
|
||||
span: Span,
|
||||
span_start: usize,
|
||||
room_or_mention_possible: bool,
|
||||
|
||||
result: Styled,
|
||||
}
|
||||
|
||||
impl<'a> Highlighter<'a> {
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_mention(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
let text = &self.content[self.span_start..idx]; // Includes @
|
||||
self.result = mem::take(&mut self.result).and_then(if self.exact {
|
||||
util::style_nick_exact(text, self.base_style)
|
||||
} else {
|
||||
util::style_nick(text, self.base_style)
|
||||
});
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_room(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
self.result = mem::take(&mut self.result).then(
|
||||
&self.content[self.span_start..idx],
|
||||
self.base_style.blue().bold(),
|
||||
);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
// Warning: `idx` is the index of the closing colon.
|
||||
fn close_emoji(&mut self, idx: usize) {
|
||||
let name = &self.content[self.span_start + 1..idx];
|
||||
if let Some(replace) = util::EMOJI.get(name) {
|
||||
match replace {
|
||||
Some(replace) if !self.exact => {
|
||||
self.result = mem::take(&mut self.result).then(replace, self.base_style);
|
||||
}
|
||||
_ => {
|
||||
let text = &self.content[self.span_start..=idx];
|
||||
let style = self.base_style.magenta();
|
||||
self.result = mem::take(&mut self.result).then(text, style);
|
||||
}
|
||||
}
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx + 1;
|
||||
} else {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarantees `self.span_start == idx` after running.
|
||||
fn close_plain(&mut self, idx: usize) {
|
||||
if self.span_start == idx {
|
||||
// Span has length 0
|
||||
return;
|
||||
}
|
||||
|
||||
self.result =
|
||||
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Mention if !nick_char(char) => self.close_mention(idx),
|
||||
Span::Room if !room_char(char) => self.close_room(idx),
|
||||
Span::Emoji if char == '&' || char == '@' => {
|
||||
self.span = Span::Nothing;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Nothing if char == '@' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Mention;
|
||||
}
|
||||
Span::Nothing if char == '&' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Room;
|
||||
}
|
||||
Span::Nothing if char == ':' => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
Span::Emoji if char == ':' => self.close_emoji(idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_final_span(&mut self) {
|
||||
let idx = self.content.len();
|
||||
if self.span_start >= idx {
|
||||
return; // Span has no contents
|
||||
}
|
||||
|
||||
match self.span {
|
||||
Span::Mention => self.close_mention(idx),
|
||||
Span::Room => self.close_room(idx),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.close_plain(idx);
|
||||
}
|
||||
|
||||
fn step(&mut self, idx: usize, char: char) {
|
||||
if self.span_start < idx {
|
||||
self.close_span_before_current_char(idx, char);
|
||||
}
|
||||
|
||||
self.update_span_with_current_char(idx, char);
|
||||
|
||||
// More permissive than the heim web client
|
||||
self.room_or_mention_possible = !char.is_alphanumeric();
|
||||
}
|
||||
|
||||
fn highlight(content: &'a str, base_style: ContentStyle, exact: bool) -> Styled {
|
||||
let mut this = Self {
|
||||
content: if exact { content } else { content.trim() },
|
||||
base_style,
|
||||
exact,
|
||||
span: Span::Nothing,
|
||||
span_start: 0,
|
||||
room_or_mention_possible: true,
|
||||
result: Styled::default(),
|
||||
};
|
||||
|
||||
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
|
||||
this.step(idx, char);
|
||||
}
|
||||
|
||||
this.close_final_span();
|
||||
|
||||
this.result
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_content(content: &str, base_style: ContentStyle, exact: bool) -> Styled {
|
||||
Highlighter::highlight(content, base_style, exact)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmallMessage {
|
||||
pub id: MessageId,
|
||||
pub parent: Option<MessageId>,
|
||||
pub time: Time,
|
||||
pub nick: String,
|
||||
pub content: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
fn as_me(content: &str) -> Option<&str> {
|
||||
content.strip_prefix("/me")
|
||||
}
|
||||
|
||||
fn style_me() -> ContentStyle {
|
||||
ContentStyle::default().grey().italic()
|
||||
}
|
||||
|
||||
fn styled_nick(nick: &str) -> Styled {
|
||||
Styled::new_plain("[")
|
||||
.and_then(util::style_nick(nick, ContentStyle::default()))
|
||||
.then_plain("]")
|
||||
}
|
||||
|
||||
fn styled_nick_me(nick: &str) -> Styled {
|
||||
let style = style_me();
|
||||
Styled::new("*", style).and_then(util::style_nick(nick, style))
|
||||
}
|
||||
|
||||
fn styled_content(content: &str) -> Styled {
|
||||
highlight_content(content.trim(), ContentStyle::default(), false)
|
||||
}
|
||||
|
||||
fn styled_content_me(content: &str) -> Styled {
|
||||
let style = style_me();
|
||||
highlight_content(content.trim(), style, false).then("*", style)
|
||||
}
|
||||
|
||||
fn styled_editor_content(content: &str) -> Styled {
|
||||
let style = if as_me(content).is_some() {
|
||||
style_me()
|
||||
} else {
|
||||
ContentStyle::default()
|
||||
};
|
||||
highlight_content(content, style, true)
|
||||
}
|
||||
|
||||
impl Msg for SmallMessage {
|
||||
type Id = MessageId;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn parent(&self) -> Option<Self::Id> {
|
||||
self.parent
|
||||
}
|
||||
|
||||
fn seen(&self) -> bool {
|
||||
self.seen
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id {
|
||||
MessageId(Snowflake::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMsg for SmallMessage {
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time.0
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
Self::pseudo(&self.nick, &self.content)
|
||||
}
|
||||
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled) {
|
||||
(styled_nick(nick), styled_editor_content(content))
|
||||
}
|
||||
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled) {
|
||||
if let Some(content) = as_me(content) {
|
||||
(styled_nick_me(nick), styled_content_me(content))
|
||||
} else {
|
||||
(styled_nick(nick), styled_content(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
macro_rules! some_or_return {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Some(result) => result,
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
($e:expr, $ret:expr) => {
|
||||
match $e {
|
||||
Some(result) => result,
|
||||
None => return $ret,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use some_or_return;
|
||||
|
||||
macro_rules! ok_or_return {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Ok(result) => result,
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
($e:expr, $ret:expr) => {
|
||||
match $e {
|
||||
Ok(result) => result,
|
||||
Err(_) => return $ret,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use ok_or_return;
|
||||
|
||||
// TODO Get rid of this macro as much as possible
|
||||
macro_rules! logging_unwrap {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
log::error!("{err}");
|
||||
panic!("{err}");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use logging_unwrap;
|
||||
191
src/main.rs
191
src/main.rs
|
|
@ -1,191 +0,0 @@
|
|||
#![forbid(unsafe_code)]
|
||||
// Rustc lint groups
|
||||
#![warn(future_incompatible)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
#![warn(unused)]
|
||||
// Rustc lints
|
||||
#![warn(noop_method_call)]
|
||||
#![warn(single_use_lifetimes)]
|
||||
// Clippy lints
|
||||
#![warn(clippy::use_self)]
|
||||
|
||||
// TODO Enable warn(unreachable_pub)?
|
||||
// TODO Remove unnecessary Debug impls and compare compile times
|
||||
// TODO Time zones other than UTC
|
||||
// TODO Fix password room auth
|
||||
|
||||
mod config;
|
||||
mod euph;
|
||||
mod export;
|
||||
mod logger;
|
||||
mod macros;
|
||||
mod store;
|
||||
mod ui;
|
||||
mod vault;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use cookie::CookieJar;
|
||||
use directories::{BaseDirs, ProjectDirs};
|
||||
use log::info;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logger::Logger;
|
||||
use crate::ui::Ui;
|
||||
use crate::vault::Vault;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
enum Command {
|
||||
/// Run the client interactively (default).
|
||||
Run,
|
||||
/// Export room logs as plain text files.
|
||||
Export(export::Args),
|
||||
/// Compact and clean up vault.
|
||||
Gc,
|
||||
/// Clear euphoria session cookies.
|
||||
ClearCookies,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::Run
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Show more detailed log messages.
|
||||
#[arg(long, short)]
|
||||
verbose: bool,
|
||||
|
||||
/// Path to the config file.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the current directory.
|
||||
#[arg(long, short)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Path to a directory for cove to store its data in.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the current directory.
|
||||
#[arg(long, short)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// If set, cove won't store data permanently.
|
||||
#[arg(long, short)]
|
||||
ephemeral: bool,
|
||||
|
||||
/// If set, cove will ignore the autojoin config option.
|
||||
#[arg(long, short)]
|
||||
offline: bool,
|
||||
|
||||
/// Measure the width of characters as displayed by the terminal emulator
|
||||
/// instead of guessing the width.
|
||||
#[arg(long, short)]
|
||||
measure_widths: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
fn set_data_dir(config: &mut Config, args_data_dir: Option<PathBuf>) {
|
||||
if let Some(data_dir) = args_data_dir {
|
||||
// The data dir specified via args_data_dir is relative to the current
|
||||
// directory and needs no resolving.
|
||||
config.data_dir = Some(data_dir);
|
||||
} else if let Some(data_dir) = &config.data_dir {
|
||||
// Resolve the data dir specified in the config file relative to the
|
||||
// user's home directory, if possible.
|
||||
if let Some(base_dirs) = BaseDirs::new() {
|
||||
config.data_dir = Some(base_dirs.home_dir().join(data_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_ephemeral(config: &mut Config, args_ephemeral: bool) {
|
||||
if args_ephemeral {
|
||||
config.ephemeral = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_offline(config: &mut Config, args_offline: bool) {
|
||||
if args_offline {
|
||||
config.offline = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let (logger, logger_guard, logger_rx) = Logger::init(args.verbose);
|
||||
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories");
|
||||
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| dirs.config_dir().join("config.toml"));
|
||||
eprintln!("Config file: {}", config_path.to_string_lossy());
|
||||
let mut config = Config::load(&config_path);
|
||||
set_data_dir(&mut config, args.data_dir);
|
||||
set_ephemeral(&mut config, args.ephemeral);
|
||||
set_offline(&mut config, args.offline);
|
||||
let config = Box::leak(Box::new(config));
|
||||
|
||||
let vault = if config.ephemeral {
|
||||
vault::launch_in_memory()?
|
||||
} else {
|
||||
let data_dir = config
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs.data_dir().to_path_buf());
|
||||
eprintln!("Data dir: {}", data_dir.to_string_lossy());
|
||||
vault::launch(&data_dir.join("vault.db"))?
|
||||
};
|
||||
|
||||
match args.command.unwrap_or_default() {
|
||||
Command::Run => run(logger, logger_rx, config, &vault, args.measure_widths).await?,
|
||||
Command::Export(args) => export::export(&vault.euph(), args).await?,
|
||||
Command::Gc => {
|
||||
eprintln!("Cleaning up and compacting vault");
|
||||
eprintln!("This may take a while...");
|
||||
vault.gc().await?;
|
||||
}
|
||||
Command::ClearCookies => {
|
||||
eprintln!("Clearing cookies");
|
||||
vault.euph().set_cookies(CookieJar::new()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
vault.close().await;
|
||||
|
||||
// Print all logged errors. This should always happen, even if cove panics,
|
||||
// because the errors may be key in diagnosing what happened. Because of
|
||||
// this, it is not implemented via a normal function call.
|
||||
drop(logger_guard);
|
||||
|
||||
eprintln!("Goodbye!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(
|
||||
logger: Logger,
|
||||
logger_rx: mpsc::UnboundedReceiver<()>,
|
||||
config: &'static Config,
|
||||
vault: &Vault,
|
||||
measure_widths: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
info!(
|
||||
"Welcome to {} {}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let mut terminal = Terminal::new()?;
|
||||
terminal.set_measuring(measure_widths);
|
||||
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
|
||||
drop(terminal); // So other things can print again
|
||||
|
||||
Ok(())
|
||||
}
|
||||
160
src/ui/chat.rs
160
src/ui/chat.rs
|
|
@ -1,160 +0,0 @@
|
|||
// TODO Implement thread view
|
||||
// TODO Implement flat (chronological?) view
|
||||
// TODO Implement message search?
|
||||
|
||||
mod blocks;
|
||||
mod tree;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{fmt, io};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::FairMutex;
|
||||
use time::OffsetDateTime;
|
||||
use toss::frame::{Frame, Size};
|
||||
use toss::styled::Styled;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use self::tree::{TreeView, TreeViewState};
|
||||
|
||||
use super::input::{InputEvent, KeyBindingsList};
|
||||
use super::widgets::Widget;
|
||||
|
||||
///////////
|
||||
// Trait //
|
||||
///////////
|
||||
|
||||
pub trait ChatMsg {
|
||||
fn time(&self) -> OffsetDateTime;
|
||||
fn styled(&self) -> (Styled, Styled);
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled);
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
|
||||
}
|
||||
|
||||
///////////
|
||||
// State //
|
||||
///////////
|
||||
|
||||
pub enum Mode {
|
||||
Tree,
|
||||
// Thread,
|
||||
// Flat,
|
||||
}
|
||||
|
||||
pub struct ChatState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
mode: Mode,
|
||||
tree: TreeViewState<M, S>,
|
||||
// thread: ThreadView,
|
||||
// flat: FlatView,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
mode: Mode::Tree,
|
||||
tree: TreeViewState::new(store.clone()),
|
||||
store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
pub fn store(&self) -> &S {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn widget(&self, nick: String, focused: bool) -> Chat<M, S> {
|
||||
match self.mode {
|
||||
Mode::Tree => Chat::Tree(self.tree.widget(nick, focused)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Reaction<M: Msg> {
|
||||
NotHandled,
|
||||
Handled,
|
||||
Composed {
|
||||
parent: Option<M::Id>,
|
||||
content: String,
|
||||
},
|
||||
ComposeError(io::Error),
|
||||
}
|
||||
|
||||
impl<M: Msg> Reaction<M> {
|
||||
pub fn handled(&self) -> bool {
|
||||
!matches!(self, Self::NotHandled)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
match self.mode {
|
||||
Mode::Tree => self.tree.list_key_bindings(bindings, can_compose).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, S::Error> {
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.handle_input_event(terminal, crossterm_lock, event, can_compose)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cursor(&self) -> Option<M::Id> {
|
||||
match self.mode {
|
||||
Mode::Tree => self.tree.cursor().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Reaction::Composed`] message was sent, either successfully or
|
||||
/// unsuccessfully.
|
||||
///
|
||||
/// If successful, include the message's id as an argument. If unsuccessful,
|
||||
/// instead pass a `None`.
|
||||
pub async fn sent(&mut self, id: Option<M::Id>) {
|
||||
match self.mode {
|
||||
Mode::Tree => self.tree.sent(id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////
|
||||
// Widget //
|
||||
////////////
|
||||
|
||||
pub enum Chat<M: Msg, S: MsgStore<M>> {
|
||||
Tree(TreeView<M, S>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> Widget for Chat<M, S>
|
||||
where
|
||||
M: Msg + ChatMsg,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: fmt::Display,
|
||||
{
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
match self {
|
||||
Self::Tree(tree) => tree.size(frame, max_width, max_height),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
match *self {
|
||||
Self::Tree(tree) => Box::new(tree).render(frame).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
use std::collections::{vec_deque, VecDeque};
|
||||
use std::ops::Range;
|
||||
|
||||
use toss::frame::Frame;
|
||||
|
||||
use crate::macros::some_or_return;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub struct Block<I> {
|
||||
pub id: I,
|
||||
pub top_line: i32,
|
||||
pub height: i32,
|
||||
/// The lines of the block that should be made visible if the block is
|
||||
/// focused on. By default, the focus encompasses the entire block.
|
||||
///
|
||||
/// If not all of these lines can be made visible, the top of the range
|
||||
/// should be preferred over the bottom.
|
||||
pub focus: Range<i32>,
|
||||
pub widget: BoxedWidget,
|
||||
}
|
||||
|
||||
impl<I> Block<I> {
|
||||
pub fn new<W: Into<BoxedWidget>>(frame: &mut Frame, id: I, widget: W) -> Self {
|
||||
// Interestingly, rust-analyzer fails to deduce the type of `widget`
|
||||
// here but rustc knows it's a `BoxedWidget`.
|
||||
let widget = widget.into();
|
||||
let size = widget.size(frame, Some(frame.size().width), None);
|
||||
let height = size.height.into();
|
||||
Self {
|
||||
id,
|
||||
top_line: 0,
|
||||
height,
|
||||
focus: 0..height,
|
||||
widget,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus(mut self, focus: Range<i32>) -> Self {
|
||||
self.focus = focus;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Blocks<I> {
|
||||
pub blocks: VecDeque<Block<I>>,
|
||||
/// The top line of the first block. Useful for prepending blocks,
|
||||
/// especially to empty [`Blocks`]s.
|
||||
pub top_line: i32,
|
||||
/// The bottom line of the last block. Useful for appending blocks,
|
||||
/// especially to empty [`Blocks`]s.
|
||||
pub bottom_line: i32,
|
||||
}
|
||||
|
||||
impl<I> Blocks<I> {
|
||||
pub fn new() -> Self {
|
||||
Self::new_below(0)
|
||||
}
|
||||
|
||||
/// Create a new [`Blocks`] such that the first prepended line will be on
|
||||
/// `line`.
|
||||
pub fn new_below(line: i32) -> Self {
|
||||
Self {
|
||||
blocks: VecDeque::new(),
|
||||
top_line: line + 1,
|
||||
bottom_line: line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> vec_deque::Iter<'_, Block<I>> {
|
||||
self.blocks.iter()
|
||||
}
|
||||
|
||||
pub fn offset(&mut self, delta: i32) {
|
||||
self.top_line += delta;
|
||||
self.bottom_line += delta;
|
||||
for block in &mut self.blocks {
|
||||
block.top_line += delta;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_front(&mut self, mut block: Block<I>) {
|
||||
self.top_line -= block.height;
|
||||
block.top_line = self.top_line;
|
||||
self.blocks.push_front(block);
|
||||
}
|
||||
|
||||
pub fn push_back(&mut self, mut block: Block<I>) {
|
||||
block.top_line = self.bottom_line + 1;
|
||||
self.bottom_line += block.height;
|
||||
self.blocks.push_back(block);
|
||||
}
|
||||
|
||||
pub fn prepend(&mut self, mut layout: Self) {
|
||||
while let Some(block) = layout.blocks.pop_back() {
|
||||
self.push_front(block);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(&mut self, mut layout: Self) {
|
||||
while let Some(block) = layout.blocks.pop_front() {
|
||||
self.push_back(block);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_top_line(&mut self, line: i32) {
|
||||
self.top_line = line;
|
||||
|
||||
if let Some(first_block) = self.blocks.front_mut() {
|
||||
first_block.top_line = self.top_line;
|
||||
}
|
||||
|
||||
for i in 1..self.blocks.len() {
|
||||
self.blocks[i].top_line = self.blocks[i - 1].top_line + self.blocks[i - 1].height;
|
||||
}
|
||||
|
||||
self.bottom_line = self
|
||||
.blocks
|
||||
.back()
|
||||
.map(|b| b.top_line + b.height - 1)
|
||||
.unwrap_or(self.top_line - 1);
|
||||
}
|
||||
|
||||
pub fn set_bottom_line(&mut self, line: i32) {
|
||||
self.bottom_line = line;
|
||||
|
||||
if let Some(last_block) = self.blocks.back_mut() {
|
||||
last_block.top_line = self.bottom_line + 1 - last_block.height;
|
||||
}
|
||||
|
||||
for i in (1..self.blocks.len()).rev() {
|
||||
self.blocks[i - 1].top_line = self.blocks[i].top_line - self.blocks[i - 1].height;
|
||||
}
|
||||
|
||||
self.top_line = self
|
||||
.blocks
|
||||
.front()
|
||||
.map(|b| b.top_line)
|
||||
.unwrap_or(self.bottom_line + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: Eq> Blocks<I> {
|
||||
pub fn find(&self, id: &I) -> Option<&Block<I>> {
|
||||
self.blocks.iter().find(|b| b.id == *id)
|
||||
}
|
||||
|
||||
pub fn recalculate_offsets(&mut self, id: &I, top_line: i32) {
|
||||
let idx = some_or_return!(self
|
||||
.blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, b)| b.id == *id)
|
||||
.map(|(i, _)| i));
|
||||
|
||||
self.blocks[idx].top_line = top_line;
|
||||
|
||||
// Propagate changes to top
|
||||
for i in (0..idx).rev() {
|
||||
self.blocks[i].top_line = self.blocks[i + 1].top_line - self.blocks[i].height;
|
||||
}
|
||||
self.top_line = self.blocks.front().expect("blocks nonempty").top_line;
|
||||
|
||||
// Propagate changes to bottom
|
||||
for i in (idx + 1)..self.blocks.len() {
|
||||
self.blocks[i].top_line = self.blocks[i - 1].top_line + self.blocks[i - 1].height;
|
||||
}
|
||||
let bottom = self.blocks.back().expect("blocks nonempty");
|
||||
self.bottom_line = bottom.top_line + bottom.height - 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
// TODO Focusing on sub-trees
|
||||
|
||||
mod cursor;
|
||||
mod layout;
|
||||
mod tree_blocks;
|
||||
mod widgets;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::Mutex;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::util;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::Widget;
|
||||
|
||||
use self::cursor::Cursor;
|
||||
|
||||
use super::{ChatMsg, Reaction};
|
||||
|
||||
///////////
|
||||
// State //
|
||||
///////////
|
||||
|
||||
enum Correction {
|
||||
MakeCursorVisible,
|
||||
MoveCursorToVisibleArea,
|
||||
CenterCursor,
|
||||
}
|
||||
|
||||
struct InnerTreeViewState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
|
||||
last_cursor: Cursor<M::Id>,
|
||||
last_cursor_line: i32,
|
||||
last_visible_msgs: Vec<M::Id>,
|
||||
|
||||
cursor: Cursor<M::Id>,
|
||||
editor: EditorState,
|
||||
|
||||
/// Scroll the view on the next render. Positive values scroll up and
|
||||
/// negative values scroll down.
|
||||
scroll: i32,
|
||||
correction: Option<Correction>,
|
||||
|
||||
folded: HashSet<M::Id>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
|
||||
fn new(store: S) -> Self {
|
||||
Self {
|
||||
store,
|
||||
last_cursor: Cursor::Bottom,
|
||||
last_cursor_line: 0,
|
||||
last_visible_msgs: vec![],
|
||||
cursor: Cursor::Bottom,
|
||||
editor: EditorState::new(),
|
||||
scroll: 0,
|
||||
correction: None,
|
||||
folded: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling");
|
||||
bindings.binding("p/P", "move cursor to parent/root");
|
||||
bindings.binding("h/l, ←/→", "move cursor chronologically");
|
||||
bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down a line");
|
||||
bindings.binding("ctrl+u/d", "scroll up/down half a screen");
|
||||
bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen");
|
||||
bindings.binding("z", "center cursor on screen");
|
||||
// TODO Bindings inspired by vim's ()/[]/{} bindings?
|
||||
}
|
||||
|
||||
async fn handle_movement_input_event(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
event: &InputEvent,
|
||||
) -> Result<bool, S::Error> {
|
||||
let chat_height = frame.size().height - 3;
|
||||
|
||||
match event {
|
||||
key!('k') | key!(Up) => self.move_cursor_up().await?,
|
||||
key!('j') | key!(Down) => self.move_cursor_down().await?,
|
||||
key!('K') | key!(Ctrl + Up) => self.move_cursor_up_sibling().await?,
|
||||
key!('J') | key!(Ctrl + Down) => self.move_cursor_down_sibling().await?,
|
||||
key!('p') => self.move_cursor_to_parent().await?,
|
||||
key!('P') => self.move_cursor_to_root().await?,
|
||||
key!('h') | key!(Left) => self.move_cursor_older().await?,
|
||||
key!('l') | key!(Right) => self.move_cursor_newer().await?,
|
||||
key!('H') | key!(Ctrl + Left) => self.move_cursor_older_unseen().await?,
|
||||
key!('L') | key!(Ctrl + Right) => self.move_cursor_newer_unseen().await?,
|
||||
key!('g') | key!(Home) => self.move_cursor_to_top().await?,
|
||||
key!('G') | key!(End) => self.move_cursor_to_bottom().await,
|
||||
key!(Ctrl + 'y') => self.scroll_up(1),
|
||||
key!(Ctrl + 'e') => self.scroll_down(1),
|
||||
key!(Ctrl + 'u') => self.scroll_up((chat_height / 2).into()),
|
||||
key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()),
|
||||
key!(Ctrl + 'b') | key!(PageUp) => self.scroll_up(chat_height.saturating_sub(1).into()),
|
||||
key!(Ctrl + 'f') | key!(PageDown) => {
|
||||
self.scroll_down(chat_height.saturating_sub(1).into())
|
||||
}
|
||||
key!('z') => self.center_cursor(),
|
||||
_ => return Ok(false),
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("space", "fold current message's subtree");
|
||||
bindings.binding("s", "toggle current message's seen status");
|
||||
bindings.binding("S", "mark all visible messages as seen");
|
||||
bindings.binding("ctrl+s", "mark all older messages as seen");
|
||||
}
|
||||
|
||||
async fn handle_action_input_event(
|
||||
&mut self,
|
||||
event: &InputEvent,
|
||||
id: Option<&M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
match event {
|
||||
key!(' ') => {
|
||||
if let Some(id) = id {
|
||||
if !self.folded.remove(id) {
|
||||
self.folded.insert(id.clone());
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
key!('s') => {
|
||||
if let Some(id) = id {
|
||||
if let Some(msg) = self.store.tree(id).await?.msg(id) {
|
||||
self.store.set_seen(id, !msg.seen()).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
key!('S') => {
|
||||
for id in &self.last_visible_msgs {
|
||||
self.store.set_seen(id, true).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
key!(Ctrl + 's') => {
|
||||
if let Some(id) = id {
|
||||
self.store.set_older_seen(id, true).await?;
|
||||
} else {
|
||||
self.store
|
||||
.set_older_seen(&M::last_possible_id(), true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("r", "reply to message (inline if possible, else directly)");
|
||||
bindings.binding("R", "reply to message (opposite of R)");
|
||||
bindings.binding("t", "start a new thread");
|
||||
}
|
||||
|
||||
async fn handle_edit_initiating_input_event(
|
||||
&mut self,
|
||||
event: &InputEvent,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
match event {
|
||||
key!('r') => {
|
||||
if let Some(parent) = self.parent_for_normal_reply().await? {
|
||||
self.cursor = Cursor::editor(id, parent);
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
}
|
||||
}
|
||||
key!('R') => {
|
||||
if let Some(parent) = self.parent_for_alternate_reply().await? {
|
||||
self.cursor = Cursor::editor(id, parent);
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
}
|
||||
}
|
||||
key!('t') | key!('T') => {
|
||||
self.cursor = Cursor::editor(id, None);
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
self.list_movement_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
self.list_action_key_bindings(bindings);
|
||||
if can_compose {
|
||||
bindings.empty();
|
||||
self.list_edit_initiating_key_bindings(bindings);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_normal_input_event(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
event: &InputEvent,
|
||||
can_compose: bool,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
#[allow(clippy::if_same_then_else)]
|
||||
Ok(if self.handle_movement_input_event(frame, event).await? {
|
||||
true
|
||||
} else if self.handle_action_input_event(event, id.as_ref()).await? {
|
||||
true
|
||||
} else if can_compose {
|
||||
self.handle_edit_initiating_input_event(event, id).await?
|
||||
} else {
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close editor");
|
||||
bindings.binding("enter", "send message");
|
||||
util::list_editor_key_bindings_allowing_external_editing(bindings, |_| true);
|
||||
}
|
||||
|
||||
fn handle_editor_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
coming_from: Option<M::Id>,
|
||||
parent: Option<M::Id>,
|
||||
) -> Reaction<M> {
|
||||
// TODO Tab-completion
|
||||
match event {
|
||||
key!(Esc) => {
|
||||
self.cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom);
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
return Reaction::Handled;
|
||||
}
|
||||
|
||||
key!(Enter) => {
|
||||
let content = self.editor.text();
|
||||
if !content.trim().is_empty() {
|
||||
self.cursor = Cursor::Pseudo {
|
||||
coming_from,
|
||||
parent: parent.clone(),
|
||||
};
|
||||
return Reaction::Composed { parent, content };
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
let handled = util::handle_editor_input_event_allowing_external_editing(
|
||||
&self.editor,
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
|_| true,
|
||||
);
|
||||
match handled {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Reaction::NotHandled,
|
||||
Err(e) => return Reaction::ComposeError(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Reaction::Handled
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
bindings.heading("Chat");
|
||||
match &self.cursor {
|
||||
Cursor::Bottom | Cursor::Msg(_) => {
|
||||
self.list_normal_key_bindings(bindings, can_compose);
|
||||
}
|
||||
Cursor::Editor { .. } => self.list_editor_key_bindings(bindings),
|
||||
Cursor::Pseudo { .. } => {
|
||||
self.list_normal_key_bindings(bindings, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, S::Error> {
|
||||
Ok(match &self.cursor {
|
||||
Cursor::Bottom => {
|
||||
if self
|
||||
.handle_normal_input_event(terminal.frame(), event, can_compose, None)
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
Cursor::Msg(id) => {
|
||||
let id = id.clone();
|
||||
if self
|
||||
.handle_normal_input_event(terminal.frame(), event, can_compose, Some(id))
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
Cursor::Editor {
|
||||
coming_from,
|
||||
parent,
|
||||
} => self.handle_editor_input_event(
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
coming_from.clone(),
|
||||
parent.clone(),
|
||||
),
|
||||
Cursor::Pseudo { .. } => {
|
||||
if self
|
||||
.handle_movement_input_event(terminal.frame(), event)
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cursor(&self) -> Option<M::Id> {
|
||||
match &self.cursor {
|
||||
Cursor::Msg(id) => Some(id.clone()),
|
||||
Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sent(&mut self, id: Option<M::Id>) {
|
||||
if let Cursor::Pseudo { coming_from, .. } = &self.cursor {
|
||||
if let Some(id) = id {
|
||||
self.last_cursor = Cursor::Msg(id.clone());
|
||||
self.cursor = Cursor::Msg(id);
|
||||
self.editor.clear();
|
||||
} else {
|
||||
self.cursor = match coming_from {
|
||||
Some(id) => Cursor::Msg(id.clone()),
|
||||
None => Cursor::Bottom,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeViewState<M: Msg, S: MsgStore<M>>(Arc<Mutex<InnerTreeViewState<M, S>>>);
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self(Arc::new(Mutex::new(InnerTreeViewState::new(store))))
|
||||
}
|
||||
|
||||
pub fn widget(&self, nick: String, focused: bool) -> TreeView<M, S> {
|
||||
TreeView {
|
||||
inner: self.0.clone(),
|
||||
nick,
|
||||
focused,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
self.0.lock().await.list_key_bindings(bindings, can_compose);
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, S::Error> {
|
||||
self.0
|
||||
.lock()
|
||||
.await
|
||||
.handle_input_event(terminal, crossterm_lock, event, can_compose)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cursor(&self) -> Option<M::Id> {
|
||||
self.0.lock().await.cursor()
|
||||
}
|
||||
|
||||
pub async fn sent(&mut self, id: Option<M::Id>) {
|
||||
self.0.lock().await.sent(id)
|
||||
}
|
||||
}
|
||||
|
||||
////////////
|
||||
// Widget //
|
||||
////////////
|
||||
|
||||
pub struct TreeView<M: Msg, S: MsgStore<M>> {
|
||||
inner: Arc<Mutex<InnerTreeViewState<M, S>>>,
|
||||
nick: String,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> Widget for TreeView<M, S>
|
||||
where
|
||||
M: Msg + ChatMsg,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: fmt::Display,
|
||||
{
|
||||
fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size {
|
||||
Size::ZERO
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
let mut guard = self.inner.lock().await;
|
||||
let blocks = logging_unwrap!(guard.relayout(self.nick, self.focused, frame).await);
|
||||
|
||||
let size = frame.size();
|
||||
for block in blocks.into_blocks().blocks {
|
||||
frame.push(
|
||||
Pos::new(0, block.top_line),
|
||||
Size::new(size.width, block.height as u16),
|
||||
);
|
||||
block.widget.render(frame).await;
|
||||
frame.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
//! Moving the cursor around.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
use super::{Correction, InnerTreeViewState};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Cursor<I> {
|
||||
Bottom,
|
||||
Msg(I),
|
||||
Editor {
|
||||
coming_from: Option<I>,
|
||||
parent: Option<I>,
|
||||
},
|
||||
Pseudo {
|
||||
coming_from: Option<I>,
|
||||
parent: Option<I>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<I> Cursor<I> {
|
||||
pub fn editor(coming_from: Option<I>, parent: Option<I>) -> Self {
|
||||
Self::Editor {
|
||||
coming_from,
|
||||
parent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: Eq> Cursor<I> {
|
||||
pub fn refers_to(&self, id: &I) -> bool {
|
||||
if let Self::Msg(own_id) = self {
|
||||
own_id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refers_to_last_child_of(&self, id: &I) -> bool {
|
||||
if let Self::Editor {
|
||||
parent: Some(parent),
|
||||
..
|
||||
}
|
||||
| Self::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} = self
|
||||
{
|
||||
parent == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
|
||||
fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if let Some(parent) = tree.parent(id) {
|
||||
*id = parent;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if folded.contains(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
|
||||
*id = child.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_last_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if folded.contains(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
|
||||
*id = child.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the previous sibling, or don't move if this is not possible.
|
||||
///
|
||||
/// Always stays at the same level of indentation.
|
||||
async fn find_prev_sibling(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> Result<bool, S::Error> {
|
||||
let moved = if let Some(prev_sibling) = tree.prev_sibling(id) {
|
||||
*id = prev_sibling;
|
||||
true
|
||||
} else if tree.parent(id).is_none() {
|
||||
// We're at the root of our tree, so we need to move to the root of
|
||||
// the previous tree.
|
||||
if let Some(prev_root_id) = store.prev_root_id(tree.root()).await? {
|
||||
*tree = store.tree(&prev_root_id).await?;
|
||||
*id = prev_root_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move to the next sibling, or don't move if this is not possible.
|
||||
///
|
||||
/// Always stays at the same level of indentation.
|
||||
async fn find_next_sibling(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> Result<bool, S::Error> {
|
||||
let moved = if let Some(next_sibling) = tree.next_sibling(id) {
|
||||
*id = next_sibling;
|
||||
true
|
||||
} else if tree.parent(id).is_none() {
|
||||
// We're at the root of our tree, so we need to move to the root of
|
||||
// the next tree.
|
||||
if let Some(next_root_id) = store.next_root_id(tree.root()).await? {
|
||||
*tree = store.tree(&next_root_id).await?;
|
||||
*id = next_root_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move to the previous message, or don't move if this is not possible.
|
||||
async fn find_prev_msg(
|
||||
store: &S,
|
||||
folded: &HashSet<M::Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> Result<bool, S::Error> {
|
||||
// Move to previous sibling, then to its last child
|
||||
// If not possible, move to parent
|
||||
let moved = if Self::find_prev_sibling(store, tree, id).await? {
|
||||
while Self::find_last_child(folded, tree, id) {}
|
||||
true
|
||||
} else {
|
||||
Self::find_parent(tree, id)
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move to the next message, or don't move if this is not possible.
|
||||
async fn find_next_msg(
|
||||
store: &S,
|
||||
folded: &HashSet<M::Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> Result<bool, S::Error> {
|
||||
if Self::find_first_child(folded, tree, id) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if Self::find_next_sibling(store, tree, id).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Temporary id to avoid modifying the original one if no parent-sibling
|
||||
// can be found.
|
||||
let mut tmp_id = id.clone();
|
||||
while Self::find_parent(tree, &mut tmp_id) {
|
||||
if Self::find_next_sibling(store, tree, &mut tmp_id).await? {
|
||||
*id = tmp_id;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn move_cursor_up(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => {
|
||||
if let Some(last_root_id) = self.store.last_root_id().await? {
|
||||
let tree = self.store.tree(&last_root_id).await?;
|
||||
let mut id = last_root_id;
|
||||
while Self::find_last_child(&self.folded, &tree, &mut id) {}
|
||||
self.cursor = Cursor::Msg(id);
|
||||
}
|
||||
}
|
||||
Cursor::Msg(msg) => {
|
||||
let path = self.store.path(msg).await?;
|
||||
let mut tree = self.store.tree(path.first()).await?;
|
||||
Self::find_prev_msg(&self.store, &self.folded, &mut tree, msg).await?;
|
||||
}
|
||||
Cursor::Editor { .. } => {}
|
||||
Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let tree = self.store.tree(parent).await?;
|
||||
let mut id = parent.clone();
|
||||
while Self::find_last_child(&self.folded, &tree, &mut id) {}
|
||||
self.cursor = Cursor::Msg(id);
|
||||
}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_down(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(msg) => {
|
||||
let path = self.store.path(msg).await?;
|
||||
let mut tree = self.store.tree(path.first()).await?;
|
||||
if !Self::find_next_msg(&self.store, &self.folded, &mut tree, msg).await? {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
}
|
||||
Cursor::Pseudo { parent: None, .. } => {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let mut tree = self.store.tree(parent).await?;
|
||||
let mut id = parent.clone();
|
||||
while Self::find_last_child(&self.folded, &tree, &mut id) {}
|
||||
// Now we're at the previous message
|
||||
if Self::find_next_msg(&self.store, &self.folded, &mut tree, &mut id).await? {
|
||||
self.cursor = Cursor::Msg(id);
|
||||
} else {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_up_sibling(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => {
|
||||
if let Some(last_root_id) = self.store.last_root_id().await? {
|
||||
self.cursor = Cursor::Msg(last_root_id);
|
||||
}
|
||||
}
|
||||
Cursor::Msg(msg) => {
|
||||
let path = self.store.path(msg).await?;
|
||||
let mut tree = self.store.tree(path.first()).await?;
|
||||
Self::find_prev_sibling(&self.store, &mut tree, msg).await?;
|
||||
}
|
||||
Cursor::Editor { .. } => {}
|
||||
Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let path = self.store.path(parent).await?;
|
||||
let tree = self.store.tree(path.first()).await?;
|
||||
if let Some(children) = tree.children(parent) {
|
||||
if let Some(last_child) = children.last() {
|
||||
self.cursor = Cursor::Msg(last_child.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_down_sibling(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(msg) => {
|
||||
let path = self.store.path(msg).await?;
|
||||
let mut tree = self.store.tree(path.first()).await?;
|
||||
if !Self::find_next_sibling(&self.store, &mut tree, msg).await?
|
||||
&& tree.parent(msg).is_none()
|
||||
{
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
}
|
||||
Cursor::Pseudo { parent: None, .. } => {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_to_parent(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => self.cursor = Cursor::Msg(parent.clone()),
|
||||
Cursor::Msg(id) => {
|
||||
// Could also be done via retrieving the path, but it doesn't
|
||||
// really matter here
|
||||
let tree = self.store.tree(id).await?;
|
||||
Self::find_parent(&tree, id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_to_root(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let path = self.store.path(parent).await?;
|
||||
self.cursor = Cursor::Msg(path.first().clone());
|
||||
}
|
||||
Cursor::Msg(msg) => {
|
||||
let path = self.store.path(msg).await?;
|
||||
*msg = path.first().clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_older(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(id) => {
|
||||
if let Some(prev_id) = self.store.older_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
}
|
||||
}
|
||||
Cursor::Bottom | Cursor::Pseudo { .. } => {
|
||||
if let Some(id) = self.store.newest_msg_id().await? {
|
||||
self.cursor = Cursor::Msg(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_newer(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(id) => {
|
||||
if let Some(prev_id) = self.store.newer_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
} else {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
}
|
||||
Cursor::Pseudo { .. } => {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_older_unseen(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(id) => {
|
||||
if let Some(prev_id) = self.store.older_unseen_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
}
|
||||
}
|
||||
Cursor::Bottom | Cursor::Pseudo { .. } => {
|
||||
if let Some(id) = self.store.newest_unseen_msg_id().await? {
|
||||
self.cursor = Cursor::Msg(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_newer_unseen(&mut self) -> Result<(), S::Error> {
|
||||
match &mut self.cursor {
|
||||
Cursor::Msg(id) => {
|
||||
if let Some(prev_id) = self.store.newer_unseen_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
} else {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
}
|
||||
Cursor::Pseudo { .. } => {
|
||||
self.cursor = Cursor::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_to_top(&mut self) -> Result<(), S::Error> {
|
||||
if let Some(first_root_id) = self.store.first_root_id().await? {
|
||||
self.cursor = Cursor::Msg(first_root_id);
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_cursor_to_bottom(&mut self) {
|
||||
self.cursor = Cursor::Bottom;
|
||||
// Not really necessary; only here for consistency with other methods
|
||||
self.correction = Some(Correction::MakeCursorVisible);
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, amount: i32) {
|
||||
self.scroll += amount;
|
||||
self.correction = Some(Correction::MoveCursorToVisibleArea);
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, amount: i32) {
|
||||
self.scroll -= amount;
|
||||
self.correction = Some(Correction::MoveCursorToVisibleArea);
|
||||
}
|
||||
|
||||
pub fn center_cursor(&mut self) {
|
||||
self.correction = Some(Correction::CenterCursor);
|
||||
}
|
||||
|
||||
/// The outer `Option` shows whether a parent exists or not. The inner
|
||||
/// `Option` shows if that parent has an id.
|
||||
pub async fn parent_for_normal_reply(&self) -> Result<Option<Option<M::Id>>, S::Error> {
|
||||
Ok(match &self.cursor {
|
||||
Cursor::Bottom => Some(None),
|
||||
Cursor::Msg(id) => {
|
||||
let path = self.store.path(id).await?;
|
||||
let tree = self.store.tree(path.first()).await?;
|
||||
|
||||
Some(Some(if tree.next_sibling(id).is_some() {
|
||||
// A reply to a message that has further siblings should be a
|
||||
// direct reply. An indirect reply might end up a lot further
|
||||
// down in the current conversation.
|
||||
id.clone()
|
||||
} else if let Some(parent) = tree.parent(id) {
|
||||
// A reply to a message without younger siblings should be
|
||||
// an indirect reply so as not to create unnecessarily deep
|
||||
// threads. In the case that our message has children, this
|
||||
// might get a bit confusing. I'm not sure yet how well this
|
||||
// "smart" reply actually works in practice.
|
||||
parent
|
||||
} else {
|
||||
// When replying to a top-level message, it makes sense to avoid
|
||||
// creating unnecessary new threads.
|
||||
id.clone()
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The outer `Option` shows whether a parent exists or not. The inner
|
||||
/// `Option` shows if that parent has an id.
|
||||
pub async fn parent_for_alternate_reply(&self) -> Result<Option<Option<M::Id>>, S::Error> {
|
||||
Ok(match &self.cursor {
|
||||
Cursor::Bottom => Some(None),
|
||||
Cursor::Msg(id) => {
|
||||
let path = self.store.path(id).await?;
|
||||
let tree = self.store.tree(path.first()).await?;
|
||||
|
||||
Some(Some(if tree.next_sibling(id).is_none() {
|
||||
// The opposite of replying normally
|
||||
id.clone()
|
||||
} else if let Some(parent) = tree.parent(id) {
|
||||
// The opposite of replying normally
|
||||
parent
|
||||
} else {
|
||||
// The same as replying normally, still to avoid creating
|
||||
// unnecessary new threads
|
||||
id.clone()
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,605 +0,0 @@
|
|||
use toss::frame::Frame;
|
||||
|
||||
use crate::store::{Msg, MsgStore, Path, Tree};
|
||||
use crate::ui::chat::blocks::Block;
|
||||
use crate::ui::widgets::empty::Empty;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::tree_blocks::{BlockId, Root, TreeBlocks};
|
||||
use super::{widgets, Correction, Cursor, InnerTreeViewState};
|
||||
|
||||
const SCROLLOFF: i32 = 2;
|
||||
const MIN_CONTENT_HEIGHT: i32 = 10;
|
||||
|
||||
fn scrolloff(height: i32) -> i32 {
|
||||
let scrolloff = (height - MIN_CONTENT_HEIGHT).max(0) / 2;
|
||||
scrolloff.min(SCROLLOFF)
|
||||
}
|
||||
|
||||
struct Context {
|
||||
nick: String,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
impl<M: Msg + ChatMsg, S: MsgStore<M>> InnerTreeViewState<M, S> {
|
||||
async fn cursor_path(&self, cursor: &Cursor<M::Id>) -> Result<Path<M::Id>, S::Error> {
|
||||
Ok(match cursor {
|
||||
Cursor::Msg(id) => self.store.path(id).await?,
|
||||
Cursor::Bottom
|
||||
| Cursor::Editor { parent: None, .. }
|
||||
| Cursor::Pseudo { parent: None, .. } => Path::new(vec![M::last_possible_id()]),
|
||||
Cursor::Editor {
|
||||
parent: Some(parent),
|
||||
..
|
||||
}
|
||||
| Cursor::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let mut path = self.store.path(parent).await?;
|
||||
path.push(M::last_possible_id());
|
||||
path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_path_visible(&mut self, path: &Path<M::Id>) {
|
||||
for segment in path.parent_segments() {
|
||||
self.folded.remove(segment);
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_line(&self, blocks: &TreeBlocks<M::Id>) -> i32 {
|
||||
if let Cursor::Bottom = self.cursor {
|
||||
// The value doesn't matter as it will always be ignored.
|
||||
0
|
||||
} else {
|
||||
blocks
|
||||
.blocks()
|
||||
.find(&BlockId::from_cursor(&self.cursor))
|
||||
.expect("no cursor found")
|
||||
.top_line
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_cursor(&self, blocks: &TreeBlocks<M::Id>) -> bool {
|
||||
blocks
|
||||
.blocks()
|
||||
.find(&BlockId::from_cursor(&self.cursor))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn editor_block(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
indent: usize,
|
||||
) -> Block<BlockId<M::Id>> {
|
||||
let (widget, cursor_row) =
|
||||
widgets::editor::<M>(frame.widthdb(), indent, &context.nick, &self.editor);
|
||||
let cursor_row = cursor_row as i32;
|
||||
Block::new(frame, BlockId::Cursor, widget).focus(cursor_row..cursor_row + 1)
|
||||
}
|
||||
|
||||
fn pseudo_block(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
indent: usize,
|
||||
) -> Block<BlockId<M::Id>> {
|
||||
let widget = widgets::pseudo::<M>(indent, &context.nick, &self.editor);
|
||||
Block::new(frame, BlockId::Cursor, widget)
|
||||
}
|
||||
|
||||
fn layout_subtree(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
tree: &Tree<M>,
|
||||
indent: usize,
|
||||
id: &M::Id,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) {
|
||||
// Ghost cursor in front, for positioning according to last cursor line
|
||||
if self.last_cursor.refers_to(id) {
|
||||
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
|
||||
blocks.blocks_mut().push_back(block);
|
||||
}
|
||||
|
||||
// Last part of message body if message is folded
|
||||
let folded = self.folded.contains(id);
|
||||
let folded_info = if folded {
|
||||
Some(tree.subtree_size(id)).filter(|s| *s > 0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Main message body
|
||||
let highlighted = context.focused && self.cursor.refers_to(id);
|
||||
let widget = if let Some(msg) = tree.msg(id) {
|
||||
widgets::msg(highlighted, indent, msg, folded_info)
|
||||
} else {
|
||||
widgets::msg_placeholder(highlighted, indent, folded_info)
|
||||
};
|
||||
let block = Block::new(frame, BlockId::Msg(id.clone()), widget);
|
||||
blocks.blocks_mut().push_back(block);
|
||||
|
||||
// Children, recursively
|
||||
if !folded {
|
||||
if let Some(children) = tree.children(id) {
|
||||
for child in children {
|
||||
self.layout_subtree(context, frame, tree, indent + 1, child, blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing ghost cursor, for positioning according to last cursor line
|
||||
if self.last_cursor.refers_to_last_child_of(id) {
|
||||
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
|
||||
blocks.blocks_mut().push_back(block);
|
||||
}
|
||||
|
||||
// Trailing editor or pseudomessage
|
||||
if self.cursor.refers_to_last_child_of(id) {
|
||||
match self.cursor {
|
||||
Cursor::Editor { .. } => {
|
||||
blocks
|
||||
.blocks_mut()
|
||||
.push_back(self.editor_block(context, frame, indent + 1))
|
||||
}
|
||||
Cursor::Pseudo { .. } => {
|
||||
blocks
|
||||
.blocks_mut()
|
||||
.push_back(self.pseudo_block(context, frame, indent + 1))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_tree(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
tree: Tree<M>,
|
||||
) -> TreeBlocks<M::Id> {
|
||||
let root = Root::Tree(tree.root().clone());
|
||||
let mut blocks = TreeBlocks::new(root.clone(), root);
|
||||
self.layout_subtree(context, frame, &tree, 0, tree.root(), &mut blocks);
|
||||
blocks
|
||||
}
|
||||
|
||||
fn layout_bottom(&self, context: &Context, frame: &mut Frame) -> TreeBlocks<M::Id> {
|
||||
let mut blocks = TreeBlocks::new(Root::Bottom, Root::Bottom);
|
||||
|
||||
// Ghost cursor, for positioning according to last cursor line
|
||||
if let Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } =
|
||||
self.last_cursor
|
||||
{
|
||||
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
|
||||
blocks.blocks_mut().push_back(block);
|
||||
}
|
||||
|
||||
match self.cursor {
|
||||
Cursor::Bottom => {
|
||||
let block = Block::new(frame, BlockId::Cursor, Empty::new());
|
||||
blocks.blocks_mut().push_back(block);
|
||||
}
|
||||
Cursor::Editor { parent: None, .. } => blocks
|
||||
.blocks_mut()
|
||||
.push_back(self.editor_block(context, frame, 0)),
|
||||
Cursor::Pseudo { parent: None, .. } => blocks
|
||||
.blocks_mut()
|
||||
.push_back(self.pseudo_block(context, frame, 0)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
async fn expand_to_top(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) -> Result<(), S::Error> {
|
||||
let top_line = 0;
|
||||
|
||||
while blocks.blocks().top_line > top_line {
|
||||
let top_root = blocks.top_root();
|
||||
let prev_root_id = match top_root {
|
||||
Root::Bottom => self.store.last_root_id().await?,
|
||||
Root::Tree(root_id) => self.store.prev_root_id(root_id).await?,
|
||||
};
|
||||
let prev_root_id = match prev_root_id {
|
||||
Some(id) => id,
|
||||
None => break,
|
||||
};
|
||||
let prev_tree = self.store.tree(&prev_root_id).await?;
|
||||
blocks.prepend(self.layout_tree(context, frame, prev_tree));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expand_to_bottom(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) -> Result<(), S::Error> {
|
||||
let bottom_line = frame.size().height as i32 - 1;
|
||||
|
||||
while blocks.blocks().bottom_line < bottom_line {
|
||||
let bottom_root = blocks.bottom_root();
|
||||
let next_root_id = match bottom_root {
|
||||
Root::Bottom => break,
|
||||
Root::Tree(root_id) => self.store.next_root_id(root_id).await?,
|
||||
};
|
||||
if let Some(next_root_id) = next_root_id {
|
||||
let next_tree = self.store.tree(&next_root_id).await?;
|
||||
blocks.append(self.layout_tree(context, frame, next_tree));
|
||||
} else {
|
||||
blocks.append(self.layout_bottom(context, frame));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fill_screen_and_clamp_scrolling(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) -> Result<(), S::Error> {
|
||||
let top_line = 0;
|
||||
let bottom_line = frame.size().height as i32 - 1;
|
||||
|
||||
self.expand_to_top(context, frame, blocks).await?;
|
||||
|
||||
if blocks.blocks().top_line > top_line {
|
||||
blocks.blocks_mut().set_top_line(0);
|
||||
}
|
||||
|
||||
self.expand_to_bottom(context, frame, blocks).await?;
|
||||
|
||||
if blocks.blocks().bottom_line < bottom_line {
|
||||
blocks.blocks_mut().set_bottom_line(bottom_line);
|
||||
}
|
||||
|
||||
self.expand_to_top(context, frame, blocks).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn layout_last_cursor_seed(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
last_cursor_path: &Path<M::Id>,
|
||||
) -> Result<TreeBlocks<M::Id>, S::Error> {
|
||||
Ok(match &self.last_cursor {
|
||||
Cursor::Bottom => {
|
||||
let mut blocks = self.layout_bottom(context, frame);
|
||||
|
||||
let bottom_line = frame.size().height as i32 - 1;
|
||||
blocks.blocks_mut().set_bottom_line(bottom_line);
|
||||
|
||||
blocks
|
||||
}
|
||||
Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } => {
|
||||
let mut blocks = self.layout_bottom(context, frame);
|
||||
|
||||
blocks
|
||||
.blocks_mut()
|
||||
.recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line);
|
||||
|
||||
blocks
|
||||
}
|
||||
Cursor::Msg(_)
|
||||
| Cursor::Editor {
|
||||
parent: Some(_), ..
|
||||
}
|
||||
| Cursor::Pseudo {
|
||||
parent: Some(_), ..
|
||||
} => {
|
||||
let root = last_cursor_path.first();
|
||||
let tree = self.store.tree(root).await?;
|
||||
let mut blocks = self.layout_tree(context, frame, tree);
|
||||
|
||||
blocks
|
||||
.blocks_mut()
|
||||
.recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line);
|
||||
|
||||
blocks
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn layout_cursor_seed(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
last_cursor_path: &Path<M::Id>,
|
||||
cursor_path: &Path<M::Id>,
|
||||
) -> Result<TreeBlocks<M::Id>, S::Error> {
|
||||
let bottom_line = frame.size().height as i32 - 1;
|
||||
|
||||
Ok(match &self.cursor {
|
||||
Cursor::Bottom
|
||||
| Cursor::Editor { parent: None, .. }
|
||||
| Cursor::Pseudo { parent: None, .. } => {
|
||||
let mut blocks = self.layout_bottom(context, frame);
|
||||
|
||||
blocks.blocks_mut().set_bottom_line(bottom_line);
|
||||
|
||||
blocks
|
||||
}
|
||||
Cursor::Msg(_)
|
||||
| Cursor::Editor {
|
||||
parent: Some(_), ..
|
||||
}
|
||||
| Cursor::Pseudo {
|
||||
parent: Some(_), ..
|
||||
} => {
|
||||
let root = cursor_path.first();
|
||||
let tree = self.store.tree(root).await?;
|
||||
let mut blocks = self.layout_tree(context, frame, tree);
|
||||
|
||||
let cursor_above_last = cursor_path < last_cursor_path;
|
||||
let cursor_line = if cursor_above_last { 0 } else { bottom_line };
|
||||
blocks
|
||||
.blocks_mut()
|
||||
.recalculate_offsets(&BlockId::from_cursor(&self.cursor), cursor_line);
|
||||
|
||||
blocks
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn layout_initial_seed(
|
||||
&self,
|
||||
context: &Context,
|
||||
frame: &mut Frame,
|
||||
last_cursor_path: &Path<M::Id>,
|
||||
cursor_path: &Path<M::Id>,
|
||||
) -> Result<TreeBlocks<M::Id>, S::Error> {
|
||||
if let Cursor::Bottom = self.cursor {
|
||||
self.layout_cursor_seed(context, frame, last_cursor_path, cursor_path)
|
||||
.await
|
||||
} else {
|
||||
self.layout_last_cursor_seed(context, frame, last_cursor_path)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_so_cursor_is_visible(&self, frame: &Frame, blocks: &mut TreeBlocks<M::Id>) {
|
||||
if matches!(self.cursor, Cursor::Bottom) {
|
||||
return; // Cursor is locked to bottom
|
||||
}
|
||||
|
||||
let block = blocks
|
||||
.blocks()
|
||||
.find(&BlockId::from_cursor(&self.cursor))
|
||||
.expect("no cursor found");
|
||||
|
||||
let height = frame.size().height as i32;
|
||||
let scrolloff = scrolloff(height);
|
||||
|
||||
let min_line = -block.focus.start + scrolloff;
|
||||
let max_line = height - block.focus.end - scrolloff;
|
||||
|
||||
// If the message is higher than the available space, the top of the
|
||||
// message should always be visible. I'm not using top_line.clamp(...)
|
||||
// because the order of the min and max matters.
|
||||
let top_line = block.top_line;
|
||||
#[allow(clippy::manual_clamp)]
|
||||
let new_top_line = top_line.min(max_line).max(min_line);
|
||||
if new_top_line != top_line {
|
||||
blocks.blocks_mut().offset(new_top_line - top_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_so_cursor_is_centered(&self, frame: &Frame, blocks: &mut TreeBlocks<M::Id>) {
|
||||
if matches!(self.cursor, Cursor::Bottom) {
|
||||
return; // Cursor is locked to bottom
|
||||
}
|
||||
|
||||
let block = blocks
|
||||
.blocks()
|
||||
.find(&BlockId::from_cursor(&self.cursor))
|
||||
.expect("no cursor found");
|
||||
|
||||
let height = frame.size().height as i32;
|
||||
let scrolloff = scrolloff(height);
|
||||
|
||||
let min_line = -block.focus.start + scrolloff;
|
||||
let max_line = height - block.focus.end - scrolloff;
|
||||
|
||||
// If the message is higher than the available space, the top of the
|
||||
// message should always be visible. I'm not using top_line.clamp(...)
|
||||
// because the order of the min and max matters.
|
||||
let top_line = block.top_line;
|
||||
let new_top_line = (height - block.height) / 2;
|
||||
#[allow(clippy::manual_clamp)]
|
||||
let new_top_line = new_top_line.min(max_line).max(min_line);
|
||||
if new_top_line != top_line {
|
||||
blocks.blocks_mut().offset(new_top_line - top_line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to obtain a [`Cursor::Msg`] pointing to the block.
|
||||
fn msg_id(block: &Block<BlockId<M::Id>>) -> Option<M::Id> {
|
||||
match &block.id {
|
||||
BlockId::Msg(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn visible(block: &Block<BlockId<M::Id>>, first_line: i32, last_line: i32) -> bool {
|
||||
(first_line + 1 - block.height..=last_line).contains(&block.top_line)
|
||||
}
|
||||
|
||||
fn move_cursor_so_it_is_visible(
|
||||
&mut self,
|
||||
frame: &Frame,
|
||||
blocks: &TreeBlocks<M::Id>,
|
||||
) -> Option<M::Id> {
|
||||
if !matches!(self.cursor, Cursor::Bottom | Cursor::Msg(_)) {
|
||||
// In all other cases, there is no need to make the cursor visible
|
||||
// since scrolling behaves differently enough.
|
||||
return None;
|
||||
}
|
||||
|
||||
let height = frame.size().height as i32;
|
||||
let scrolloff = scrolloff(height);
|
||||
|
||||
let first_line = scrolloff;
|
||||
let last_line = height - 1 - scrolloff;
|
||||
|
||||
let new_cursor = if matches!(self.cursor, Cursor::Bottom) {
|
||||
blocks
|
||||
.blocks()
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|b| Self::visible(b, first_line, last_line))
|
||||
.find_map(Self::msg_id)
|
||||
} else {
|
||||
let block = blocks
|
||||
.blocks()
|
||||
.find(&BlockId::from_cursor(&self.cursor))
|
||||
.expect("no cursor found");
|
||||
|
||||
if Self::visible(block, first_line, last_line) {
|
||||
return None;
|
||||
} else if block.top_line < first_line {
|
||||
blocks
|
||||
.blocks()
|
||||
.iter()
|
||||
.filter(|b| Self::visible(b, first_line, last_line))
|
||||
.find_map(Self::msg_id)
|
||||
} else {
|
||||
blocks
|
||||
.blocks()
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|b| Self::visible(b, first_line, last_line))
|
||||
.find_map(Self::msg_id)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(id) = new_cursor {
|
||||
self.cursor = Cursor::Msg(id.clone());
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_msgs(frame: &Frame, blocks: &TreeBlocks<M::Id>) -> Vec<M::Id> {
|
||||
let height: i32 = frame.size().height.into();
|
||||
let first_line = 0;
|
||||
let last_line = first_line + height - 1;
|
||||
|
||||
let mut result = vec![];
|
||||
for block in blocks.blocks().iter() {
|
||||
if Self::visible(block, first_line, last_line) {
|
||||
if let BlockId::Msg(id) = &block.id {
|
||||
result.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn relayout(
|
||||
&mut self,
|
||||
nick: String,
|
||||
focused: bool,
|
||||
frame: &mut Frame,
|
||||
) -> Result<TreeBlocks<M::Id>, S::Error> {
|
||||
// The basic idea is this:
|
||||
//
|
||||
// First, layout a full screen of blocks around self.last_cursor, using
|
||||
// self.last_cursor_line for offset positioning. At this point, any
|
||||
// outstanding scrolling is performed as well.
|
||||
//
|
||||
// Then, check if self.cursor is somewhere in these blocks. If it is, we
|
||||
// now know the position of our own cursor. If it is not, it has jumped
|
||||
// too far away from self.last_cursor and we'll need to render a new
|
||||
// full screen of blocks around self.cursor before proceeding, using the
|
||||
// cursor paths to determine the position of self.cursor on the screen.
|
||||
//
|
||||
// Now that we have a more-or-less accurate screen position of
|
||||
// self.cursor, we can perform the actual cursor logic, i.e. make the
|
||||
// cursor visible or move it so it is visible.
|
||||
//
|
||||
// This entire process is complicated by the different kinds of cursors.
|
||||
|
||||
let context = Context { nick, focused };
|
||||
|
||||
let last_cursor_path = self.cursor_path(&self.last_cursor).await?;
|
||||
let cursor_path = self.cursor_path(&self.cursor).await?;
|
||||
self.make_path_visible(&cursor_path);
|
||||
|
||||
let mut blocks = self
|
||||
.layout_initial_seed(&context, frame, &last_cursor_path, &cursor_path)
|
||||
.await?;
|
||||
blocks.blocks_mut().offset(self.scroll);
|
||||
self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks)
|
||||
.await?;
|
||||
|
||||
if !self.contains_cursor(&blocks) {
|
||||
blocks = self
|
||||
.layout_cursor_seed(&context, frame, &last_cursor_path, &cursor_path)
|
||||
.await?;
|
||||
self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks)
|
||||
.await?;
|
||||
}
|
||||
|
||||
match self.correction {
|
||||
Some(Correction::MakeCursorVisible) => {
|
||||
self.scroll_so_cursor_is_visible(frame, &mut blocks);
|
||||
self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks)
|
||||
.await?;
|
||||
}
|
||||
Some(Correction::MoveCursorToVisibleArea) => {
|
||||
let new_cursor_msg_id = self.move_cursor_so_it_is_visible(frame, &blocks);
|
||||
if let Some(cursor_msg_id) = new_cursor_msg_id {
|
||||
// Moving the cursor invalidates our current blocks, so we sadly
|
||||
// have to either perform an expensive operation or redraw the
|
||||
// entire thing. I'm choosing the latter for now.
|
||||
|
||||
self.last_cursor = self.cursor.clone();
|
||||
self.last_cursor_line = self.cursor_line(&blocks);
|
||||
self.last_visible_msgs = Self::visible_msgs(frame, &blocks);
|
||||
self.scroll = 0;
|
||||
self.correction = None;
|
||||
|
||||
let last_cursor_path = self.store.path(&cursor_msg_id).await?;
|
||||
blocks = self
|
||||
.layout_last_cursor_seed(&context, frame, &last_cursor_path)
|
||||
.await?;
|
||||
self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some(Correction::CenterCursor) => {
|
||||
self.scroll_so_cursor_is_centered(frame, &mut blocks);
|
||||
self.fill_screen_and_clamp_scrolling(&context, frame, &mut blocks)
|
||||
.await?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
self.last_cursor = self.cursor.clone();
|
||||
self.last_cursor_line = self.cursor_line(&blocks);
|
||||
self.last_visible_msgs = Self::visible_msgs(frame, &blocks);
|
||||
self.scroll = 0;
|
||||
self.correction = None;
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
use crate::ui::chat::blocks::Blocks;
|
||||
|
||||
use super::Cursor;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockId<I> {
|
||||
Msg(I),
|
||||
Cursor,
|
||||
LastCursor,
|
||||
}
|
||||
|
||||
impl<I: Clone> BlockId<I> {
|
||||
pub fn from_cursor(cursor: &Cursor<I>) -> Self {
|
||||
match cursor {
|
||||
Cursor::Msg(id) => Self::Msg(id.clone()),
|
||||
_ => Self::Cursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Root<I> {
|
||||
Bottom,
|
||||
Tree(I),
|
||||
}
|
||||
|
||||
pub struct TreeBlocks<I> {
|
||||
blocks: Blocks<BlockId<I>>,
|
||||
top_root: Root<I>,
|
||||
bottom_root: Root<I>,
|
||||
}
|
||||
|
||||
impl<I> TreeBlocks<I> {
|
||||
pub fn new(top_root: Root<I>, bottom_root: Root<I>) -> Self {
|
||||
Self {
|
||||
blocks: Blocks::new(),
|
||||
top_root,
|
||||
bottom_root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blocks(&self) -> &Blocks<BlockId<I>> {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
pub fn blocks_mut(&mut self) -> &mut Blocks<BlockId<I>> {
|
||||
&mut self.blocks
|
||||
}
|
||||
|
||||
pub fn into_blocks(self) -> Blocks<BlockId<I>> {
|
||||
self.blocks
|
||||
}
|
||||
|
||||
pub fn top_root(&self) -> &Root<I> {
|
||||
&self.top_root
|
||||
}
|
||||
|
||||
pub fn bottom_root(&self) -> &Root<I> {
|
||||
&self.bottom_root
|
||||
}
|
||||
|
||||
pub fn prepend(&mut self, other: Self) {
|
||||
self.blocks.prepend(other.blocks);
|
||||
self.top_root = other.top_root;
|
||||
}
|
||||
|
||||
pub fn append(&mut self, other: Self) {
|
||||
self.blocks.append(other.blocks);
|
||||
self.bottom_root = other.bottom_root;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
mod indent;
|
||||
mod seen;
|
||||
mod time;
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use toss::styled::Styled;
|
||||
use toss::widthdb::WidthDb;
|
||||
|
||||
use super::super::ChatMsg;
|
||||
use crate::store::Msg;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::join::{HJoin, Segment};
|
||||
use crate::ui::widgets::layer::Layer;
|
||||
use crate::ui::widgets::padding::Padding;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
use self::indent::Indent;
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
pub fn style_placeholder() -> ContentStyle {
|
||||
ContentStyle::default().dark_grey()
|
||||
}
|
||||
|
||||
fn style_time(highlighted: bool) -> ContentStyle {
|
||||
if highlighted {
|
||||
ContentStyle::default().black().on_white()
|
||||
} else {
|
||||
ContentStyle::default().grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_indent(highlighted: bool) -> ContentStyle {
|
||||
if highlighted {
|
||||
ContentStyle::default().black().on_white()
|
||||
} else {
|
||||
ContentStyle::default().dark_grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_info() -> ContentStyle {
|
||||
ContentStyle::default().italic().dark_grey()
|
||||
}
|
||||
|
||||
fn style_editor_highlight() -> ContentStyle {
|
||||
ContentStyle::default().black().on_cyan()
|
||||
}
|
||||
|
||||
fn style_pseudo_highlight() -> ContentStyle {
|
||||
ContentStyle::default().black().on_yellow()
|
||||
}
|
||||
|
||||
pub fn msg<M: Msg + ChatMsg>(
|
||||
highlighted: bool,
|
||||
indent: usize,
|
||||
msg: &M,
|
||||
folded_info: Option<usize>,
|
||||
) -> BoxedWidget {
|
||||
let (nick, mut content) = msg.styled();
|
||||
|
||||
if let Some(amount) = folded_info {
|
||||
content = content
|
||||
.then_plain("\n")
|
||||
.then(format!("[{amount} more]"), style_info());
|
||||
}
|
||||
|
||||
HJoin::new(vec![
|
||||
Segment::new(seen::widget(msg.seen())),
|
||||
Segment::new(
|
||||
Padding::new(time::widget(Some(msg.time()), style_time(highlighted)))
|
||||
.stretch(true)
|
||||
.right(1),
|
||||
),
|
||||
Segment::new(Indent::new(indent, style_indent(highlighted))),
|
||||
Segment::new(Layer::new(vec![
|
||||
Indent::new(1, style_indent(false)).into(),
|
||||
Padding::new(Text::new(nick)).right(1).into(),
|
||||
])),
|
||||
// TODO Minimum content width
|
||||
// TODO Minimizing and maximizing messages
|
||||
Segment::new(Text::new(content).wrap(true)).priority(1),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn msg_placeholder(
|
||||
highlighted: bool,
|
||||
indent: usize,
|
||||
folded_info: Option<usize>,
|
||||
) -> BoxedWidget {
|
||||
let mut content = Styled::new(PLACEHOLDER, style_placeholder());
|
||||
|
||||
if let Some(amount) = folded_info {
|
||||
content = content
|
||||
.then_plain("\n")
|
||||
.then(format!("[{amount} more]"), style_info());
|
||||
}
|
||||
|
||||
HJoin::new(vec![
|
||||
Segment::new(seen::widget(true)),
|
||||
Segment::new(
|
||||
Padding::new(time::widget(None, style_time(highlighted)))
|
||||
.stretch(true)
|
||||
.right(1),
|
||||
),
|
||||
Segment::new(Indent::new(indent, style_indent(highlighted))),
|
||||
Segment::new(Text::new(content)),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn editor<M: ChatMsg>(
|
||||
widthdb: &mut WidthDb,
|
||||
indent: usize,
|
||||
nick: &str,
|
||||
editor: &EditorState,
|
||||
) -> (BoxedWidget, usize) {
|
||||
let (nick, content) = M::edit(nick, &editor.text());
|
||||
let editor = editor.widget().highlight(|_| content);
|
||||
let cursor_row = editor.cursor_row(widthdb);
|
||||
|
||||
let widget = HJoin::new(vec![
|
||||
Segment::new(seen::widget(true)),
|
||||
Segment::new(
|
||||
Padding::new(time::widget(None, style_editor_highlight()))
|
||||
.stretch(true)
|
||||
.right(1),
|
||||
),
|
||||
Segment::new(Indent::new(indent, style_editor_highlight())),
|
||||
Segment::new(Layer::new(vec![
|
||||
Indent::new(1, style_indent(false)).into(),
|
||||
Padding::new(Text::new(nick)).right(1).into(),
|
||||
])),
|
||||
Segment::new(editor).priority(1).expanding(true),
|
||||
])
|
||||
.into();
|
||||
|
||||
(widget, cursor_row)
|
||||
}
|
||||
|
||||
pub fn pseudo<M: ChatMsg>(indent: usize, nick: &str, editor: &EditorState) -> BoxedWidget {
|
||||
let (nick, content) = M::edit(nick, &editor.text());
|
||||
|
||||
HJoin::new(vec![
|
||||
Segment::new(seen::widget(true)),
|
||||
Segment::new(
|
||||
Padding::new(time::widget(None, style_pseudo_highlight()))
|
||||
.stretch(true)
|
||||
.right(1),
|
||||
),
|
||||
Segment::new(Indent::new(indent, style_pseudo_highlight())),
|
||||
Segment::new(Layer::new(vec![
|
||||
Indent::new(1, style_indent(false)).into(),
|
||||
Padding::new(Text::new(nick)).right(1).into(),
|
||||
])),
|
||||
Segment::new(Text::new(content).wrap(true)).priority(1),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use crossterm::style::ContentStyle;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
|
||||
use crate::ui::widgets::Widget;
|
||||
|
||||
pub const INDENT: &str = "│ ";
|
||||
pub const INDENT_WIDTH: usize = 2;
|
||||
|
||||
pub struct Indent {
|
||||
level: usize,
|
||||
style: ContentStyle,
|
||||
}
|
||||
|
||||
impl Indent {
|
||||
pub fn new(level: usize, style: ContentStyle) -> Self {
|
||||
Self { level, style }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Indent {
|
||||
fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size {
|
||||
Size::new((INDENT_WIDTH * self.level) as u16, 0)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
let size = frame.size();
|
||||
|
||||
for y in 0..size.height {
|
||||
frame.write(
|
||||
Pos::new(0, y.into()),
|
||||
(INDENT.repeat(self.level), self.style),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
use crossterm::style::{ContentStyle, Stylize};
|
||||
|
||||
use crate::ui::widgets::background::Background;
|
||||
use crate::ui::widgets::empty::Empty;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
const UNSEEN: &str = "*";
|
||||
const WIDTH: u16 = 1;
|
||||
|
||||
fn seen_style() -> ContentStyle {
|
||||
ContentStyle::default().black().on_green()
|
||||
}
|
||||
|
||||
pub fn widget(seen: bool) -> BoxedWidget {
|
||||
if seen {
|
||||
Empty::new().width(WIDTH).into()
|
||||
} else {
|
||||
let style = seen_style();
|
||||
Background::new(Text::new((UNSEEN, style)))
|
||||
.style(style)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
use crossterm::style::ContentStyle;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::ui::widgets::background::Background;
|
||||
use crate::ui::widgets::empty::Empty;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]");
|
||||
const TIME_WIDTH: u16 = 16;
|
||||
|
||||
pub fn widget(time: Option<OffsetDateTime>, style: ContentStyle) -> BoxedWidget {
|
||||
if let Some(time) = time {
|
||||
let text = time.format(TIME_FORMAT).expect("could not format time");
|
||||
Background::new(Text::new((text, style)))
|
||||
.style(style)
|
||||
.into()
|
||||
} else {
|
||||
Background::new(Empty::new().width(TIME_WIDTH))
|
||||
.style(style)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use euphoxide::api::PersonalAccountView;
|
||||
use euphoxide::conn;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::util;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::empty::Empty;
|
||||
use crate::ui::widgets::join::{HJoin, Segment, VJoin};
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::resize::Resize;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Email,
|
||||
Password,
|
||||
}
|
||||
|
||||
pub struct LoggedOut {
|
||||
focus: Focus,
|
||||
email: EditorState,
|
||||
password: EditorState,
|
||||
}
|
||||
|
||||
impl LoggedOut {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
focus: Focus::Email,
|
||||
email: EditorState::new(),
|
||||
password: EditorState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(&self) -> BoxedWidget {
|
||||
let bold = ContentStyle::default().bold();
|
||||
VJoin::new(vec![
|
||||
Segment::new(Text::new(("Not logged in", bold.yellow()))),
|
||||
Segment::new(Empty::new().height(1)),
|
||||
Segment::new(HJoin::new(vec![
|
||||
Segment::new(Text::new(("Email address:", bold))),
|
||||
Segment::new(Empty::new().width(1)),
|
||||
Segment::new(self.email.widget().focus(self.focus == Focus::Email)),
|
||||
])),
|
||||
Segment::new(HJoin::new(vec![
|
||||
Segment::new(Text::new(("Password:", bold))),
|
||||
Segment::new(Empty::new().width(5 + 1)),
|
||||
Segment::new(
|
||||
self.password
|
||||
.widget()
|
||||
.focus(self.focus == Focus::Password)
|
||||
.hidden(),
|
||||
),
|
||||
])),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LoggedIn(PersonalAccountView);
|
||||
|
||||
impl LoggedIn {
|
||||
fn widget(&self) -> BoxedWidget {
|
||||
let bold = ContentStyle::default().bold();
|
||||
VJoin::new(vec![
|
||||
Segment::new(Text::new(("Logged in", bold.green()))),
|
||||
Segment::new(Empty::new().height(1)),
|
||||
Segment::new(HJoin::new(vec![
|
||||
Segment::new(Text::new(("Email address:", bold))),
|
||||
Segment::new(Empty::new().width(1)),
|
||||
Segment::new(Text::new((&self.0.email,))),
|
||||
])),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AccountUiState {
|
||||
LoggedOut(LoggedOut),
|
||||
LoggedIn(LoggedIn),
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
impl AccountUiState {
|
||||
pub fn new() -> Self {
|
||||
Self::LoggedOut(LoggedOut::new())
|
||||
}
|
||||
|
||||
/// Returns `false` if the account UI should not be displayed any longer.
|
||||
pub fn stabilize(&mut self, state: Option<&euph::State>) -> bool {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(state))) = state {
|
||||
match (&self, &state.account) {
|
||||
(Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())),
|
||||
(Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> BoxedWidget {
|
||||
let inner = match self {
|
||||
Self::LoggedOut(logged_out) => logged_out.widget(),
|
||||
Self::LoggedIn(logged_in) => logged_in.widget(),
|
||||
};
|
||||
Popup::new(Resize::new(inner).min_width(40))
|
||||
.title("Account")
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close account ui");
|
||||
|
||||
match self {
|
||||
Self::LoggedOut(logged_out) => {
|
||||
match logged_out.focus {
|
||||
Focus::Email => bindings.binding("enter", "focus on password"),
|
||||
Focus::Password => bindings.binding("enter", "log in"),
|
||||
}
|
||||
bindings.binding("tab", "switch focus");
|
||||
util::list_editor_key_bindings(bindings, |c| c != '\n');
|
||||
}
|
||||
Self::LoggedIn(_) => bindings.binding("L", "log out"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
) -> EventResult {
|
||||
if let key!(Esc) = event {
|
||||
return EventResult::ResetState;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::LoggedOut(logged_out) => {
|
||||
if let key!(Tab) = event {
|
||||
logged_out.focus = match logged_out.focus {
|
||||
Focus::Email => Focus::Password,
|
||||
Focus::Password => Focus::Email,
|
||||
};
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
match logged_out.focus {
|
||||
Focus::Email => {
|
||||
if let key!(Enter) = event {
|
||||
logged_out.focus = Focus::Password;
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(
|
||||
&logged_out.email,
|
||||
terminal,
|
||||
event,
|
||||
|c| c != '\n',
|
||||
) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
Focus::Password => {
|
||||
if let key!(Enter) = event {
|
||||
if let Some(room) = room {
|
||||
let _ =
|
||||
room.login(logged_out.email.text(), logged_out.password.text());
|
||||
}
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(
|
||||
&logged_out.password,
|
||||
terminal,
|
||||
event,
|
||||
|c| c != '\n',
|
||||
) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::LoggedIn(_) => {
|
||||
if let key!('L') = event {
|
||||
if let Some(room) = room {
|
||||
let _ = room.logout();
|
||||
}
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::euph::Room;
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::util;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub fn new() -> EditorState {
|
||||
EditorState::new()
|
||||
}
|
||||
|
||||
pub fn widget(editor: &EditorState) -> BoxedWidget {
|
||||
Popup::new(editor.widget().hidden())
|
||||
.title("Enter password")
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "authenticate");
|
||||
util::list_editor_key_bindings(bindings, |_| true);
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
editor: &EditorState,
|
||||
) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::ResetState,
|
||||
key!(Enter) => {
|
||||
if let Some(room) = &room {
|
||||
let _ = room.auth(editor.text());
|
||||
}
|
||||
EventResult::ResetState
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, |_| true) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
use std::io;
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::list::ListState;
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub struct LinksState {
|
||||
links: Vec<String>,
|
||||
list: ListState<usize>,
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
Close,
|
||||
ErrorOpeningLink { link: String, error: io::Error },
|
||||
}
|
||||
|
||||
const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
||||
|
||||
impl LinksState {
|
||||
pub fn new(content: &str) -> Self {
|
||||
let links = LinkFinder::new()
|
||||
.url_must_have_scheme(false)
|
||||
.kinds(&[LinkKind::Url])
|
||||
.links(content)
|
||||
.map(|l| l.as_str().to_string())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
links,
|
||||
list: ListState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> BoxedWidget {
|
||||
let style_selected = ContentStyle::default().black().on_white();
|
||||
|
||||
let mut list = self.list.widget().focus(true);
|
||||
if self.links.is_empty() {
|
||||
list.add_unsel(Text::new((
|
||||
"No links found",
|
||||
ContentStyle::default().grey().italic(),
|
||||
)))
|
||||
}
|
||||
for (id, link) in self.links.iter().enumerate() {
|
||||
let (line_normal, line_selected) = if let Some(number_key) = NUMBER_KEYS.get(id) {
|
||||
(
|
||||
Styled::new(
|
||||
format!("[{number_key}]"),
|
||||
ContentStyle::default().dark_grey().bold(),
|
||||
)
|
||||
.then_plain(" ")
|
||||
.then_plain(link),
|
||||
Styled::new(format!("[{number_key}]"), style_selected.bold())
|
||||
.then(" ", style_selected)
|
||||
.then(link, style_selected),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Styled::new_plain(format!(" {link}")),
|
||||
Styled::new(format!(" {link}"), style_selected),
|
||||
)
|
||||
};
|
||||
|
||||
list.add_sel(id, Text::new(line_normal), Text::new(line_selected));
|
||||
}
|
||||
|
||||
Popup::new(list).title("Links").build()
|
||||
}
|
||||
|
||||
fn open_link_by_id(&self, id: usize) -> EventResult {
|
||||
if let Some(link) = self.links.get(id) {
|
||||
// The `http://` or `https://` schema is necessary for open::that to
|
||||
// successfully open the link in the browser.
|
||||
let link = if link.starts_with("http://") || link.starts_with("https://") {
|
||||
link.clone()
|
||||
} else {
|
||||
format!("https://{link}")
|
||||
};
|
||||
|
||||
if let Err(error) = open::that(&link) {
|
||||
return EventResult::ErrorOpeningLink { link, error };
|
||||
}
|
||||
}
|
||||
EventResult::Handled
|
||||
}
|
||||
|
||||
fn open_link(&self) -> EventResult {
|
||||
if let Some(id) = self.list.cursor() {
|
||||
self.open_link_by_id(id)
|
||||
} else {
|
||||
EventResult::Handled
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close links popup");
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down");
|
||||
bindings.empty();
|
||||
bindings.binding("enter", "open selected link");
|
||||
bindings.binding("1,2,...", "open link by position");
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => return EventResult::Close,
|
||||
key!('k') | key!(Up) => self.list.move_cursor_up(),
|
||||
key!('j') | key!(Down) => self.list.move_cursor_down(),
|
||||
key!('g') | key!(Home) => self.list.move_cursor_to_top(),
|
||||
key!('G') | key!(End) => self.list.move_cursor_to_bottom(),
|
||||
key!(Ctrl + 'y') => self.list.scroll_up(1),
|
||||
key!(Ctrl + 'e') => self.list.scroll_down(1),
|
||||
key!(Enter) => return self.open_link(),
|
||||
key!('1') => return self.open_link_by_id(0),
|
||||
key!('2') => return self.open_link_by_id(1),
|
||||
key!('3') => return self.open_link_by_id(2),
|
||||
key!('4') => return self.open_link_by_id(3),
|
||||
key!('5') => return self.open_link_by_id(4),
|
||||
key!('6') => return self.open_link_by_id(5),
|
||||
key!('7') => return self.open_link_by_id(6),
|
||||
key!('8') => return self.open_link_by_id(7),
|
||||
key!('9') => return self.open_link_by_id(8),
|
||||
key!('0') => return self.open_link_by_id(9),
|
||||
_ => return EventResult::NotHandled,
|
||||
}
|
||||
EventResult::Handled
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
use crossterm::style::ContentStyle;
|
||||
use euphoxide::conn::Joined;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::util;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::padding::Padding;
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub fn new(joined: Joined) -> EditorState {
|
||||
EditorState::with_initial_text(joined.session.name)
|
||||
}
|
||||
|
||||
pub fn widget(editor: &EditorState) -> BoxedWidget {
|
||||
let editor = editor
|
||||
.widget()
|
||||
.highlight(|s| euph::style_nick_exact(s, ContentStyle::default()));
|
||||
Popup::new(Padding::new(editor).left(1))
|
||||
.title("Choose nick")
|
||||
.inner_padding(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn nick_char(c: char) -> bool {
|
||||
c != '\n'
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "set nick");
|
||||
util::list_editor_key_bindings(bindings, nick_char);
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
editor: &EditorState,
|
||||
) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::ResetState,
|
||||
key!(Enter) => {
|
||||
if let Some(room) = &room {
|
||||
let _ = room.nick(editor.text());
|
||||
}
|
||||
EventResult::ResetState
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, nick_char) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
use std::borrow::Cow;
|
||||
use std::iter;
|
||||
|
||||
use crossterm::style::{Color, ContentStyle, Stylize};
|
||||
use euphoxide::api::{NickEvent, SessionId, SessionType, SessionView, UserId};
|
||||
use euphoxide::conn::{Joined, SessionInfo};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use crate::euph;
|
||||
use crate::ui::widgets::background::Background;
|
||||
use crate::ui::widgets::empty::Empty;
|
||||
use crate::ui::widgets::list::{List, ListState};
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub fn widget(state: &ListState<SessionId>, joined: &Joined, focused: bool) -> BoxedWidget {
|
||||
let mut list = state.widget().focus(focused);
|
||||
render_rows(&mut list, joined);
|
||||
list.into()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct HalfSession {
|
||||
name: String,
|
||||
id: UserId,
|
||||
session_id: SessionId,
|
||||
is_staff: bool,
|
||||
is_manager: bool,
|
||||
}
|
||||
|
||||
impl HalfSession {
|
||||
fn from_session_view(sess: &SessionView) -> Self {
|
||||
Self {
|
||||
name: sess.name.clone(),
|
||||
id: sess.id.clone(),
|
||||
session_id: sess.session_id.clone(),
|
||||
is_staff: sess.is_staff,
|
||||
is_manager: sess.is_manager,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_nick_event(nick: &NickEvent) -> Self {
|
||||
Self {
|
||||
name: nick.to.clone(),
|
||||
id: nick.id.clone(),
|
||||
session_id: nick.session_id.clone(),
|
||||
is_staff: false,
|
||||
is_manager: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_session_info(info: &SessionInfo) -> Self {
|
||||
match info {
|
||||
SessionInfo::Full(sess) => Self::from_session_view(sess),
|
||||
SessionInfo::Partial(nick) => Self::from_nick_event(nick),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rows(list: &mut List<SessionId>, joined: &Joined) {
|
||||
let mut people = vec![];
|
||||
let mut bots = vec![];
|
||||
let mut lurkers = vec![];
|
||||
let mut nurkers = vec![];
|
||||
|
||||
let sessions = joined
|
||||
.listing
|
||||
.values()
|
||||
.map(HalfSession::from_session_info)
|
||||
.chain(iter::once(HalfSession::from_session_view(&joined.session)));
|
||||
for sess in sessions {
|
||||
match sess.id.session_type() {
|
||||
Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess),
|
||||
Some(SessionType::Bot) => bots.push(sess),
|
||||
_ if sess.name.is_empty() => lurkers.push(sess),
|
||||
_ => people.push(sess),
|
||||
}
|
||||
}
|
||||
|
||||
people.sort_unstable();
|
||||
bots.sort_unstable();
|
||||
lurkers.sort_unstable();
|
||||
nurkers.sort_unstable();
|
||||
|
||||
render_section(list, "People", &people, &joined.session);
|
||||
render_section(list, "Bots", &bots, &joined.session);
|
||||
render_section(list, "Lurkers", &lurkers, &joined.session);
|
||||
render_section(list, "Nurkers", &nurkers, &joined.session);
|
||||
}
|
||||
|
||||
fn render_section(
|
||||
list: &mut List<SessionId>,
|
||||
name: &str,
|
||||
sessions: &[HalfSession],
|
||||
own_session: &SessionView,
|
||||
) {
|
||||
if sessions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let heading_style = ContentStyle::new().bold();
|
||||
|
||||
if !list.is_empty() {
|
||||
list.add_unsel(Empty::new());
|
||||
}
|
||||
|
||||
let row = Styled::new_plain(" ")
|
||||
.then(name, heading_style)
|
||||
.then_plain(format!(" ({})", sessions.len()));
|
||||
list.add_unsel(Text::new(row));
|
||||
|
||||
for session in sessions {
|
||||
render_row(list, session, own_session);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_row(list: &mut List<SessionId>, session: &HalfSession, own_session: &SessionView) {
|
||||
let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() {
|
||||
let name = "lurk";
|
||||
let style = ContentStyle::default().grey();
|
||||
let style_inv = ContentStyle::default().black().on_grey();
|
||||
(Cow::Borrowed(name), style, style_inv, style_inv)
|
||||
} else {
|
||||
let name = &session.name as &str;
|
||||
let (r, g, b) = euph::nick_color(name);
|
||||
let color = Color::Rgb { r, g, b };
|
||||
let style = ContentStyle::default().bold().with(color);
|
||||
let style_inv = ContentStyle::default().bold().black().on(color);
|
||||
let perms_style_inv = ContentStyle::default().black().on(color);
|
||||
(euph::EMOJI.replace(name), style, style_inv, perms_style_inv)
|
||||
};
|
||||
|
||||
let perms = if session.is_staff {
|
||||
"!"
|
||||
} else if session.is_manager {
|
||||
"*"
|
||||
} else if session.id.session_type() == Some(SessionType::Account) {
|
||||
"~"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let owner = if session.session_id == own_session.session_id {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
|
||||
let normal = Styled::new_plain(owner)
|
||||
.then(&name, style)
|
||||
.then_plain(perms);
|
||||
let selected = Styled::new_plain(owner)
|
||||
.then(name, style_inv)
|
||||
.then(perms, perms_style_inv);
|
||||
list.add_sel(
|
||||
session.session_id.clone(),
|
||||
Text::new(normal),
|
||||
Background::new(Text::new(selected)).style(style_inv),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use crate::ui::widgets::float::Float;
|
||||
use crate::ui::widgets::popup::Popup;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
|
||||
pub enum RoomPopup {
|
||||
Error { description: String, reason: String },
|
||||
}
|
||||
|
||||
impl RoomPopup {
|
||||
fn server_error_widget(description: &str, reason: &str) -> BoxedWidget {
|
||||
let border_style = ContentStyle::default().red().bold();
|
||||
let text = Styled::new_plain(description)
|
||||
.then_plain("\n\n")
|
||||
.then("Reason:", ContentStyle::default().bold())
|
||||
.then_plain(" ")
|
||||
.then_plain(reason);
|
||||
Popup::new(Text::new(text))
|
||||
.title(("Error", border_style))
|
||||
.border(border_style)
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> BoxedWidget {
|
||||
let widget = match self {
|
||||
Self::Error {
|
||||
description,
|
||||
reason,
|
||||
} => Self::server_error_widget(description, reason),
|
||||
};
|
||||
|
||||
Float::new(widget).horizontal(0.5).vertical(0.5).into()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,782 +0,0 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId};
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined, Joining, SessionInfo};
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use toss::styled::Styled;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::config;
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::ui::chat::{ChatState, Reaction};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::border::Border;
|
||||
use crate::ui::widgets::editor::EditorState;
|
||||
use crate::ui::widgets::join::{HJoin, Segment, VJoin};
|
||||
use crate::ui::widgets::layer::Layer;
|
||||
use crate::ui::widgets::list::ListState;
|
||||
use crate::ui::widgets::padding::Padding;
|
||||
use crate::ui::widgets::text::Text;
|
||||
use crate::ui::widgets::BoxedWidget;
|
||||
use crate::ui::{util, UiEvent};
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
use super::account::{self, AccountUiState};
|
||||
use super::links::{self, LinksState};
|
||||
use super::popup::RoomPopup;
|
||||
use super::{auth, inspect, nick, nick_list};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Chat,
|
||||
NickList,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum State {
|
||||
Normal,
|
||||
Auth(EditorState),
|
||||
Nick(EditorState),
|
||||
Account(AccountUiState),
|
||||
Links(LinksState),
|
||||
InspectMessage(Message),
|
||||
InspectSession(SessionInfo),
|
||||
}
|
||||
|
||||
pub struct EuphRoom {
|
||||
server_config: ServerConfig,
|
||||
config: config::EuphRoom,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
room: Option<euph::Room>,
|
||||
|
||||
focus: Focus,
|
||||
state: State,
|
||||
popups: VecDeque<RoomPopup>,
|
||||
|
||||
chat: ChatState<euph::SmallMessage, EuphRoomVault>,
|
||||
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
|
||||
|
||||
nick_list: ListState<SessionId>,
|
||||
}
|
||||
|
||||
impl EuphRoom {
|
||||
pub fn new(
|
||||
server_config: ServerConfig,
|
||||
config: config::EuphRoom,
|
||||
vault: EuphRoomVault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_config,
|
||||
config,
|
||||
ui_event_tx,
|
||||
room: None,
|
||||
focus: Focus::Chat,
|
||||
state: State::Normal,
|
||||
popups: VecDeque::new(),
|
||||
chat: ChatState::new(vault),
|
||||
last_msg_sent: None,
|
||||
nick_list: ListState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault(&self) -> &EuphRoomVault {
|
||||
self.chat.store()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
self.vault().room()
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, next_instance_id: &mut usize) {
|
||||
if self.room.is_none() {
|
||||
let room = self.vault().room();
|
||||
let instance_config = self
|
||||
.server_config
|
||||
.clone()
|
||||
.room(self.vault().room().to_string())
|
||||
.name(format!("{room}-{}", next_instance_id))
|
||||
.human(true)
|
||||
.username(self.config.username.clone())
|
||||
.force_username(self.config.force_username)
|
||||
.password(self.config.password.clone());
|
||||
*next_instance_id = next_instance_id.wrapping_add(1);
|
||||
|
||||
let tx = self.ui_event_tx.clone();
|
||||
self.room = Some(euph::Room::new(
|
||||
self.vault().clone(),
|
||||
instance_config,
|
||||
move |e| {
|
||||
let _ = tx.send(UiEvent::Euph(e));
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.room = None;
|
||||
}
|
||||
|
||||
pub fn room_state(&self) -> Option<&euph::State> {
|
||||
if let Some(room) = &self.room {
|
||||
Some(room.state())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// TODO fn room_state_joined(&self) -> Option<&Joined> {}
|
||||
|
||||
pub fn stopped(&self) -> bool {
|
||||
self.room.as_ref().map(|r| r.stopped()).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self) {
|
||||
if let Some(room) = &self.room {
|
||||
if room.stopped() {
|
||||
self.room = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unseen_msgs_count(&self) -> usize {
|
||||
logging_unwrap!(self.vault().unseen_msgs_count().await)
|
||||
}
|
||||
|
||||
async fn stabilize_pseudo_msg(&mut self) {
|
||||
if let Some(id_rx) = &mut self.last_msg_sent {
|
||||
match id_rx.try_recv() {
|
||||
Ok(id) => {
|
||||
self.chat.sent(Some(id)).await;
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {} // Wait a bit longer
|
||||
Err(TryRecvError::Closed) => {
|
||||
self.chat.sent(None).await;
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stabilize_focus(&mut self) {
|
||||
match self.room_state() {
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_))) => {}
|
||||
_ => self.focus = Focus::Chat, // There is no nick list to focus on
|
||||
}
|
||||
}
|
||||
|
||||
fn stabilize_state(&mut self) {
|
||||
let room_state = self.room.as_ref().map(|r| r.state());
|
||||
match (&mut self.state, room_state) {
|
||||
(
|
||||
State::Auth(_),
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)),
|
||||
) => {} // Nothing to see here
|
||||
(State::Auth(_), _) => self.state = State::Normal,
|
||||
|
||||
(State::Nick(_), Some(euph::State::Connected(_, conn::State::Joined(_)))) => {}
|
||||
(State::Nick(_), _) => self.state = State::Normal,
|
||||
|
||||
(State::Account(account), state) => {
|
||||
if !account.stabilize(state) {
|
||||
self.state = State::Normal
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stabilize(&mut self) {
|
||||
self.stabilize_pseudo_msg().await;
|
||||
self.stabilize_focus();
|
||||
self.stabilize_state();
|
||||
}
|
||||
|
||||
pub async fn widget(&mut self) -> BoxedWidget {
|
||||
self.stabilize().await;
|
||||
|
||||
let room_state = self.room_state();
|
||||
let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state
|
||||
{
|
||||
self.widget_with_nick_list(room_state, joined).await
|
||||
} else {
|
||||
self.widget_without_nick_list(room_state).await
|
||||
};
|
||||
|
||||
let mut layers = vec![chat];
|
||||
|
||||
match &self.state {
|
||||
State::Normal => {}
|
||||
State::Auth(editor) => layers.push(auth::widget(editor)),
|
||||
State::Nick(editor) => layers.push(nick::widget(editor)),
|
||||
State::Account(account) => layers.push(account.widget()),
|
||||
State::Links(links) => layers.push(links.widget()),
|
||||
State::InspectMessage(message) => layers.push(inspect::message_widget(message)),
|
||||
State::InspectSession(session) => layers.push(inspect::session_widget(session)),
|
||||
}
|
||||
|
||||
for popup in &self.popups {
|
||||
layers.push(popup.widget());
|
||||
}
|
||||
|
||||
Layer::new(layers).into()
|
||||
}
|
||||
|
||||
async fn widget_without_nick_list(&self, state: Option<&euph::State>) -> BoxedWidget {
|
||||
VJoin::new(vec![
|
||||
Segment::new(Border::new(
|
||||
Padding::new(self.status_widget(state).await).horizontal(1),
|
||||
)),
|
||||
// TODO Use last known nick?
|
||||
Segment::new(self.chat.widget(String::new(), true)).expanding(true),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn widget_with_nick_list(
|
||||
&self,
|
||||
state: Option<&euph::State>,
|
||||
joined: &Joined,
|
||||
) -> BoxedWidget {
|
||||
HJoin::new(vec![
|
||||
Segment::new(VJoin::new(vec![
|
||||
Segment::new(Border::new(
|
||||
Padding::new(self.status_widget(state).await).horizontal(1),
|
||||
)),
|
||||
Segment::new(
|
||||
self.chat
|
||||
.widget(joined.session.name.clone(), self.focus == Focus::Chat),
|
||||
)
|
||||
.expanding(true),
|
||||
]))
|
||||
.expanding(true),
|
||||
Segment::new(Border::new(
|
||||
Padding::new(nick_list::widget(
|
||||
&self.nick_list,
|
||||
joined,
|
||||
self.focus == Focus::NickList,
|
||||
))
|
||||
.right(1),
|
||||
)),
|
||||
])
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> BoxedWidget {
|
||||
let room_style = ContentStyle::default().bold().blue();
|
||||
let mut info = Styled::new(format!("&{}", self.name()), room_style);
|
||||
|
||||
info = match state {
|
||||
None | Some(euph::State::Stopped) => info.then_plain(", archive"),
|
||||
Some(euph::State::Disconnected) => info.then_plain(", waiting..."),
|
||||
Some(euph::State::Connecting) => info.then_plain(", connecting..."),
|
||||
Some(euph::State::Connected(_, conn::State::Joining(j))) if j.bounce.is_some() => {
|
||||
info.then_plain(", auth required")
|
||||
}
|
||||
Some(euph::State::Connected(_, conn::State::Joining(_))) => {
|
||||
info.then_plain(", joining...")
|
||||
}
|
||||
Some(euph::State::Connected(_, conn::State::Joined(j))) => {
|
||||
let nick = &j.session.name;
|
||||
if nick.is_empty() {
|
||||
info.then_plain(", present without nick")
|
||||
} else {
|
||||
info.then_plain(", present as ")
|
||||
.and_then(euph::style_nick(nick, ContentStyle::default()))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let unseen = self.unseen_msgs_count().await;
|
||||
if unseen > 0 {
|
||||
info = info
|
||||
.then_plain(" (")
|
||||
.then(format!("{unseen}"), ContentStyle::default().bold().green())
|
||||
.then_plain(")");
|
||||
}
|
||||
|
||||
Text::new(info).into()
|
||||
}
|
||||
|
||||
async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
let can_compose = matches!(
|
||||
self.room_state(),
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_)))
|
||||
);
|
||||
self.chat.list_key_bindings(bindings, can_compose).await;
|
||||
}
|
||||
|
||||
async fn handle_chat_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
let can_compose = matches!(
|
||||
self.room_state(),
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_)))
|
||||
);
|
||||
|
||||
let reaction = self
|
||||
.chat
|
||||
.handle_input_event(terminal, crossterm_lock, event, can_compose)
|
||||
.await;
|
||||
let reaction = logging_unwrap!(reaction);
|
||||
|
||||
match reaction {
|
||||
Reaction::NotHandled => {}
|
||||
Reaction::Handled => return true,
|
||||
Reaction::Composed { parent, content } => {
|
||||
if let Some(room) = &self.room {
|
||||
match room.send(parent, content) {
|
||||
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
|
||||
Err(_) => self.chat.sent(None).await,
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reaction::ComposeError(e) => {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: "Failed to use external editor".to_string(),
|
||||
reason: format!("{e}"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
match self.room_state() {
|
||||
// Authenticating
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)) => {
|
||||
bindings.binding("a", "authenticate");
|
||||
}
|
||||
|
||||
// Connected
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_))) => {
|
||||
bindings.binding("n", "change nick");
|
||||
bindings.binding("m", "download more messages");
|
||||
bindings.binding("A", "show account ui");
|
||||
}
|
||||
|
||||
// Otherwise
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Inspecting messages
|
||||
bindings.binding("i", "inspect message");
|
||||
bindings.binding("I", "show message links");
|
||||
bindings.binding("ctrl+p", "open room's plugh.de/present page");
|
||||
}
|
||||
|
||||
async fn handle_room_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
match self.room_state() {
|
||||
// Authenticating
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)) => {
|
||||
if let key!('a') = event {
|
||||
self.state = State::Auth(auth::new());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Joined
|
||||
Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event {
|
||||
key!('n') | key!('N') => {
|
||||
self.state = State::Nick(nick::new(joined.clone()));
|
||||
return true;
|
||||
}
|
||||
key!('m') => {
|
||||
if let Some(room) = &self.room {
|
||||
let _ = room.log();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('A') => {
|
||||
self.state = State::Account(AccountUiState::new());
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
// Otherwise
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Always applicable
|
||||
match event {
|
||||
key!('i') => {
|
||||
if let Some(id) = self.chat.cursor().await {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().full_msg(id).await) {
|
||||
self.state = State::InspectMessage(msg);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('I') => {
|
||||
if let Some(id) = self.chat.cursor().await {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().msg(id).await) {
|
||||
self.state = State::Links(LinksState::new(&msg.content));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!(Ctrl + 'p') => {
|
||||
let link = format!("https://plugh.de/present/{}/", self.name());
|
||||
if let Err(error) = open::that(&link) {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
async fn list_chat_focus_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
self.list_room_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
self.list_chat_key_bindings(bindings).await;
|
||||
}
|
||||
|
||||
async fn handle_chat_focus_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
// We need to handle chat input first, otherwise the other
|
||||
// key bindings will shadow characters in the editor.
|
||||
if self
|
||||
.handle_chat_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.handle_room_input_event(event).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn list_nick_list_focus_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
util::list_list_key_bindings(bindings);
|
||||
|
||||
bindings.binding("i", "inspect session");
|
||||
}
|
||||
|
||||
fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
if util::handle_list_input_event(&mut self.nick_list, event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let key!('i') = event {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state()
|
||||
{
|
||||
if let Some(id) = self.nick_list.cursor() {
|
||||
if id == joined.session.session_id {
|
||||
self.state =
|
||||
State::InspectSession(SessionInfo::Full(joined.session.clone()));
|
||||
} else if let Some(session) = joined.listing.get(&id) {
|
||||
self.state = State::InspectSession(session.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
// Handled in rooms list, not here
|
||||
bindings.binding("esc", "leave room");
|
||||
|
||||
match self.focus {
|
||||
Focus::Chat => {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
|
||||
bindings.binding("tab", "focus on nick list");
|
||||
}
|
||||
|
||||
self.list_chat_focus_key_bindings(bindings).await;
|
||||
}
|
||||
Focus::NickList => {
|
||||
bindings.binding("tab, esc", "focus on chat");
|
||||
bindings.empty();
|
||||
bindings.heading("Nick list");
|
||||
self.list_nick_list_focus_key_bindings(bindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_normal_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
match self.focus {
|
||||
Focus::Chat => {
|
||||
// Needs to be handled first or the tab key may be shadowed
|
||||
// during editing.
|
||||
if self
|
||||
.handle_chat_focus_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
|
||||
if let key!(Tab) = event {
|
||||
self.focus = Focus::NickList;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Focus::NickList => {
|
||||
if let key!(Tab) | key!(Esc) = event {
|
||||
self.focus = Focus::Chat;
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.handle_nick_list_focus_input_event(event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.heading("Room");
|
||||
|
||||
if !self.popups.is_empty() {
|
||||
bindings.binding("esc", "close popup");
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.state {
|
||||
State::Normal => self.list_normal_key_bindings(bindings).await,
|
||||
State::Auth(_) => auth::list_key_bindings(bindings),
|
||||
State::Nick(_) => nick::list_key_bindings(bindings),
|
||||
State::Account(account) => account.list_key_bindings(bindings),
|
||||
State::Links(links) => links.list_key_bindings(bindings),
|
||||
State::InspectMessage(_) | State::InspectSession(_) => {
|
||||
inspect::list_key_bindings(bindings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
if !self.popups.is_empty() {
|
||||
if matches!(event, key!(Esc)) {
|
||||
self.popups.pop_back();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO Use a common EventResult
|
||||
|
||||
match &mut self.state {
|
||||
State::Normal => {
|
||||
self.handle_normal_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
}
|
||||
State::Auth(editor) => {
|
||||
match auth::handle_input_event(terminal, event, &self.room, editor) {
|
||||
auth::EventResult::NotHandled => false,
|
||||
auth::EventResult::Handled => true,
|
||||
auth::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Nick(editor) => {
|
||||
match nick::handle_input_event(terminal, event, &self.room, editor) {
|
||||
nick::EventResult::NotHandled => false,
|
||||
nick::EventResult::Handled => true,
|
||||
nick::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Account(account) => {
|
||||
match account.handle_input_event(terminal, event, &self.room) {
|
||||
account::EventResult::NotHandled => false,
|
||||
account::EventResult::Handled => true,
|
||||
account::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Links(links) => match links.handle_input_event(event) {
|
||||
links::EventResult::NotHandled => false,
|
||||
links::EventResult::Handled => true,
|
||||
links::EventResult::Close => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
links::EventResult::ErrorOpeningLink { link, error } => {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
true
|
||||
}
|
||||
},
|
||||
State::InspectMessage(_) | State::InspectSession(_) => {
|
||||
match inspect::handle_input_event(event) {
|
||||
inspect::EventResult::NotHandled => false,
|
||||
inspect::EventResult::Close => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_event(&mut self, event: Event) -> bool {
|
||||
let room = match &self.room {
|
||||
None => return false,
|
||||
Some(room) => room,
|
||||
};
|
||||
|
||||
if event.config().name != room.instance().config().name {
|
||||
// If we allowed names other than the current one, old instances
|
||||
// that haven't yet shut down properly could mess up our state.
|
||||
return false;
|
||||
}
|
||||
|
||||
// We handle the packet internally first because the room event handling
|
||||
// will consume it while we only need a reference.
|
||||
let handled = if let Event::Packet(_, packet, _) = &event {
|
||||
match &packet.content {
|
||||
Ok(data) => self.handle_euph_data(data),
|
||||
Err(reason) => self.handle_euph_error(packet.r#type, reason),
|
||||
}
|
||||
} else {
|
||||
// The room state changes, which always means a redraw.
|
||||
true
|
||||
};
|
||||
|
||||
self.room
|
||||
.as_mut()
|
||||
// See check at the beginning of the function.
|
||||
.expect("no room even though we checked earlier")
|
||||
.handle_event(event)
|
||||
.await;
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn handle_euph_data(&mut self, data: &Data) -> bool {
|
||||
// These packets don't result in any noticeable change in the UI.
|
||||
#[allow(clippy::match_like_matches_macro)]
|
||||
let handled = match data {
|
||||
Data::PingEvent(_) | Data::PingReply(_) => {
|
||||
// Pings are displayed nowhere in the room UI.
|
||||
false
|
||||
}
|
||||
Data::DisconnectEvent(_) => {
|
||||
// Followed by the server closing the connection, meaning that
|
||||
// we'll get an `EuphRoomEvent::Disconnected` soon after this.
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
|
||||
// Because the euphoria API is very carefully designed with emphasis on
|
||||
// consistency, some failures are not normal errors but instead
|
||||
// error-free replies that encode their own error.
|
||||
let error = match data {
|
||||
Data::AuthReply(reply) if !reply.success => {
|
||||
Some(("authenticate", reply.reason.clone()))
|
||||
}
|
||||
Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason.clone())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((action, reason)) = error {
|
||||
let description = format!("Failed to {action}.");
|
||||
let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string());
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn handle_euph_error(&mut self, r#type: PacketType, reason: &str) -> bool {
|
||||
let action = match r#type {
|
||||
PacketType::AuthReply => "authenticate",
|
||||
PacketType::NickReply => "set nick",
|
||||
PacketType::PmInitiateReply => "initiate pm",
|
||||
PacketType::SendReply => "send message",
|
||||
PacketType::ChangeEmailReply => "change account email",
|
||||
PacketType::ChangeNameReply => "change account name",
|
||||
PacketType::ChangePasswordReply => "change account password",
|
||||
PacketType::LoginReply => "log in",
|
||||
PacketType::LogoutReply => "log out",
|
||||
PacketType::RegisterAccountReply => "register account",
|
||||
PacketType::ResendVerificationEmailReply => "resend verification email",
|
||||
PacketType::ResetPasswordReply => "reset account password",
|
||||
PacketType::BanReply => "ban",
|
||||
PacketType::EditMessageReply => "edit message",
|
||||
PacketType::GrantAccessReply => "grant room access",
|
||||
PacketType::GrantManagerReply => "grant manager permissions",
|
||||
PacketType::RevokeAccessReply => "revoke room access",
|
||||
PacketType::RevokeManagerReply => "revoke manager permissions",
|
||||
PacketType::UnbanReply => "unban",
|
||||
_ => return false,
|
||||
};
|
||||
let description = format!("Failed to {action}.");
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
149
src/ui/input.rs
149
src/ui/input.rs
|
|
@ -1,149 +0,0 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use super::widgets::background::Background;
|
||||
use super::widgets::border::Border;
|
||||
use super::widgets::empty::Empty;
|
||||
use super::widgets::float::Float;
|
||||
use super::widgets::join::{HJoin, Segment};
|
||||
use super::widgets::layer::Layer;
|
||||
use super::widgets::list::{List, ListState};
|
||||
use super::widgets::padding::Padding;
|
||||
use super::widgets::resize::Resize;
|
||||
use super::widgets::text::Text;
|
||||
use super::widgets::BoxedWidget;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
Key(KeyEvent),
|
||||
Paste(String),
|
||||
}
|
||||
|
||||
impl InputEvent {
|
||||
pub fn from_event(event: Event) -> Option<Self> {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key) => Some(Self::Key(key.into())),
|
||||
crossterm::event::Event::Paste(text) => Some(Self::Paste(text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key event data type that is a bit easier to pattern match on than
|
||||
/// [`crossterm::event::KeyEvent`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct KeyEvent {
|
||||
pub code: KeyCode,
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
}
|
||||
|
||||
impl From<crossterm::event::KeyEvent> for KeyEvent {
|
||||
fn from(event: crossterm::event::KeyEvent) -> Self {
|
||||
Self {
|
||||
code: event.code,
|
||||
shift: event.modifiers.contains(KeyModifiers::SHIFT),
|
||||
ctrl: event.modifiers.contains(KeyModifiers::CONTROL),
|
||||
alt: event.modifiers.contains(KeyModifiers::ALT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
#[allow(unused_macro_rules)]
|
||||
macro_rules! key {
|
||||
// key!(Paste text)
|
||||
( Paste $text:ident ) => { crate::ui::input::InputEvent::Paste($text) };
|
||||
|
||||
// key!('a')
|
||||
( $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
|
||||
( Ctrl + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
|
||||
( Alt + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(Char c)
|
||||
( Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
|
||||
( Ctrl + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
|
||||
( Alt + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(F n)
|
||||
( F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: false, }) };
|
||||
( Shift + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: true, ctrl: false, alt: false, }) };
|
||||
( Ctrl + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: true, alt: false, }) };
|
||||
( Alt + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(other)
|
||||
( $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: false, }) };
|
||||
( Shift + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: true, ctrl: false, alt: false, }) };
|
||||
( Ctrl + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: true, alt: false, }) };
|
||||
( Alt + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: true, }) };
|
||||
}
|
||||
pub(crate) use key;
|
||||
|
||||
/// Helper wrapper around a list widget for a more consistent key binding style.
|
||||
pub struct KeyBindingsList(List<Infallible>);
|
||||
|
||||
impl KeyBindingsList {
|
||||
/// Width of the left column of key bindings.
|
||||
const BINDING_WIDTH: u16 = 20;
|
||||
|
||||
pub fn new(state: &ListState<Infallible>) -> Self {
|
||||
Self(state.widget())
|
||||
}
|
||||
|
||||
fn binding_style() -> ContentStyle {
|
||||
ContentStyle::default().cyan()
|
||||
}
|
||||
|
||||
pub fn widget(self) -> BoxedWidget {
|
||||
let binding_style = Self::binding_style();
|
||||
Float::new(Layer::new(vec![
|
||||
Border::new(Background::new(Padding::new(self.0).horizontal(1))).into(),
|
||||
Float::new(
|
||||
Padding::new(Text::new(
|
||||
Styled::new("jk/↓↑", binding_style)
|
||||
.then_plain(" to scroll, ")
|
||||
.then("esc", binding_style)
|
||||
.then_plain(" to close"),
|
||||
))
|
||||
.horizontal(1),
|
||||
)
|
||||
.horizontal(0.5)
|
||||
.into(),
|
||||
]))
|
||||
.horizontal(0.5)
|
||||
.vertical(0.5)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn empty(&mut self) {
|
||||
self.0.add_unsel(Empty::new());
|
||||
}
|
||||
|
||||
pub fn heading(&mut self, name: &str) {
|
||||
self.0
|
||||
.add_unsel(Text::new((name, ContentStyle::default().bold())));
|
||||
}
|
||||
|
||||
pub fn binding(&mut self, binding: &str, description: &str) {
|
||||
let widget = HJoin::new(vec![
|
||||
Segment::new(
|
||||
Resize::new(Padding::new(Text::new((binding, Self::binding_style()))).right(1))
|
||||
.min_width(Self::BINDING_WIDTH),
|
||||
),
|
||||
Segment::new(Text::new(description)),
|
||||
]);
|
||||
self.0.add_unsel(widget);
|
||||
}
|
||||
|
||||
pub fn binding_ctd(&mut self, description: &str) {
|
||||
let widget = HJoin::new(vec![
|
||||
Segment::new(Resize::new(Empty::new()).min_width(Self::BINDING_WIDTH)),
|
||||
Segment::new(Text::new(description)),
|
||||
]);
|
||||
self.0.add_unsel(widget);
|
||||
}
|
||||
}
|
||||
590
src/ui/rooms.rs
590
src/ui/rooms.rs
|
|
@ -1,590 +0,0 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use euphoxide::api::SessionType;
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined};
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::styled::Styled;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::config::{Config, RoomsSortOrder};
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::Vault;
|
||||
|
||||
use super::euph::room::EuphRoom;
|
||||
use super::input::{key, InputEvent, KeyBindingsList};
|
||||
use super::widgets::editor::EditorState;
|
||||
use super::widgets::join::{HJoin, Segment, VJoin};
|
||||
use super::widgets::layer::Layer;
|
||||
use super::widgets::list::{List, ListState};
|
||||
use super::widgets::popup::Popup;
|
||||
use super::widgets::resize::Resize;
|
||||
use super::widgets::text::Text;
|
||||
use super::widgets::BoxedWidget;
|
||||
use super::{util, UiEvent};
|
||||
|
||||
enum State {
|
||||
ShowList,
|
||||
ShowRoom(String),
|
||||
Connect(EditorState),
|
||||
Delete(String, EditorState),
|
||||
}
|
||||
|
||||
enum Order {
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn from_rooms_sort_order(order: RoomsSortOrder) -> Self {
|
||||
match order {
|
||||
RoomsSortOrder::Alphabet => Self::Alphabet,
|
||||
RoomsSortOrder::Importance => Self::Importance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rooms {
|
||||
config: &'static Config,
|
||||
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
state: State,
|
||||
|
||||
list: ListState<String>,
|
||||
order: Order,
|
||||
|
||||
euph_server_config: ServerConfig,
|
||||
euph_next_instance_id: usize,
|
||||
euph_rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
impl Rooms {
|
||||
pub async fn new(
|
||||
config: &'static Config,
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
let cookies = logging_unwrap!(vault.euph().cookies().await);
|
||||
let euph_server_config = ServerConfig::default().cookies(Arc::new(Mutex::new(cookies)));
|
||||
|
||||
let mut result = Self {
|
||||
config,
|
||||
vault,
|
||||
ui_event_tx,
|
||||
state: State::ShowList,
|
||||
list: ListState::new(),
|
||||
order: Order::from_rooms_sort_order(config.rooms_sort_order),
|
||||
euph_server_config,
|
||||
euph_next_instance_id: 0,
|
||||
euph_rooms: HashMap::new(),
|
||||
};
|
||||
|
||||
if !config.offline {
|
||||
for (name, config) in &config.euph.rooms {
|
||||
if config.autojoin {
|
||||
result.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
|
||||
self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_to_room(&mut self, name: String) {
|
||||
let room = self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
});
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
|
||||
fn connect_to_all_rooms(&mut self) {
|
||||
for room in self.euph_rooms.values_mut() {
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_from_room(&mut self, name: &str) {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_from_all_rooms(&mut self) {
|
||||
for room in self.euph_rooms.values_mut() {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove rooms that are not running any more and can't be found in the db
|
||||
/// 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 = logging_unwrap!(self.vault.euph().rooms().await);
|
||||
let mut rooms_set = rooms
|
||||
.into_iter()
|
||||
.chain(self.config.euph.rooms.keys().cloned())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// 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 {
|
||||
self.get_or_insert_room(room).retain();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn widget(&mut self) -> BoxedWidget {
|
||||
match &self.state {
|
||||
State::ShowRoom(_) => {}
|
||||
_ => self.stabilize_rooms().await,
|
||||
}
|
||||
|
||||
match &self.state {
|
||||
State::ShowList => self.rooms_widget().await,
|
||||
State::ShowRoom(name) => {
|
||||
self.euph_rooms
|
||||
.get_mut(name)
|
||||
.expect("room exists after stabilization")
|
||||
.widget()
|
||||
.await
|
||||
}
|
||||
State::Connect(editor) => Layer::new(vec![
|
||||
self.rooms_widget().await,
|
||||
Self::new_room_widget(editor),
|
||||
])
|
||||
.into(),
|
||||
State::Delete(name, editor) => Layer::new(vec![
|
||||
self.rooms_widget().await,
|
||||
Self::delete_room_widget(name, editor),
|
||||
])
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_room_widget(editor: &EditorState) -> BoxedWidget {
|
||||
let room_style = ContentStyle::default().bold().blue();
|
||||
let editor = editor.widget().highlight(|s| Styled::new(s, room_style));
|
||||
Popup::new(HJoin::new(vec![
|
||||
Segment::new(Text::new(("&", room_style))),
|
||||
Segment::new(editor).priority(0),
|
||||
]))
|
||||
.title("Connect to")
|
||||
.build()
|
||||
}
|
||||
|
||||
fn delete_room_widget(name: &str, editor: &EditorState) -> BoxedWidget {
|
||||
let warn_style = ContentStyle::default().bold().red();
|
||||
let room_style = ContentStyle::default().bold().blue();
|
||||
let editor = editor.widget().highlight(|s| Styled::new(s, room_style));
|
||||
let text = Styled::new_plain("Are you sure you want to delete ")
|
||||
.then("&", room_style)
|
||||
.then(name, room_style)
|
||||
.then_plain("?\n\n")
|
||||
.then_plain("This will delete the entire room history from your vault. ")
|
||||
.then_plain("To shrink your vault afterwards, run ")
|
||||
.then("cove gc", ContentStyle::default().italic().grey())
|
||||
.then_plain(".\n\n")
|
||||
.then_plain("To confirm the deletion, ")
|
||||
.then_plain("enter the full name of the room and press enter:");
|
||||
Popup::new(VJoin::new(vec![
|
||||
// The HJoin prevents the text from filling up the entire available
|
||||
// space if the editor is wider than the text.
|
||||
Segment::new(HJoin::new(vec![Segment::new(
|
||||
Resize::new(Text::new(text).wrap(true)).max_width(54),
|
||||
)])),
|
||||
Segment::new(HJoin::new(vec![
|
||||
Segment::new(Text::new(("&", room_style))),
|
||||
Segment::new(editor).priority(0),
|
||||
])),
|
||||
]))
|
||||
.title(("Delete room", warn_style))
|
||||
.border(warn_style)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn format_pbln(joined: &Joined) -> String {
|
||||
let mut p = 0_usize;
|
||||
let mut b = 0_usize;
|
||||
let mut l = 0_usize;
|
||||
let mut n = 0_usize;
|
||||
|
||||
let sessions = joined
|
||||
.listing
|
||||
.values()
|
||||
.map(|s| (s.id(), s.name()))
|
||||
.chain(iter::once((
|
||||
&joined.session.id,
|
||||
&joined.session.name as &str,
|
||||
)));
|
||||
for (user_id, name) in sessions {
|
||||
match user_id.session_type() {
|
||||
Some(SessionType::Bot) if name.is_empty() => n += 1,
|
||||
Some(SessionType::Bot) => b += 1,
|
||||
_ if name.is_empty() => l += 1,
|
||||
_ => p += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// There must always be either one p, b, l or n since we're including
|
||||
// ourselves.
|
||||
let mut result = vec![];
|
||||
if p > 0 {
|
||||
result.push(format!("{p}p"));
|
||||
}
|
||||
if b > 0 {
|
||||
result.push(format!("{b}b"));
|
||||
}
|
||||
if l > 0 {
|
||||
result.push(format!("{l}l"));
|
||||
}
|
||||
if n > 0 {
|
||||
result.push(format!("{n}n"));
|
||||
}
|
||||
result.join(" ")
|
||||
}
|
||||
|
||||
fn format_room_state(state: Option<&euph::State>) -> Option<String> {
|
||||
match state {
|
||||
None | Some(euph::State::Stopped) => None,
|
||||
Some(euph::State::Disconnected) => Some("waiting".to_string()),
|
||||
Some(euph::State::Connecting) => Some("connecting".to_string()),
|
||||
Some(euph::State::Connected(_, connected)) => match connected {
|
||||
conn::State::Joining(joining) if joining.bounce.is_some() => {
|
||||
Some("auth required".to_string())
|
||||
}
|
||||
conn::State::Joining(_) => Some("joining".to_string()),
|
||||
conn::State::Joined(joined) => Some(Self::format_pbln(joined)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unseen_msgs(unseen: usize) -> Option<String> {
|
||||
if unseen == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{unseen}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled {
|
||||
let unseen_style = ContentStyle::default().bold().green();
|
||||
|
||||
let state = Self::format_room_state(state);
|
||||
let unseen = Self::format_unseen_msgs(unseen);
|
||||
|
||||
match (state, unseen) {
|
||||
(None, None) => Styled::default(),
|
||||
(None, Some(u)) => Styled::new_plain(" (")
|
||||
.then(u, unseen_style)
|
||||
.then_plain(")"),
|
||||
(Some(s), None) => Styled::new_plain(" (").then_plain(s).then_plain(")"),
|
||||
(Some(s), Some(u)) => Styled::new_plain(" (")
|
||||
.then_plain(s)
|
||||
.then_plain(", ")
|
||||
.then(u, unseen_style)
|
||||
.then_plain(")"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_rooms(&self, rooms: &mut [(&String, Option<&euph::State>, usize)]) {
|
||||
match self.order {
|
||||
Order::Alphabet => rooms.sort_unstable_by_key(|(name, _, _)| *name),
|
||||
Order::Importance => rooms.sort_unstable_by_key(|(name, state, unseen)| {
|
||||
(state.is_none(), *unseen == 0, *name)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_rows(&self, list: &mut List<String>) {
|
||||
if self.euph_rooms.is_empty() {
|
||||
list.add_unsel(Text::new((
|
||||
"Press F1 for key bindings",
|
||||
ContentStyle::default().grey().italic(),
|
||||
)))
|
||||
}
|
||||
|
||||
let mut rooms = vec![];
|
||||
for (name, room) in &self.euph_rooms {
|
||||
let state = room.room_state();
|
||||
let unseen = room.unseen_msgs_count().await;
|
||||
rooms.push((name, state, unseen));
|
||||
}
|
||||
self.sort_rooms(&mut rooms);
|
||||
for (name, state, unseen) in rooms {
|
||||
let room_style = ContentStyle::default().bold().blue();
|
||||
let room_sel_style = ContentStyle::default().bold().black().on_white();
|
||||
|
||||
let mut normal = Styled::new(format!("&{name}"), room_style);
|
||||
let mut selected = Styled::new(format!("&{name}"), room_sel_style);
|
||||
|
||||
let info = Self::format_room_info(state, unseen);
|
||||
normal = normal.and_then(info.clone());
|
||||
selected = selected.and_then(info);
|
||||
|
||||
list.add_sel(name.clone(), Text::new(normal), Text::new(selected));
|
||||
}
|
||||
}
|
||||
|
||||
async fn rooms_widget(&self) -> BoxedWidget {
|
||||
let heading_style = ContentStyle::default().bold();
|
||||
let amount = self.euph_rooms.len();
|
||||
let heading =
|
||||
Text::new(Styled::new("Rooms", heading_style).then_plain(format!(" ({amount})")));
|
||||
|
||||
let mut list = self.list.widget().focus(true);
|
||||
self.render_rows(&mut list).await;
|
||||
|
||||
VJoin::new(vec![Segment::new(heading), Segment::new(list).priority(0)]).into()
|
||||
}
|
||||
|
||||
fn room_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn list_showlist_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.heading("Rooms");
|
||||
util::list_list_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
bindings.binding("enter", "enter selected room");
|
||||
bindings.binding("c", "connect to selected room");
|
||||
bindings.binding("C", "connect to all rooms");
|
||||
bindings.binding("d", "disconnect from selected room");
|
||||
bindings.binding("D", "disconnect from all rooms");
|
||||
bindings.binding("a", "connect to all autojoin room");
|
||||
bindings.binding("A", "disconnect from all non-autojoin rooms");
|
||||
bindings.binding("n", "connect to new room");
|
||||
bindings.binding("X", "delete room");
|
||||
bindings.empty();
|
||||
bindings.binding("s", "change sort order");
|
||||
}
|
||||
|
||||
fn handle_showlist_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
if util::handle_list_input_event(&mut self.list, event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match event {
|
||||
key!(Enter) => {
|
||||
if let Some(name) = self.list.cursor() {
|
||||
self.state = State::ShowRoom(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('c') => {
|
||||
if let Some(name) = self.list.cursor() {
|
||||
self.connect_to_room(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('C') => {
|
||||
self.connect_to_all_rooms();
|
||||
return true;
|
||||
}
|
||||
key!('d') => {
|
||||
if let Some(name) = self.list.cursor() {
|
||||
self.disconnect_from_room(&name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('D') => {
|
||||
self.disconnect_from_all_rooms();
|
||||
return true;
|
||||
}
|
||||
key!('a') => {
|
||||
for (name, options) in &self.config.euph.rooms {
|
||||
if options.autojoin {
|
||||
self.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('A') => {
|
||||
for (name, room) in &mut self.euph_rooms {
|
||||
let autojoin = self
|
||||
.config
|
||||
.euph
|
||||
.rooms
|
||||
.get(name)
|
||||
.map(|r| r.autojoin)
|
||||
.unwrap_or(false);
|
||||
if !autojoin {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('n') => {
|
||||
self.state = State::Connect(EditorState::new());
|
||||
return true;
|
||||
}
|
||||
key!('X') => {
|
||||
if let Some(name) = self.list.cursor() {
|
||||
self.state = State::Delete(name, EditorState::new());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('s') => {
|
||||
self.order = match self.order {
|
||||
Order::Alphabet => Order::Importance,
|
||||
Order::Importance => Order::Alphabet,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
match &self.state {
|
||||
State::ShowList => Self::list_showlist_key_bindings(bindings),
|
||||
State::ShowRoom(name) => {
|
||||
// Key bindings for leaving the room are a part of the room's
|
||||
// list_key_bindings function since they may be shadowed by the
|
||||
// nick selector or message editor.
|
||||
if let Some(room) = self.euph_rooms.get(name) {
|
||||
room.list_key_bindings(bindings).await;
|
||||
} else {
|
||||
// There should always be a room here already but I don't
|
||||
// really want to panic in case it is not. If I show a
|
||||
// message like this, it'll hopefully be reported if
|
||||
// somebody ever encounters it.
|
||||
bindings.binding_ctd("oops, this text should never be visible")
|
||||
}
|
||||
}
|
||||
State::Connect(_) => {
|
||||
bindings.heading("Rooms");
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "connect to room");
|
||||
util::list_editor_key_bindings(bindings, Self::room_char);
|
||||
}
|
||||
State::Delete(_, _) => {
|
||||
bindings.heading("Rooms");
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "delete room");
|
||||
util::list_editor_key_bindings(bindings, Self::room_char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
self.stabilize_rooms().await;
|
||||
|
||||
match &self.state {
|
||||
State::ShowList => {
|
||||
if self.handle_showlist_input_event(event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
State::ShowRoom(name) => {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
if room
|
||||
.handle_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let key!(Esc) = event {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Connect(ed) => match event {
|
||||
key!(Esc) => {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
key!(Enter) => {
|
||||
let name = ed.text();
|
||||
if !name.is_empty() {
|
||||
self.connect_to_room(name.clone());
|
||||
self.state = State::ShowRoom(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(ed, terminal, event, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
State::Delete(name, editor) => match event {
|
||||
key!(Esc) => {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
key!(Enter) if editor.text() == *name => {
|
||||
self.euph_rooms.remove(name);
|
||||
logging_unwrap!(self.vault.euph().room(name.clone()).delete().await);
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn handle_euph_event(&mut self, event: Event) -> bool {
|
||||
let room_name = event.config().room.clone();
|
||||
let Some(room) = self.euph_rooms.get_mut(&room_name) else { return false; };
|
||||
|
||||
let handled = room.handle_event(event).await;
|
||||
|
||||
let room_visible = match &self.state {
|
||||
State::ShowRoom(name) => *name == room_name,
|
||||
_ => true,
|
||||
};
|
||||
handled && room_visible
|
||||
}
|
||||
}
|
||||
166
src/ui/util.rs
166
src/ui/util.rs
|
|
@ -1,166 +0,0 @@
|
|||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use super::input::{key, InputEvent, KeyBindingsList};
|
||||
use super::widgets::editor::EditorState;
|
||||
use super::widgets::list::ListState;
|
||||
|
||||
pub fn prompt(
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
initial_text: &str,
|
||||
) -> io::Result<String> {
|
||||
let content = {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.suspend().expect("could not suspend");
|
||||
let content = edit::edit(initial_text);
|
||||
terminal.unsuspend().expect("could not unsuspend");
|
||||
content
|
||||
};
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
//////////
|
||||
// List //
|
||||
//////////
|
||||
|
||||
pub fn list_list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down");
|
||||
}
|
||||
|
||||
pub fn handle_list_input_event<Id: Clone>(list: &mut ListState<Id>, event: &InputEvent) -> bool {
|
||||
match event {
|
||||
key!('k') | key!(Up) => list.move_cursor_up(),
|
||||
key!('j') | key!(Down) => list.move_cursor_down(),
|
||||
key!('g') | key!(Home) => list.move_cursor_to_top(),
|
||||
key!('G') | key!(End) => list.move_cursor_to_bottom(),
|
||||
key!(Ctrl + 'y') => list.scroll_up(1),
|
||||
key!(Ctrl + 'e') => list.scroll_down(1),
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
////////////
|
||||
// Editor //
|
||||
////////////
|
||||
|
||||
fn list_editor_editing_key_bindings(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
if char_filter('\n') {
|
||||
bindings.binding("enter+<any modifier>", "insert newline");
|
||||
}
|
||||
|
||||
bindings.binding("ctrl+h, backspace", "delete before cursor");
|
||||
bindings.binding("ctrl+d, delete", "delete after cursor");
|
||||
bindings.binding("ctrl+l", "clear editor contents");
|
||||
}
|
||||
|
||||
fn list_editor_cursor_movement_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("ctrl+b, ←", "move cursor left");
|
||||
bindings.binding("ctrl+f, →", "move cursor right");
|
||||
bindings.binding("alt+b, ctrl+←", "move cursor left a word");
|
||||
bindings.binding("alt+f, ctrl+→", "move cursor right a word");
|
||||
bindings.binding("ctrl+a, home", "move cursor to start of line");
|
||||
bindings.binding("ctrl+e, end", "move cursor to end of line");
|
||||
bindings.binding("↑/↓", "move cursor up/down");
|
||||
}
|
||||
|
||||
pub fn list_editor_key_bindings(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
list_editor_editing_key_bindings(bindings, char_filter);
|
||||
bindings.empty();
|
||||
list_editor_cursor_movement_key_bindings(bindings);
|
||||
}
|
||||
|
||||
pub fn handle_editor_input_event(
|
||||
editor: &EditorState,
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) -> bool {
|
||||
match event {
|
||||
// Enter with *any* modifier pressed - if ctrl and shift don't
|
||||
// work, maybe alt does
|
||||
key!(Enter) => return false,
|
||||
InputEvent::Key(crate::ui::input::KeyEvent {
|
||||
code: crossterm::event::KeyCode::Enter,
|
||||
..
|
||||
}) if char_filter('\n') => editor.insert_char(terminal.widthdb(), '\n'),
|
||||
|
||||
// Editing
|
||||
key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.widthdb(), *ch),
|
||||
key!(Paste str) => {
|
||||
// It seems that when pasting, '\n' are converted into '\r' for some
|
||||
// reason. I don't really know why, or at what point this happens.
|
||||
// Vim converts any '\r' pasted via the terminal into '\n', so I
|
||||
// decided to mirror that behaviour.
|
||||
let str = str.replace('\r', "\n");
|
||||
if str.chars().all(char_filter) {
|
||||
editor.insert_str(terminal.widthdb(), &str);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.widthdb()),
|
||||
key!(Ctrl + 'd') | key!(Delete) => editor.delete(),
|
||||
key!(Ctrl + 'l') => editor.clear(),
|
||||
// TODO Key bindings to delete words
|
||||
|
||||
// Cursor movement
|
||||
key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.widthdb()),
|
||||
key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.widthdb()),
|
||||
key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.widthdb()),
|
||||
key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.widthdb()),
|
||||
key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.widthdb()),
|
||||
key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.widthdb()),
|
||||
key!(Up) => editor.move_cursor_up(terminal.widthdb()),
|
||||
key!(Down) => editor.move_cursor_down(terminal.widthdb()),
|
||||
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn list_editor_key_bindings_allowing_external_editing(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
list_editor_editing_key_bindings(bindings, char_filter);
|
||||
bindings.binding("ctrl+x", "edit in external editor");
|
||||
bindings.empty();
|
||||
list_editor_cursor_movement_key_bindings(bindings);
|
||||
}
|
||||
|
||||
pub fn handle_editor_input_event_allowing_external_editing(
|
||||
editor: &EditorState,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) -> io::Result<bool> {
|
||||
if let key!(Ctrl + 'x') = event {
|
||||
editor.edit_externally(terminal, crossterm_lock)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(handle_editor_input_event(
|
||||
editor,
|
||||
terminal,
|
||||
event,
|
||||
char_filter,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
// Since the widget module is effectively a library and will probably be moved
|
||||
// to toss later, warnings about unused functions are mostly inaccurate.
|
||||
// TODO Restrict this a bit more?
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod background;
|
||||
pub mod border;
|
||||
pub mod cursor;
|
||||
pub mod editor;
|
||||
pub mod empty;
|
||||
pub mod float;
|
||||
pub mod join;
|
||||
pub mod layer;
|
||||
pub mod list;
|
||||
pub mod padding;
|
||||
pub mod popup;
|
||||
pub mod resize;
|
||||
pub mod rules;
|
||||
pub mod text;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use toss::frame::{Frame, Size};
|
||||
|
||||
// TODO Add Error type and return Result-s (at least in Widget::render)
|
||||
|
||||
#[async_trait]
|
||||
pub trait Widget {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size;
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame);
|
||||
}
|
||||
|
||||
pub type BoxedWidget = Box<dyn Widget + Send>;
|
||||
|
||||
impl<W: 'static + Widget + Send> From<W> for BoxedWidget {
|
||||
fn from(widget: W) -> Self {
|
||||
Box::new(widget)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use crossterm::style::ContentStyle;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
|
||||
use super::{BoxedWidget, Widget};
|
||||
|
||||
pub struct Background {
|
||||
inner: BoxedWidget,
|
||||
style: ContentStyle,
|
||||
}
|
||||
|
||||
impl Background {
|
||||
pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self {
|
||||
Self {
|
||||
inner: inner.into(),
|
||||
style: ContentStyle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: ContentStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Background {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
self.inner.size(frame, max_width, max_height)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
let size = frame.size();
|
||||
for dy in 0..size.height {
|
||||
for dx in 0..size.width {
|
||||
frame.write(Pos::new(dx.into(), dy.into()), (" ", self.style));
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.render(frame).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use crossterm::style::ContentStyle;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
|
||||
use super::{BoxedWidget, Widget};
|
||||
|
||||
pub struct Border {
|
||||
inner: BoxedWidget,
|
||||
style: ContentStyle,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self {
|
||||
Self {
|
||||
inner: inner.into(),
|
||||
style: ContentStyle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: ContentStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Border {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
let max_width = max_width.map(|w| w.saturating_sub(2));
|
||||
let max_height = max_height.map(|h| h.saturating_sub(2));
|
||||
let size = self.inner.size(frame, max_width, max_height);
|
||||
size + Size::new(2, 2)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
let mut size = frame.size();
|
||||
size.width = size.width.max(2);
|
||||
size.height = size.height.max(2);
|
||||
|
||||
let right = size.width as i32 - 1;
|
||||
let bottom = size.height as i32 - 1;
|
||||
frame.write(Pos::new(0, 0), ("┌", self.style));
|
||||
frame.write(Pos::new(right, 0), ("┐", self.style));
|
||||
frame.write(Pos::new(0, bottom), ("└", self.style));
|
||||
frame.write(Pos::new(right, bottom), ("┘", self.style));
|
||||
|
||||
for y in 1..bottom {
|
||||
frame.write(Pos::new(0, y), ("│", self.style));
|
||||
frame.write(Pos::new(right, y), ("│", self.style));
|
||||
}
|
||||
|
||||
for x in 1..right {
|
||||
frame.write(Pos::new(x, 0), ("─", self.style));
|
||||
frame.write(Pos::new(x, bottom), ("─", self.style));
|
||||
}
|
||||
|
||||
frame.push(Pos::new(1, 1), size - Size::new(2, 2));
|
||||
self.inner.render(frame).await;
|
||||
frame.pop();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
|
||||
use super::{BoxedWidget, Widget};
|
||||
|
||||
pub struct Cursor {
|
||||
inner: BoxedWidget,
|
||||
pos: Pos,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self {
|
||||
Self {
|
||||
inner: inner.into(),
|
||||
pos: Pos::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn at(mut self, pos: Pos) -> Self {
|
||||
self.pos = pos;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn at_xy(self, x: i32, y: i32) -> Self {
|
||||
self.at(Pos::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Cursor {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
self.inner.size(frame, max_width, max_height)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
self.inner.render(frame).await;
|
||||
frame.set_cursor(Some(self.pos));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,566 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
use std::{io, iter};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use parking_lot::{FairMutex, Mutex};
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::styled::Styled;
|
||||
use toss::terminal::Terminal;
|
||||
use toss::widthdb::WidthDb;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::ui::util;
|
||||
|
||||
use super::text::Text;
|
||||
use super::Widget;
|
||||
|
||||
/// Like [`WidthDb::wrap`] but includes a final break index if the text ends
|
||||
/// with a newline.
|
||||
fn wrap(widthdb: &mut WidthDb, text: &str, width: usize) -> Vec<usize> {
|
||||
let mut breaks = widthdb.wrap(text, width);
|
||||
if text.ends_with('\n') {
|
||||
breaks.push(text.len())
|
||||
}
|
||||
breaks
|
||||
}
|
||||
|
||||
///////////
|
||||
// State //
|
||||
///////////
|
||||
|
||||
struct InnerEditorState {
|
||||
text: String,
|
||||
|
||||
/// Index of the cursor in the text.
|
||||
///
|
||||
/// Must point to a valid grapheme boundary.
|
||||
idx: usize,
|
||||
|
||||
/// Column of the cursor on the screen just after it was last moved
|
||||
/// horizontally.
|
||||
col: usize,
|
||||
|
||||
/// Width of the text when the editor was last rendered.
|
||||
///
|
||||
/// Does not include additional column for cursor.
|
||||
last_width: u16,
|
||||
}
|
||||
|
||||
impl InnerEditorState {
|
||||
fn new(text: String) -> Self {
|
||||
Self {
|
||||
idx: text.len(),
|
||||
col: 0,
|
||||
last_width: u16::MAX,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////
|
||||
// Grapheme helper functions //
|
||||
///////////////////////////////
|
||||
|
||||
fn grapheme_boundaries(&self) -> Vec<usize> {
|
||||
self.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(i, _)| i)
|
||||
.chain(iter::once(self.text.len()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure the cursor index lies on a grapheme boundary. If it doesn't, it
|
||||
/// is moved to the next grapheme boundary.
|
||||
///
|
||||
/// Can handle arbitrary cursor index.
|
||||
fn move_cursor_to_grapheme_boundary(&mut self) {
|
||||
for i in self.grapheme_boundaries() {
|
||||
#[allow(clippy::comparison_chain)]
|
||||
if i == self.idx {
|
||||
// We're at a valid grapheme boundary already
|
||||
return;
|
||||
} else if i > self.idx {
|
||||
// There was no valid grapheme boundary at our cursor index, so
|
||||
// we'll take the next one we can get.
|
||||
self.idx = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor was out of bounds, so move it to the last valid index.
|
||||
self.idx = self.text.len();
|
||||
}
|
||||
|
||||
///////////////////////////////
|
||||
// Line/col helper functions //
|
||||
///////////////////////////////
|
||||
|
||||
/// Like [`Self::grapheme_boundaries`] but for lines.
|
||||
///
|
||||
/// Note that the last line can have a length of 0 if the text ends with a
|
||||
/// newline.
|
||||
fn line_boundaries(&self) -> Vec<usize> {
|
||||
let newlines = self
|
||||
.text
|
||||
.char_indices()
|
||||
.filter(|(_, c)| *c == '\n')
|
||||
.map(|(i, _)| i + 1); // utf-8 encodes '\n' as a single byte
|
||||
iter::once(0)
|
||||
.chain(newlines)
|
||||
.chain(iter::once(self.text.len()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find the cursor's current line.
|
||||
///
|
||||
/// Returns `(line_nr, start_idx, end_idx)`.
|
||||
fn cursor_line(&self, boundaries: &[usize]) -> (usize, usize, usize) {
|
||||
let mut result = (0, 0, 0);
|
||||
for (i, (start, end)) in boundaries.iter().zip(boundaries.iter().skip(1)).enumerate() {
|
||||
if self.idx >= *start {
|
||||
result = (i, *start, *end);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn cursor_col(&self, widthdb: &mut WidthDb, line_start: usize) -> usize {
|
||||
widthdb.width(&self.text[line_start..self.idx])
|
||||
}
|
||||
|
||||
fn line(&self, line: usize) -> (usize, usize) {
|
||||
let boundaries = self.line_boundaries();
|
||||
boundaries
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(boundaries.iter().copied().skip(1))
|
||||
.nth(line)
|
||||
.expect("line exists")
|
||||
}
|
||||
|
||||
fn move_cursor_to_line_col(&mut self, widthdb: &mut WidthDb, line: usize, col: usize) {
|
||||
let (start, end) = self.line(line);
|
||||
let line = &self.text[start..end];
|
||||
|
||||
let mut width = 0;
|
||||
for (gi, g) in line.grapheme_indices(true) {
|
||||
self.idx = start + gi;
|
||||
if col > width {
|
||||
width += widthdb.grapheme_width(g, width) as usize;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !line.ends_with('\n') {
|
||||
self.idx = end;
|
||||
}
|
||||
}
|
||||
|
||||
fn record_cursor_col(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.line_boundaries();
|
||||
let (_, start, _) = self.cursor_line(&boundaries);
|
||||
self.col = self.cursor_col(widthdb, start);
|
||||
}
|
||||
|
||||
/////////////
|
||||
// Editing //
|
||||
/////////////
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.text = String::new();
|
||||
self.idx = 0;
|
||||
self.col = 0;
|
||||
}
|
||||
|
||||
fn set_text(&mut self, widthdb: &mut WidthDb, text: String) {
|
||||
self.text = text;
|
||||
self.move_cursor_to_grapheme_boundary();
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
/// Insert a character at the current cursor position and move the cursor
|
||||
/// accordingly.
|
||||
fn insert_char(&mut self, widthdb: &mut WidthDb, ch: char) {
|
||||
self.text.insert(self.idx, ch);
|
||||
self.idx += ch.len_utf8();
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
/// Insert a string at the current cursor position and move the cursor
|
||||
/// accordingly.
|
||||
fn insert_str(&mut self, widthdb: &mut WidthDb, str: &str) {
|
||||
self.text.insert_str(self.idx, str);
|
||||
self.idx += str.len();
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
/// Delete the grapheme before the cursor position.
|
||||
fn backspace(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
|
||||
if *end == self.idx {
|
||||
self.text.replace_range(start..end, "");
|
||||
self.idx = *start;
|
||||
self.record_cursor_col(widthdb);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the grapheme after the cursor position.
|
||||
fn delete(&mut self) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
|
||||
if *start == self.idx {
|
||||
self.text.replace_range(start..end, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////
|
||||
// Cursor movement //
|
||||
/////////////////////
|
||||
|
||||
fn move_cursor_left(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
|
||||
if *end == self.idx {
|
||||
self.idx = *start;
|
||||
self.record_cursor_col(widthdb);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor_right(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
|
||||
if *start == self.idx {
|
||||
self.idx = *end;
|
||||
self.record_cursor_col(widthdb);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor_left_a_word(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
let mut encountered_word = false;
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)).rev() {
|
||||
if *end == self.idx {
|
||||
let g = &self.text[*start..*end];
|
||||
let whitespace = g.chars().all(|c| c.is_whitespace());
|
||||
if encountered_word && whitespace {
|
||||
break;
|
||||
} else if !whitespace {
|
||||
encountered_word = true;
|
||||
}
|
||||
self.idx = *start;
|
||||
}
|
||||
}
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
fn move_cursor_right_a_word(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.grapheme_boundaries();
|
||||
let mut encountered_word = false;
|
||||
for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
|
||||
if *start == self.idx {
|
||||
let g = &self.text[*start..*end];
|
||||
let whitespace = g.chars().all(|c| c.is_whitespace());
|
||||
if encountered_word && whitespace {
|
||||
break;
|
||||
} else if !whitespace {
|
||||
encountered_word = true;
|
||||
}
|
||||
self.idx = *end;
|
||||
}
|
||||
}
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
fn move_cursor_to_start_of_line(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.line_boundaries();
|
||||
let (line, _, _) = self.cursor_line(&boundaries);
|
||||
self.move_cursor_to_line_col(widthdb, line, 0);
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
fn move_cursor_to_end_of_line(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.line_boundaries();
|
||||
let (line, _, _) = self.cursor_line(&boundaries);
|
||||
self.move_cursor_to_line_col(widthdb, line, usize::MAX);
|
||||
self.record_cursor_col(widthdb);
|
||||
}
|
||||
|
||||
fn move_cursor_up(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.line_boundaries();
|
||||
let (line, _, _) = self.cursor_line(&boundaries);
|
||||
if line > 0 {
|
||||
self.move_cursor_to_line_col(widthdb, line - 1, self.col);
|
||||
}
|
||||
}
|
||||
|
||||
fn move_cursor_down(&mut self, widthdb: &mut WidthDb) {
|
||||
let boundaries = self.line_boundaries();
|
||||
|
||||
// There's always at least one line, and always at least two line
|
||||
// boundaries at 0 and self.text.len().
|
||||
let amount_of_lines = boundaries.len() - 1;
|
||||
|
||||
let (line, _, _) = self.cursor_line(&boundaries);
|
||||
if line + 1 < amount_of_lines {
|
||||
self.move_cursor_to_line_col(widthdb, line + 1, self.col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditorState(Arc<Mutex<InnerEditorState>>);
|
||||
|
||||
impl EditorState {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(InnerEditorState::new(String::new()))))
|
||||
}
|
||||
|
||||
pub fn with_initial_text(text: String) -> Self {
|
||||
Self(Arc::new(Mutex::new(InnerEditorState::new(text))))
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> Editor {
|
||||
let guard = self.0.lock();
|
||||
let text = Styled::new_plain(guard.text.clone());
|
||||
let idx = guard.idx;
|
||||
Editor {
|
||||
state: self.0.clone(),
|
||||
text,
|
||||
idx,
|
||||
focus: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.0.lock().text.clone()
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.0.lock().clear();
|
||||
}
|
||||
|
||||
pub fn set_text(&self, widthdb: &mut WidthDb, text: String) {
|
||||
self.0.lock().set_text(widthdb, text);
|
||||
}
|
||||
|
||||
pub fn insert_char(&self, widthdb: &mut WidthDb, ch: char) {
|
||||
self.0.lock().insert_char(widthdb, ch);
|
||||
}
|
||||
|
||||
pub fn insert_str(&self, widthdb: &mut WidthDb, str: &str) {
|
||||
self.0.lock().insert_str(widthdb, str);
|
||||
}
|
||||
|
||||
/// Delete the grapheme before the cursor position.
|
||||
pub fn backspace(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().backspace(widthdb);
|
||||
}
|
||||
|
||||
/// Delete the grapheme after the cursor position.
|
||||
pub fn delete(&self) {
|
||||
self.0.lock().delete();
|
||||
}
|
||||
|
||||
pub fn move_cursor_left(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_left(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_right(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_right(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_left_a_word(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_left_a_word(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_right_a_word(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_right_a_word(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_start_of_line(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_to_start_of_line(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_to_end_of_line(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_to_end_of_line(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_up(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_up(widthdb);
|
||||
}
|
||||
|
||||
pub fn move_cursor_down(&self, widthdb: &mut WidthDb) {
|
||||
self.0.lock().move_cursor_down(widthdb);
|
||||
}
|
||||
|
||||
pub fn edit_externally(
|
||||
&self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
) -> io::Result<()> {
|
||||
let mut guard = self.0.lock();
|
||||
let text = util::prompt(terminal, crossterm_lock, &guard.text)?;
|
||||
|
||||
if text.trim().is_empty() {
|
||||
// The user likely wanted to abort the edit and has deleted the
|
||||
// entire text (bar whitespace left over by some editors).
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(text) = text.strip_suffix('\n') {
|
||||
// Some editors like vim add a trailing newline that would look out
|
||||
// of place in cove's editor. To intentionally add a trailing
|
||||
// newline, simply add two in-editor.
|
||||
guard.set_text(terminal.widthdb(), text.to_string());
|
||||
} else {
|
||||
guard.set_text(terminal.widthdb(), text);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
////////////
|
||||
// Widget //
|
||||
////////////
|
||||
|
||||
pub struct Editor {
|
||||
state: Arc<Mutex<InnerEditorState>>,
|
||||
text: Styled,
|
||||
idx: usize,
|
||||
focus: bool,
|
||||
hidden: Option<Box<Text>>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn highlight<F>(mut self, f: F) -> Self
|
||||
where
|
||||
F: FnOnce(&str) -> Styled,
|
||||
{
|
||||
let new_text = f(self.text.text());
|
||||
assert_eq!(self.text.text(), new_text.text());
|
||||
self.text = new_text;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn focus(mut self, active: bool) -> Self {
|
||||
self.focus = active;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hidden(self) -> Self {
|
||||
self.hidden_with_placeholder(("<hidden>", ContentStyle::default().grey().italic()))
|
||||
}
|
||||
|
||||
pub fn hidden_with_placeholder<S: Into<Styled>>(mut self, placeholder: S) -> Self {
|
||||
self.hidden = Some(Box::new(Text::new(placeholder)));
|
||||
self
|
||||
}
|
||||
|
||||
fn wrapped_cursor(cursor_idx: usize, break_indices: &[usize]) -> (usize, usize) {
|
||||
let mut row = 0;
|
||||
let mut line_idx = cursor_idx;
|
||||
|
||||
for break_idx in break_indices {
|
||||
if cursor_idx < *break_idx {
|
||||
break;
|
||||
} else {
|
||||
row += 1;
|
||||
line_idx = cursor_idx - break_idx;
|
||||
}
|
||||
}
|
||||
|
||||
(row, line_idx)
|
||||
}
|
||||
|
||||
pub fn cursor_row(&self, widthdb: &mut WidthDb) -> usize {
|
||||
let width = self.state.lock().last_width;
|
||||
let text_width = (width - 1) as usize;
|
||||
let indices = wrap(widthdb, self.text.text(), text_width);
|
||||
let (row, _) = Self::wrapped_cursor(self.idx, &indices);
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Editor {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
if let Some(placeholder) = &self.hidden {
|
||||
let mut size = placeholder.size(frame, max_width, max_height);
|
||||
|
||||
// Cursor needs to fit regardless of focus
|
||||
size.width = size.width.max(1);
|
||||
size.height = size.height.max(1);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
let widthdb = frame.widthdb();
|
||||
|
||||
let max_width = max_width.map(|w| w as usize).unwrap_or(usize::MAX).max(1);
|
||||
let max_text_width = max_width - 1;
|
||||
let indices = wrap(widthdb, self.text.text(), max_text_width);
|
||||
let lines = self.text.clone().split_at_indices(&indices);
|
||||
|
||||
let min_width = lines
|
||||
.iter()
|
||||
.map(|l| widthdb.width(l.text().trim_end()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let min_height = lines.len();
|
||||
Size::new(min_width as u16, min_height as u16)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
if let Some(placeholder) = self.hidden {
|
||||
if !self.text.text().is_empty() {
|
||||
placeholder.render(frame).await;
|
||||
}
|
||||
if self.focus {
|
||||
frame.set_cursor(Some(Pos::ZERO));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let size = frame.size();
|
||||
let widthdb = frame.widthdb();
|
||||
|
||||
let width = size.width.max(1);
|
||||
let text_width = (width - 1) as usize;
|
||||
let indices = wrap(widthdb, self.text.text(), text_width);
|
||||
let lines = self.text.split_at_indices(&indices);
|
||||
|
||||
// Determine cursor position now while we still have the lines.
|
||||
let cursor_pos = if self.focus {
|
||||
let (cursor_row, cursor_line_idx) = Self::wrapped_cursor(self.idx, &indices);
|
||||
let cursor_col = widthdb.width(lines[cursor_row].text().split_at(cursor_line_idx).0);
|
||||
let cursor_col = cursor_col.min(text_width);
|
||||
Some(Pos::new(cursor_col as i32, cursor_row as i32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, line) in lines.into_iter().enumerate() {
|
||||
frame.write(Pos::new(0, i as i32), line);
|
||||
}
|
||||
|
||||
if let Some(pos) = cursor_pos {
|
||||
frame.set_cursor(Some(pos));
|
||||
}
|
||||
|
||||
self.state.lock().last_width = width;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use toss::frame::{Frame, Size};
|
||||
|
||||
use super::Widget;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct Empty {
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl Empty {
|
||||
pub fn new() -> Self {
|
||||
Self { size: Size::ZERO }
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: u16) -> Self {
|
||||
self.size.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: u16) -> Self {
|
||||
self.size.height = height;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: Size) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Empty {
|
||||
fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size {
|
||||
self.size
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, _frame: &mut Frame) {}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
|
||||
use super::{BoxedWidget, Widget};
|
||||
|
||||
pub struct Float {
|
||||
inner: BoxedWidget,
|
||||
horizontal: Option<f32>,
|
||||
vertical: Option<f32>,
|
||||
}
|
||||
|
||||
impl Float {
|
||||
pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self {
|
||||
Self {
|
||||
inner: inner.into(),
|
||||
horizontal: None,
|
||||
vertical: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn horizontal(mut self, position: f32) -> Self {
|
||||
self.horizontal = Some(position);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn vertical(mut self, position: f32) -> Self {
|
||||
self.vertical = Some(position);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Widget for Float {
|
||||
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
|
||||
self.inner.size(frame, max_width, max_height)
|
||||
}
|
||||
|
||||
async fn render(self: Box<Self>, frame: &mut Frame) {
|
||||
let size = frame.size();
|
||||
|
||||
let mut inner_size = self.inner.size(frame, Some(size.width), Some(size.height));
|
||||
inner_size.width = inner_size.width.min(size.width);
|
||||
inner_size.height = inner_size.height.min(size.height);
|
||||
|
||||
let mut inner_pos = Pos::ZERO;
|
||||
|
||||
if let Some(horizontal) = self.horizontal {
|
||||
let available = (size.width - inner_size.width) as f32;
|
||||
// Biased towards the left if horizontal lands exactly on the
|
||||
// boundary between two cells
|
||||
inner_pos.x = (horizontal * available).floor().min(available) as i32;
|
||||
}
|
||||
|
||||
if let Some(vertical) = self.vertical {
|
||||
let available = (size.height - inner_size.height) as f32;
|
||||
// Biased towards the top if vertical lands exactly on the boundary
|
||||
// between two cells
|
||||
inner_pos.y = (vertical * available).floor().min(available) as i32;
|
||||
}
|
||||
|
||||
frame.push(inner_pos, inner_size);
|
||||
self.inner.render(frame).await;
|
||||
frame.pop();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue