Compare commits

...

443 commits

Author SHA1 Message Date
10214f3369 Fix clippy warning 2025-06-27 11:03:06 +02:00
2ca6190d97 Use older ubuntu runner for older glibc version 2025-05-31 15:08:24 +02:00
67e77c8880 Show emoji hash in nick list 2025-05-31 15:06:55 +02:00
b70d7548da Bump version to 0.9.3 2025-05-31 13:54:07 +02:00
732d462775 Add user id based emoji hash
Helpful in scenarios where you want to disambiguate people based on
their user id at a glance.
2025-05-31 13:39:34 +02:00
40de073799 Silence clippy warning 2025-05-31 13:11:50 +02:00
8b928184e8 Fix autojoin key connecting to non-autojoin rooms 2025-04-08 23:52:27 +02:00
ca0f0b6c31 Bump version to 0.9.2 2025-03-14 15:13:14 +01:00
74fbf386b2 Update dependencies 2025-03-14 15:08:05 +01:00
a17630aeaa Ring bell when mentioned 2025-03-08 19:34:51 +01:00
496cdde18d Bump version to 0.9.1 2025-03-01 14:38:41 +01:00
6157ca5088 Update dependencies 2025-03-01 14:26:18 +01:00
30c344031a Fix rendering glitches with unicode-based width estimation 2025-02-28 14:32:30 +01:00
4cf6a15577 Bump version to 0.9.0 2025-02-24 00:00:15 +01:00
b207e91c25 Update dependencies 2025-02-24 00:00:15 +01:00
676c92752d Remove flake
A bit annoying to keep up-to-date since I don't use it myself.
2025-02-24 00:00:15 +01:00
cc436bbb3a Mention --width-estimation-method in config docs 2025-02-24 00:00:15 +01:00
56896a861e Mention release binaries in readme 2025-02-24 00:00:15 +01:00
03b91ec1cd Mention ghostty in config docs 2025-02-23 23:08:17 +01:00
cab37cb633 Fix non-replaceable emoji being rendered without colons 2025-02-23 22:55:47 +01:00
967293db37 Fix links wrapping into oblivion 2025-02-23 22:54:55 +01:00
972e4938aa Run tests in CI 2025-02-23 22:46:09 +01:00
b64f56fce5 Fix nick color in rare edge cases 2025-02-23 22:41:25 +01:00
b4c4a89625 Fix mention color of non-ascii nicks
The old code included the @ in mention color computations. If the nick
consisted only of weird unicode characters, this resulted in an
incorrect color being computed.
2025-02-23 22:03:43 +01:00
9435fbece6 Fix mention highlighting 2025-02-23 21:57:02 +01:00
315db43010 Fix /me not being grey and italic 2025-02-23 21:46:32 +01:00
24c8c92070 Update emoji 2025-02-23 21:42:13 +01:00
bf9a9d640b Navigate to rooms using message links list 2025-02-23 21:32:44 +01:00
8040b82ff1 Refactor euph message content highlighting 2025-02-23 20:49:43 +01:00
17185ea536 Add unicode-based grapheme width estimation method 2025-02-23 18:37:17 +01:00
900a686d0d Fix changelog heading
"Updated" does not conform to Keep a Changelog.
2025-02-23 04:28:21 +01:00
2fa1bec421 Remove special rl2dev code 2025-02-23 04:27:22 +01:00
e750f81b11 Drop once_cell dependency
It's now part of the standard library :D
2025-02-22 16:42:26 +01:00
866176dab6 Move cursor to new room in room list 2025-02-22 12:33:59 +01:00
bf11e055b6 Reformat changelog
There should always be space around headlines and lists in markdown
documents.
2025-02-22 12:33:59 +01:00
6c884f3077 Update RPIT lifetime bounds for the 2024 edition
When the bound matches the implicit bound, i.e. when you'd just write

    impl ... + use<'_>

then it can be omitted. My gut instinct is to always write the bound
explicitly, but maybe that'll harm readability once I'm more used to how
bounds work now.

Anyways, always try to keep the bound as small as possible, ideally just

    impl ... + use<>
2025-02-21 19:18:01 +01:00
d29e3e6651 Set up GitHub CI 2025-02-21 15:02:21 +01:00
fbc64de607 Merge and reorder imports
This change brings them in-line with the default settings of
rust-analyzer. Originally, I disliked this style, but by now, I've grown
used to it and prefer it slightly. Also, I like staying on the default
path since I usually don't need to check the imports to see if
rust-analyzer messed anything up (except for omitting the self:: when
importing modules defined in the current module, grr :D).

I've also come around to the idea of trailing mod definitions instead of
putting them at the top: I would also put module definitions that
contain code instead of referencing another file below the imports. It
feels weird to have code above imports. So it just makes sense to do the
same for all types of mod definitions.

If rustfmt ever gains stable support for grouping imports, I'll probably
use that instead.
2025-02-21 12:46:25 +01:00
816d8f86a3 Migrate rustfmt style to 2024 edition 2025-02-21 12:17:57 +01:00
25d2cc7c98 Migrate to 2024 edition 2025-02-21 12:17:57 +01:00
f45e66f572 Fix or ignore 2024 edition migration lints 2025-02-21 12:11:58 +01:00
bd43fe060b Update dependencies
Except rusqlite and vault, because newer sqlite versions appear to
result in a *very* big performance drop. I suspect the query planner,
though I really have no idea.
2025-02-21 00:41:01 +01:00
e1ba15cb9e Update time_zone docs 2025-02-21 00:32:44 +01:00
edc4219258 Update euphoxide and jiff 2025-02-21 00:31:50 +01:00
55d4321770 Fix outdated reference in config docs
Thanks, JRF!
2025-02-20 19:40:29 +01:00
e80d41cc47 Fix panic due to rustls 2024-12-04 21:17:11 +01:00
f471b9ce00 Switch to jiff from time 2024-12-04 21:08:06 +01:00
2ecc482533 Configure all deps in workspace 2024-12-04 20:20:17 +01:00
cff933b0bf Add and fix more lints 2024-12-04 20:14:41 +01:00
e43b27acfd Satisfy clippy lint 2024-11-06 22:04:57 +01:00
461cc37d88 Remove unused method 2024-11-06 22:01:56 +01:00
106a047b78 Fix clippy lint about lints 2024-11-06 22:01:56 +01:00
7aba041c9f Bump version to 0.8.3 2024-05-20 19:18:13 +02:00
19242a658e Update dependencies 2024-05-20 19:12:41 +02:00
db734c5740 Bump version to 0.8.2 2024-04-25 20:45:00 +02:00
d3666674b2 Update dependencies 2024-04-25 20:41:04 +02:00
c2cfa6e527 Remove todos 2024-04-25 20:38:43 +02:00
3a3d42bcf3 Fix clippy warning 2024-04-25 20:38:43 +02:00
db529688e9 Use cargo workspace lints 2024-04-25 20:38:21 +02:00
131b581880 Change json-stream export format to json-lines 2024-03-08 22:19:21 +01:00
50be653244 Fix incorrect cli option reference 2024-01-27 13:21:59 +01:00
998a2f2ffd Fix crash when window too small with msg editor visible 2024-01-14 12:41:48 +01:00
a5b33440c5 Fix spelling of "indexes" 2024-01-12 22:21:44 +01:00
133681fc62 Bump version to v0.8.1 2024-01-11 11:25:45 +01:00
37e4c6b845 Update dependencies 2024-01-11 11:22:34 +01:00
ee7121b04e Implement live caesar cipher 2024-01-05 23:29:41 +01:00
ab81c89854 Set window title
The title includes the amount of unseen messages in the room or room
list. The room list heading also now contains the amount of connected
rooms as well as the amount of unseen messages in total (if any).
2024-01-05 15:07:20 +01:00
ed7bd3ddb4 Remove key binding to open present page 2024-01-04 23:50:35 +01:00
6352fcf639 Bump version to v0.8.0 2024-01-04 13:55:20 +01:00
a0b029b353 Update dependencies 2024-01-04 13:52:18 +01:00
dadbb205e5 Fix cargo doc warnings 2024-01-03 23:51:27 +01:00
e0948c4f1c Fix duplicated key presses on windows 2024-01-03 23:51:20 +01:00
88ba77b955 Move domains in front of room names in the UI
The reasoning behind this change in the room list is that putting the
domain (which rarely changes) in front of the room name (which often
changes) is a lot more readable. It also moves it away from the status
info parentheses, making them more obvious again.

The reasoning for the individual room view is consistency. Putting the
domain at the end here looked fine, but putting it in front matches the
room list and still looks fine.
2024-01-03 18:56:29 +01:00
2d2dab11ba Reduce connection timout to 10 seconds 2024-01-03 18:13:15 +01:00
a8570cf9c5 Make TZ env var override time_zone config option 2024-01-03 17:03:00 +01:00
f94eb00dcd Clean up config docs 2024-01-03 16:30:08 +01:00
f555414716 Improve tz load error message very slightly 2024-01-03 02:53:04 +01:00
1189e3eb7b Display chat time stamps in configured time zone 2024-01-03 02:20:59 +01:00
43a8b91dca Load time zone when opening vault 2024-01-03 02:06:33 +01:00
c286e0244c Add time_zone config option 2024-01-03 02:05:53 +01:00
956d3013ea Remove unused code 2024-01-02 18:25:31 +01:00
a1b0e151ff Update changelog 2024-01-02 18:21:55 +01:00
1bf4035c57 Remove message editor cursor when unfocused 2024-01-02 17:52:32 +01:00
cc1a2866eb Simplify retrieving Joined from room::State 2024-01-02 17:32:59 +01:00
5bbf389dbe Remove some old todos 2024-01-02 17:31:36 +01:00
b302229d7c Mention that F1 popup can be scrolled 2024-01-02 17:17:30 +01:00
2deecc2084 Add welcome info box next to room list 2024-01-02 17:03:41 +01:00
a522b09f79 Use let-else in some more places 2024-01-02 15:43:47 +01:00
4e6e413f3d Fix not being able to enter . in domain field 2024-01-02 15:27:32 +01:00
72310d87f5 Update changelog 2024-01-02 15:02:15 +01:00
85cf99387e Extract room deletion popup into module 2024-01-02 13:58:49 +01:00
970bc07ed9 Support choosing domain in room connection popup 2024-01-02 13:51:51 +01:00
f4967731a1 Fix room deletion popup not checking entered name
From the looks of it, I accidentally broke it in v0.7.0 in commit
9bc6931fae.
2024-01-02 13:01:14 +01:00
13a4fa0938 Support domain in room deletion popup 2024-01-02 12:53:59 +01:00
2a10a7a39f Add todos 2024-01-02 12:36:54 +01:00
6a2f9de85b Update changelog 2024-01-02 12:36:20 +01:00
fdbd6e0c55 Simplify getting Joined room state 2024-01-01 19:45:32 +01:00
5995d06cad Support domain in config file 2024-01-01 19:35:46 +01:00
c094f526a5 Fix room names and domains being swapped in room list 2024-01-01 13:06:48 +01:00
60fdc40a21 Fix incorrect room name in url
I naïvely implemented Display for RoomIdentifier, which lead to me
passing room names like "&test@euphoria.leet.nu" to euphoxide instead of
"test". Of course, no room of that name exists, so every attempted
connection failed.

To figure this out, I manually relaxed the verbosity filters to let
through all euphoxide log messages and recompiled.

Only implementing Debug led to compile errors whereever I misused the
Disply instance, so at least the bug fix was nice and easy once I knew
what happened.
2024-01-01 02:18:32 +01:00
78bbfac2f3 Fix domain errors in UI 2024-01-01 02:18:32 +01:00
708d66b256 Support domain when clearing cookies 2024-01-01 01:38:22 +01:00
1f1795f111 Support domain when exporting room logs 2024-01-01 01:38:22 +01:00
2bbfca7002 Respect domain in euph room 2024-01-01 01:38:22 +01:00
da1d23646a Migrate euph vault to respect domain 2024-01-01 01:37:59 +01:00
6b7ab3584a Switch domain mentions to euphoria.leet.nu 2023-12-31 20:15:13 +01:00
076c8f1a72 Include domain in temporary tables 2023-12-31 19:35:57 +01:00
c6a1dd8632 Migrate vault identify rooms by their name and domain 2023-12-31 19:35:52 +01:00
39a4f29a2a Update dependencies 2023-12-28 20:05:18 +01:00
2983733744 Bump version to 0.7.1 2023-08-31 14:01:17 +02:00
2e039a855c Update dependencies 2023-08-31 14:01:03 +02:00
bd874e6212 Run cargo fmt 2023-08-31 13:29:01 +02:00
02bfd3ed3d Bump version to 0.7.0 2023-05-14 16:52:04 +02:00
876619454e Update dependencies 2023-05-14 16:48:26 +02:00
3b4e41ea4e Add example config to config documentation 2023-05-14 16:47:10 +02:00
dc0de4354f Reduce tearing when redrawing 2023-05-10 19:43:51 +02:00
e6585286e3 Fix crash when cursor moves inside folded subtree 2023-05-03 00:02:52 +02:00
2afda17d4b Improve config loading error handling 2023-04-30 22:57:21 +02:00
101d36cd45 Provide list of key groups in config crate
This also fixes the f1 menu not displaying the room group.
2023-04-30 22:12:21 +02:00
48279e879a Show key binding hint in link popup 2023-04-29 16:31:23 +02:00
5e55389887 Make changelog more detailed 2023-04-29 16:18:04 +02:00
e4e321c589 Remove some_or_remove, ok_or_remove macros 2023-04-29 16:00:36 +02:00
325c5e6e5c Remove resolved todos 2023-04-29 15:46:05 +02:00
01c2934fd5 Use actual key binding for empty room list hint 2023-04-29 15:41:25 +02:00
1b831f1b29 Fix messages not rendering as folded 2023-04-29 15:33:09 +02:00
dd427b7792 Show unbound bindings as "unbound" 2023-04-29 15:07:16 +02:00
b6fdc3b7e8 Parse and show space key as "space" 2023-04-29 15:04:37 +02:00
03c7fb567c Auto-derive Default for KeyGroups 2023-04-29 01:50:33 +02:00
f3efff68f5 Allow closing log with abort key 2023-04-29 01:29:44 +02:00
98cb1f2cbc Fix hidden editor sometimes crashing 2023-04-29 01:25:20 +02:00
c04f6a8cb4 Scroll key binding list with cursor keys 2023-04-29 01:17:45 +02:00
14e17730dc Update changelog 2023-04-29 01:17:45 +02:00
591807dd55 Fix handling of shift for KeyCode::Char 2023-04-29 01:17:45 +02:00
9bc6931fae Migrate input handling to new bindings 2023-04-29 01:17:45 +02:00
202969c7a9 Add and update key bindings 2023-04-29 01:17:45 +02:00
c0a01b7ad4 Move cursor centering bind to scroll group 2023-04-29 01:17:45 +02:00
e5960b8eda Include more things in InputEvent 2023-04-29 01:17:45 +02:00
6b05a2a06d Fix modifiers being capitalized 2023-04-29 01:17:45 +02:00
593443f10e Fix F1 menu panicking when opened 2023-04-29 01:17:45 +02:00
1ce31b6677 Always show all key bindings in F1 menu 2023-04-29 01:17:45 +02:00
51b1953207 Fix "any" matching no modifiers 2023-04-29 01:17:45 +02:00
ff33454b9a Document key binding format 2023-04-29 01:17:45 +02:00
36c5831b9b Simplify InputEvent
Now that I've decided the F1 menu should always show all key bindings,
there is no need for the InputEvent to be so complex.
2023-04-27 21:46:33 +02:00
64a7e7f518 Simplify KeyGroup
The trait will only be used for documenting the key bindings in the F1
menu from now on. The InputEvent will be directly match-eable against
KeyBinding-s, which should suffice for input event handling.
2023-04-27 21:37:48 +02:00
6ce2afbc9f Rename Input to InputEvent and add paste events 2023-04-27 21:10:34 +02:00
fdc46aa3b8 Rename Group to KeyGroup 2023-04-27 20:56:41 +02:00
6c99f7a53a Rename Group::Action to Event 2023-04-27 20:39:33 +02:00
15177a529a Clean up macro code a bit 2023-04-27 20:36:00 +02:00
6f85995379 Derive Document for simple enums 2023-04-27 20:34:46 +02:00
458025b8bf Use serde's default annotation for Document 2023-04-27 20:11:16 +02:00
d29441bf02 Move todo to proper location 2023-04-27 20:07:25 +02:00
9f24cb2de1 Fix function key parsing 2023-04-27 18:30:56 +02:00
c53e3c262e Include key bindings in config 2023-04-27 18:30:42 +02:00
e1c3a463b2 Move key binding groups to config crate 2023-04-27 15:31:49 +02:00
5a0efd69e4 Extract euph config into submodule 2023-04-27 15:27:23 +02:00
478e3e767c Add some key binding groups 2023-04-27 11:08:14 +02:00
1276a82e54 Implement Group derive proc macro 2023-04-27 11:08:07 +02:00
a1acc26027 Extract Document derive macro to submodule 2023-04-27 10:38:17 +02:00
17acdd0575 Add Group trait 2023-04-27 10:38:17 +02:00
072290511b Query key bindings against input event 2023-04-27 10:38:17 +02:00
3fbb9127a6 Model and (de-)serialize key bindings 2023-04-27 10:38:17 +02:00
abedc5f194 Create cove-input crate 2023-04-26 14:58:36 +02:00
f7f200a608 Add measure_widths config option 2023-04-24 18:21:46 +02:00
39026a217d Use auto-generated config documentation 2023-04-24 14:09:21 +02:00
cc7dd29af4 Print config documentation 2023-04-24 14:08:57 +02:00
3d91d447c7 Document config 2023-04-24 14:08:57 +02:00
f2c3011888 Implement Document derive proc macro 2023-04-24 14:02:20 +02:00
cedeeff10b Add Document trait for config doc generation 2023-04-24 14:02:20 +02:00
dfb2ef5371 Set up cove-macro proc macro crate 2023-04-24 12:03:30 +02:00
5b5370d2df Extract config into cove-config crate 2023-04-21 02:15:25 +02:00
288a5f97dd Set up workspace 2023-04-21 02:15:25 +02:00
babdd10fba Fix docstrings 2023-04-21 02:15:17 +02:00
502ebab132 Fix scroll offset estimation 2023-04-21 01:44:22 +02:00
027bf489b7 Fix key binding listing spacing 2023-04-20 20:54:43 +02:00
3fb774e93e Remove stray crash 2023-04-20 20:48:26 +02:00
318f7e2a73 Update vault 2023-04-19 23:24:35 +02:00
164c02243d Fix scrolling for editor cursor 2023-04-18 18:13:25 +02:00
ade7be594e Update toss and remove more async 2023-04-17 20:37:30 +02:00
a638caadcb Render messages with less async 2023-04-17 18:59:16 +02:00
8182cc5d38 Fix blocks never being higher than one line 2023-04-17 16:53:34 +02:00
07b761e0f9 Fix list cursor being invisible until first redraw 2023-04-17 11:28:06 +02:00
3f18b76c7d Fix chat scrolling up after sending message 2023-04-17 10:25:53 +02:00
21bb87fd45 Rename new modules to old module names 2023-04-17 10:14:01 +02:00
bc8c5968d6 Remove old chat, widgets, util modules 2023-04-17 10:10:26 +02:00
e2b75d2f52 Move ChatMsg trait to chat2 2023-04-17 10:10:26 +02:00
6f0088e194 Migrate F12 log to AsyncWidget 2023-04-17 10:10:26 +02:00
b8da97aaa4 Migrate room popups to AsyncWidget 2023-04-17 10:10:26 +02:00
31c8453a83 Migrate links popup to AsyncWidget 2023-04-17 10:10:25 +02:00
f69d88bf4a Migrate chat to AsyncWidget 2023-04-17 10:10:25 +02:00
ecc4995397 Implement common widgets 2023-04-17 09:39:01 +02:00
95068920f1 Implement common cursor movement logic 2023-04-17 09:39:01 +02:00
a18ee8e7c0 Implement common renderer and scrolling logic 2023-04-17 09:39:01 +02:00
bb4d0fe047 Add blocks as basis for rendering 2023-04-17 09:39:01 +02:00
d7d25a8390 Migrate inspection popups to AsyncWidget 2023-04-17 09:39:01 +02:00
91d8d7ba97 Migrate account popup to AsyncWidget 2023-04-17 09:39:01 +02:00
03766802fd Migrate auth popup to AsyncWidget 2023-04-17 09:39:01 +02:00
e358e2184e Migrate nick popup to AsyncWidget 2023-04-17 09:39:01 +02:00
c7cbd9856b Migrate nick list to AsyncWidget 2023-04-17 09:39:00 +02:00
d8d3e64776 Migrate room to AsyncWidget 2023-04-17 09:39:00 +02:00
ead4fa7c8a Migrate rooms list to AsyncWidget 2023-04-17 09:39:00 +02:00
adc70ad233 Migrate key bindings list widget to AsyncWidget 2023-04-17 09:39:00 +02:00
d5b6dd9802 Migrate topmost widget to AsyncWidget 2023-04-17 09:39:00 +02:00
8de5bf87af Add util2 module for new widgets 2023-04-17 09:39:00 +02:00
267ef2bee9 Add List AsyncWidget 2023-04-17 09:39:00 +02:00
07960142e0 Add Popup AsyncWidget 2023-04-17 09:39:00 +02:00
3f7ed63064 Add AsyncWidgetWrapper and WidgetWrapper 2023-04-17 09:39:00 +02:00
ff9a16d8a3 Make Widget::size like toss::AsyncWidget::size 2023-04-17 09:39:00 +02:00
059ff94aef Update toss 2023-04-17 09:39:00 +02:00
b515ace906 Add InfallibleExt util trait 2023-04-14 23:32:56 +02:00
d2e3e2aef9 Remove flake-utils dependency
See also:
4f399bd5c4/flake.nix (L14)
17198cf5ae
2023-04-14 22:31:48 +02:00
674534dfa4 Optimize dependencies in debug builds 2023-04-12 19:37:25 +02:00
3f63221594 Write "e.g." correctly 2023-04-12 00:15:52 +02:00
53250ccdcb Bump version to 0.6.1 2023-04-10 12:43:03 +02:00
6089a94a2e Update dependencies 2023-04-10 12:40:37 +02:00
923e68c0b5 Always show rooms from config in rooms list 2023-04-10 12:25:17 +02:00
8c4a966451 Update euphoxide 2023-04-08 20:34:12 +02:00
847af34ceb Make JSON exports faster 2023-04-05 21:56:10 +02:00
9f9c3d998e Bump version to 0.6.0 2023-04-04 23:37:21 +02:00
a487eeb85d Update dependencies 2023-04-04 23:37:21 +02:00
3eb33f14e6 Refine changelog 2023-04-04 23:37:21 +02:00
4e2b597f1e Fix waiting rooms being sorted to bottom 2023-03-17 18:27:21 +01:00
1e90e76fba Fix rooms being stuck in "Connecting" state
I haven't managed to reliably reproduce this bug, so I don't know if
this actually fixes it.
2023-03-12 16:36:54 +01:00
0612d235d7 Recognize links without scheme 2023-03-07 14:25:09 +01:00
da3d84c9d8 Fix connecting to rooms as bot instead of human 2023-03-04 22:00:37 +01:00
582cac8421 Turn repo into flake 2023-03-04 20:15:29 +01:00
65fa1b8afd Update euphoxide
This fixes authentication for rooms requiring passwords
2023-02-26 21:21:26 +01:00
7568fb3434 Add todo 2023-02-23 14:41:10 +01:00
5738fe391a Include instance log messages again 2023-02-23 14:41:10 +01:00
293112777a Fix bugged room state from lingering connection
When disconnecting from a room whose instance is "waiting" and then
reconnecting, the old instance would not be stopped immediately.
Instead, it would continue to run until it managed to reconnect, sending
status updates to the main event bus in the process.

These events led to the euph::Room entering a state where it was
connected but no last_msg_id was set. This meant that no new messages
could be entered into the vault, including messages sent by the user.
The result was UI weirdness when sending a message.

As a fix, euphoxide instances are now identified via an u32 id. This id
is unique across all rooms. Packets by unknown ids are rejected and have
no effect on room states.
2023-02-23 14:41:10 +01:00
d74282581c Deduplicate code 2023-02-23 14:41:10 +01:00
fb164eeaa9 Add todos 2023-02-23 14:41:10 +01:00
7e9e441c1e Use Garmelon/vault 2023-02-23 14:41:10 +01:00
35a140e21f Make MsgStore fallible 2023-02-23 14:41:10 +01:00
5581fc1fc2 Add vscode settings 2023-02-23 14:41:10 +01:00
8bd58417dd Fix import grouping 2023-02-23 14:41:10 +01:00
84279d6800 Print non-export output on stderr 2023-02-23 14:41:10 +01:00
ca10ca277b Add option to export to stdout 2023-02-23 14:41:10 +01:00
0ceaffc608 Add json-stream export format 2023-02-23 14:41:10 +01:00
ba1b8b419c Add todo 2023-02-23 14:41:10 +01:00
55cc8a5d09 Update dependencies 2023-02-23 14:41:10 +01:00
ecedad8f0f Update euphoxide 2023-01-30 19:04:24 +01:00
56373135c7 Fix mentions not being stopped by > 2023-01-30 17:59:55 +01:00
b6d69ce0b5 Fix sort order for rooms waiting to reconnect 2023-01-24 18:23:06 +01:00
f2d70f99eb Fix rooms not reconnecting properly 2023-01-24 18:22:52 +01:00
2f7234189b Add --verbose flag 2023-01-23 23:03:17 +01:00
f9533d8119 Update debug logging
Some things euphoxide already logs. The priorities for the other
messages were adjusted to make more sense (hopefully).
2023-01-23 22:57:56 +01:00
c2e739abf9 Fix auth-auth-disconnect-reconnect loop
Both euphoxide and cove would try to authenticate, leading to the server
disconnecting the session. The Instance would then immediately reconnect
because the previous initial connection was successful. Rinse and repeat
2023-01-23 22:50:42 +01:00
1be5fb5f39 Limit logged messages 2023-01-23 22:49:34 +01:00
8dd5db5888 Switch euph::Room to use euphoxide's Instance 2023-01-23 22:49:34 +01:00
b94dfbdc31 Update euphoxide and enable feature "bot" 2023-01-23 22:49:34 +01:00
0ff3e94690 Fix rendering of /me 2023-01-21 14:24:07 +01:00
875f8be181 Simplify return type 2023-01-20 21:45:30 +01:00
23352e7027 Rename "status" to "state" in most places
This follows the name change of euphoxide, which renamed its connection
Status to State.
2023-01-20 21:45:30 +01:00
f72da10171 Don't set bg color on replaced emoji 2023-01-20 21:45:30 +01:00
c38b8c2ee2 Display colon-delimited emoji in messages 2023-01-20 21:45:30 +01:00
16011a267d Display colon-delimited emoji in nicks 2023-01-20 20:19:03 +01:00
9f7c1fb9c0 Respect emoji when calculating nick hue 2023-01-20 20:18:34 +01:00
9324517c56 Update euphoxide 2023-01-20 19:33:52 +01:00
82d6738e49 Bump version to 0.5.2 2023-01-14 18:02:13 +01:00
1585b2e8a1 Update dependencies 2023-01-14 18:00:58 +01:00
f61c03cf0a Remove redundant vault 2023-01-14 17:46:05 +01:00
acb03b1f09 Open room present link with p 2023-01-14 17:45:46 +01:00
20186bda5c Satisfy clippy 2023-01-05 14:21:50 +01:00
5acf49d018 Simplify lints 2022-12-11 20:36:41 +01:00
008554a2bd Update euphoxide and tokio-tungstenite 2022-12-10 02:49:46 +01:00
89cda4088e Add some &rl2dev history bug workarounds 2022-12-07 01:36:22 +01:00
a2275d89eb Bump version to 0.5.1 2022-11-27 02:11:18 +01:00
c84470ff5c Update dependencies 2022-11-27 02:05:02 +01:00
31129ece39 Increase reconnect delay to one minute 2022-11-09 19:57:43 +01:00
bf2732eccd Satisfy clippy 2022-11-05 14:46:01 +01:00
d437341dab Omit newlines between errors 2022-11-05 14:45:26 +01:00
ffcae898f3 Update euphoxide 2022-10-23 14:05:42 +02:00
3895388e54 Update to clap 4.0 2022-09-29 13:06:06 +02:00
ec34a45f2b Add todo 2022-09-27 14:01:18 +02:00
ec3ba31176 Bump version to 0.5.0 2022-09-26 21:24:58 +02:00
30fe8aac60 Update dependencies 2022-09-26 21:24:58 +02:00
1ee82eaed0 Use esc to leave nick list focus 2022-09-26 21:24:58 +02:00
7dfa8c6048 Make initial rooms sort order configurable 2022-09-26 20:34:45 +02:00
61a9cc10f1 Update toss to version with separate widthdb 2022-09-26 17:36:49 +02:00
5ed0cd5f3f Update euphoxide to version with partial sessions 2022-09-26 16:56:38 +02:00
374c4c4f79 Update euphoxide to version with id newtype wrappers 2022-09-26 10:20:47 +02:00
2d88513a28 Add message inspection popup 2022-09-25 23:18:18 +02:00
bbf6371f87 Reorganize key bindings list a bit 2022-09-25 22:50:45 +02:00
5d1252faae Fix nick list cursor rendering 2022-09-25 22:39:33 +02:00
f109fd0d9b Move cursor in nick list 2022-09-25 22:35:05 +02:00
c16ad024ed Extract list key bindings to util
Also refactors the Rooms event handling code a bit
2022-09-25 22:34:41 +02:00
30276fcbbf Display nick list cursor 2022-09-25 22:22:01 +02:00
75e3a08b58 Display chat cursor only when chat has focus 2022-09-25 22:22:01 +02:00
9c9d9a51bb Switch focus using tab
Also refactored some key event handling code in the process.
2022-09-25 22:22:01 +02:00
8703a62887 Track focus in room 2022-09-25 20:03:03 +02:00
147c3eaf92 Clean up use of Size 2022-09-25 20:02:38 +02:00
d5c0c94883 Remove and add todos 2022-09-25 20:02:36 +02:00
4dde87d805 Fix list cursor when item moves off-screen
When a list scrolls or changes in such a way that the cursor item moves
off-screen, the cursor would jump to the closest visible item.

It makes more sense for the cursor to remain on its selected item and
for the list to scroll instead. That way, it is less likely for the user
to perform an action on the wrong list item if they press a key while
the list is changing.
2022-09-25 19:35:58 +02:00
9aac9f6fdd Add error popup when external editor fails 2022-09-25 18:57:59 +02:00
4c7ac31699 Fix inspect message only working when connected 2022-09-16 00:47:20 +02:00
e7041da098 Fix typo 2022-09-10 15:38:34 +02:00
bc54184b13 Simplify function call 2022-09-10 01:03:02 +02:00
8eaec4426b Log encountered errors on shutdown 2022-09-09 22:25:09 +02:00
c07941b374 Log sql errors in vault 2022-09-09 22:04:23 +02:00
37df869695 Simplify code 2022-09-09 21:59:24 +02:00
cb1fdb41b8 Rename tree_id to root_id 2022-09-09 21:55:14 +02:00
da2c3d86f5 Move functions to EuphVault and add EuphRoomVault
This commit moves all euph_* functions from Vault to EuphVault. The
previous EuphVault is now called EuphRoomVault and re-exports all
room-based functions from the EuphVault. It also implements MsgStore.
2022-09-09 21:55:14 +02:00
ff56bb2678 Reduce vault code duplication with macros 2022-09-09 21:55:14 +02:00
d7e19b5eca Add message inspection popup 2022-09-09 00:02:02 +02:00
d92c7cb98e Add room deletion confirmation popup 2022-09-08 22:57:04 +02:00
f49481cb10 Fix cursor disappearing in editor 2022-09-08 22:05:46 +02:00
9876dd67a7 Fix cursor being visible through popups 2022-09-08 18:16:29 +02:00
fff774dd16 Bump version to 0.4.0 2022-09-01 21:37:03 +02:00
7d598df28a Update dependencies 2022-09-01 21:37:03 +02:00
f305f688a2 Mention --config in changelog 2022-09-01 21:37:03 +02:00
86c128b92d Document config file format and options 2022-09-01 21:11:53 +02:00
067389efa2 Move "Using cove" section to the top
It is fairly important and easily missed if it is placed after the long
"Manual install" section. If I ever add easier ways to install cove
(like providing prebuilt binaries or packaging it on some package
managers), I might change this order again and refer to the "Using cove"
section in the top paragraph instead.
2022-09-01 21:09:25 +02:00
19febc188e Remove unnecessary mut-s 2022-08-30 17:32:57 +02:00
21245a8274 Use absolute paths in key! macro 2022-08-30 17:25:50 +02:00
5eeabea2de Add todos 2022-08-30 17:17:11 +02:00
03ddc5eb9b Add rooms keybindings around autojoin rooms 2022-08-30 15:09:06 +02:00
a091855ea3 Fix links key binding masking editor key bindings 2022-08-30 03:05:37 +02:00
9c3f846d8a Update changelog 2022-08-30 02:37:35 +02:00
37b04c7eba Open links via number shortcuts 2022-08-30 02:32:29 +02:00
7932c2f20b Show message when no links were found 2022-08-30 02:32:08 +02:00
8846234d8d Extract links from message 2022-08-30 02:31:45 +02:00
JRF
a1043eafd3 Add key bindings to select and open links 2022-08-29 19:00:42 -05:00
c09608d1f8 Open link popup via key binding 2022-08-30 00:30:08 +02:00
bb542ae08e Retrieve individual messages from store 2022-08-29 22:57:02 +02:00
JRF
7e086258b6 Add key bindings to move to parent/root message 2022-08-29 00:38:31 +02:00
827a854101 Add --offline cli flag 2022-08-27 17:05:40 +02:00
73a0971c34 Add 'offline' config option to turn off autojoin 2022-08-27 17:03:31 +02:00
04581f9158 Add 'euph.rooms.<name>.autojoin' config option 2022-08-27 15:09:53 +02:00
74561c791b Add key bindings to dis-/connect from/to all rooms 2022-08-27 14:51:15 +02:00
ac13f4b490 Add key binding to change rooms sort order 2022-08-27 14:37:34 +02:00
c9eee7f1d0 Clean up cursor movement code a bit 2022-08-27 12:10:23 +02:00
8c1b207ac1 Fix --data-dir being incorrectly resolved 2022-08-27 12:10:23 +02:00
6150d05255 Add 'euph.rooms.<name>.force_username' config option 2022-08-25 23:19:40 +02:00
d0ba210855 Add 'euph.rooms.<name>.username' config option 2022-08-25 23:03:33 +02:00
6e6fddc0b1 Add 'euph.rooms.<name>.password' config option 2022-08-25 22:49:34 +02:00
e40948567a Add 'data_dir' config option 2022-08-25 22:33:25 +02:00
84ff1f068b Add 'ephemeral' config option 2022-08-25 22:15:43 +02:00
d61e0ceab7 Load config file on startup 2022-08-25 22:12:29 +02:00
8419afd2e1 Remove old comment 2022-08-25 22:08:47 +02:00
48764a2454 Bump version to 0.3.0 2022-08-22 21:26:39 +02:00
46a8f94818 Update dependencies 2022-08-22 21:25:34 +02:00
669e52a2ee Add key binding to download more logs 2022-08-22 21:15:49 +02:00
4956027027 Don't download room history in ephemeral mode 2022-08-22 21:15:09 +02:00
68bd6042c5 Add --ephemeral cli flag 2022-08-22 21:04:15 +02:00
f76c6a557d Remove key binding A as alias for a while joining room 2022-08-22 20:36:30 +02:00
3012de944b Fix hidden editor rendering 2022-08-22 20:22:25 +02:00
c618413728 Make popup titles left-aligned
In some cases when expanding popups with centered titles horizontally,
the title would jump right and left by one character. The new popups
also look more like the lazygit popups.
2022-08-22 20:07:26 +02:00
7b1259dee3 Redesign account ui 2022-08-22 20:03:47 +02:00
10e1ad6003 Reconect immediately on login/logout 2022-08-22 18:26:50 +02:00
7ca6ed5496 Fix client not disconnecting on account changes 2022-08-22 18:21:48 +02:00
59a4294e35 Fix char filter when pasting into editor 2022-08-22 17:25:21 +02:00
8128342099 Implement account login and logout 2022-08-22 17:25:21 +02:00
84930c8c34 Add focus and hiding options to editor 2022-08-22 17:15:09 +02:00
e24a5ee1c4 Adjust export help message wording
Since the export command can now export multiple rooms at a time, the
old description was outdated.
2022-08-21 16:35:04 +02:00
d25873f3c6 Fix auth failure popup not showing up 2022-08-21 02:54:18 +02:00
10ea7d13fd Refactor room key binding code a bit 2022-08-21 02:42:29 +02:00
235fd9acc5 Move stability checks back into room 2022-08-21 02:38:45 +02:00
878467835e Extract auth dialog into module 2022-08-21 02:36:30 +02:00
9ad550f98c Extract nick dialog into module 2022-08-21 02:30:12 +02:00
4e0509b08e Extract nick list rendering into module 2022-08-21 02:14:29 +02:00
07fab96e12 Fix not being able to leave connected rooms 2022-08-21 01:42:03 +02:00
c661984d1c Hide password while authenticating 2022-08-21 01:41:52 +02:00
7b52add24e Add password authentication dialog 2022-08-21 01:19:07 +02:00
19d75a1d15 Add enum for room status
This way, it is far easier to understand what the different values mean
2022-08-21 00:35:17 +02:00
28899965c7 Update euphoxide 2022-08-20 23:52:54 +02:00
2201e04e15 Fix UI not updating when connecting to room fails 2022-08-20 23:18:13 +02:00
6c637390e4 Use popup widget builder 2022-08-20 23:17:48 +02:00
4094ba3e3d Add popup widget builder 2022-08-20 23:17:42 +02:00
12f4b9fa73 Fix UI not redrawing when resizing 2022-08-20 21:22:33 +02:00
df8a278854 Polish look of error popups 2022-08-20 21:22:33 +02:00
5d5f55107a Show error popups on some server errors 2022-08-20 21:22:33 +02:00
ab36df3c2b Add error popups to room UI 2022-08-20 21:05:24 +02:00
ded927b9f0 Overhaul UI event handling 2022-08-20 18:36:20 +02:00
ade06efa01 Fix pasting multi-line strings 2022-08-20 16:31:09 +02:00
fe381e1146 Make unrendered editors' behaviour a bit more sane
In practice, this doesn't really matter anyways.
2022-08-20 16:25:46 +02:00
cf086b6065 Add note about UI events 2022-08-19 23:38:04 +02:00
037bed698c Make room list heading always visible 2022-08-19 23:26:49 +02:00
fc44a59a6f Remove dependency on palette 2022-08-19 23:18:26 +02:00
84bf2015ec Remove unused dependencies 2022-08-18 18:14:22 +02:00
36b717ff8c Use euphoxide instead of euph module 2022-08-18 18:13:49 +02:00
d07b1051a9 Add euphoxide dependency 2022-08-18 18:10:42 +02:00
6dc7d2bd0b Placate clippy 2022-08-17 23:06:49 +02:00
80dad00125 Fix crash when connecting to some types of room 2022-08-17 23:04:43 +02:00
34bcf85236 Bump version to 0.2.1 2022-08-11 23:23:54 +02:00
06faaa9a9d Update dependencies 2022-08-11 23:22:53 +02:00
992af0fddb Add support for kitty keyboard protocol 2022-08-11 23:21:32 +02:00
692a167143 Fix crash when joining new rooms 2022-08-11 22:58:45 +02:00
19a477e423 Make cursor visible after exiting editor 2022-08-11 14:37:04 +02:00
e6a6497b30 Bump version to 0.2.0 2022-08-11 00:14:23 +02:00
efe44bb6cb Update dependencies 2022-08-11 00:01:51 +02:00
f7e7003788 Handle paste events in editor
Only on non-windows platforms though, since crossterm doesn't support
pasting on windows.
2022-08-10 23:59:45 +02:00
5ad9f0f3e7 Include pastes in input events 2022-08-10 23:59:08 +02:00
7733b1a2c8 Update crossterm 2022-08-10 23:16:12 +02:00
fa91515a61 Move euph room ui to new euph module 2022-08-10 22:35:30 +02:00
f7e379fe3a Scroll with page up/down 2022-08-10 03:14:26 +02:00
7857fcf2d8 Update changelog 2022-08-10 03:11:03 +02:00
186ca5ea5a Add json export 2022-08-10 03:08:06 +02:00
ed181a6518 Restructure export code and arg handling 2022-08-10 01:58:25 +02:00
44fce04a87 Include version in clap output 2022-08-10 00:33:45 +02:00
c6f879c2a5 Flush BufWriter before exiting 2022-08-10 00:30:34 +02:00
5acb4c6396 Center cursor on screen 2022-08-09 15:51:47 +02:00
a4b79d4e81 Move cursor to prev/next sibling 2022-08-09 15:44:35 +02:00
d65183e0ae Update changelog 2022-08-09 15:14:02 +02:00
c41ab742d3 Fix message count in folded info 2022-08-09 15:12:49 +02:00
87a14eedf2 Move cursor over folded subtrees 2022-08-09 15:07:37 +02:00
0ad3432141 Fold subtrees 2022-08-09 15:00:12 +02:00
26923745ad Show unseen message count in room status info 2022-08-09 01:18:20 +02:00
f17d4459d1 Remove unnecessary trigger 2022-08-09 01:09:27 +02:00
8a28ba7b6e Move euph_trees logic into sqlite triggers 2022-08-09 01:09:20 +02:00
84d0bc2bca Follow sqlite advice for temp triggers 2022-08-09 00:54:07 +02:00
fa7d904932 Fix formatting 2022-08-09 00:50:21 +02:00
9314e29b0e Fix unseen message count not appearing initially
When launching cove, the euph_rooms hash map would be empty until
interacting with a room for the first time. This led to the unseen
message count only being displayed after interacting with a room. Now,
missing rooms are inserted into euph_rooms during stabilization.
2022-08-09 00:50:07 +02:00
453233be9c Cache unseen message count 2022-08-09 00:41:17 +02:00
888870b779 Show unseen message count in room list 2022-08-08 23:14:58 +02:00
e00ce4ebba Warn about possible vault corruption 2022-08-08 21:31:12 +02:00
db7abaf000 Update changelog 2022-08-08 21:31:12 +02:00
9e99c0706a Improve mark-older-as-unseen performance 2022-08-08 21:31:12 +02:00
0490ce394d Improve unseen cursor movement performance
It's only really noticeable when pressing H at the first unseen message
2022-08-08 21:31:12 +02:00
bfc221106d Move to prev/next unseen message 2022-08-08 21:31:12 +02:00
05ce069121 Fix reinserting existing messages overwriting seen 2022-08-08 21:31:12 +02:00
973a621a13 Fix type conversion error when cursor is at bottom 2022-08-08 21:31:12 +02:00
cee91695e0 Mark older messages as seen instead 2022-08-08 21:31:12 +02:00
573f231466 Mark all messages as seen 2022-08-08 21:31:12 +02:00
43247e2a5c Mark all visible messages as seen 2022-08-08 21:31:12 +02:00
de569211f6 Display seen status of messages 2022-08-08 21:31:12 +02:00
6166c5e366 Toggle messages' seen status 2022-08-08 21:31:09 +02:00
ff4118e21d Query and set seen status via store 2022-08-08 15:14:50 +02:00
20ec6ef3b3 Set messages' seen status when adding to vault 2022-08-08 15:14:50 +02:00
fdb8fc7bd0 Add 'seen' flag to euph msgs in vault 2022-08-08 15:14:50 +02:00
00f376c11b Add checklist for bumping version number 2022-08-07 01:03:48 +02:00
f430b0efc7 Fix db inconsistencies when deleting a room
Since the euph_trees table can't have any foreign key constraints
pointing to the euph_rooms table, deleting a room wouldn't delete that
room's trees in euph_trees. Upon reconnecting to the room, those trees
would then be displayed as placeholder messages without children.
2022-08-07 00:55:54 +02:00
a2b9f57a09 Fix room and nick dialog padding 2022-08-07 00:55:54 +02:00
d114857abd Update changelog 2022-08-07 00:55:54 +02:00
de095e74ae Change binding for external editor
In order to avoid collisions with ctrl+e, we need a new binding. In
bash/readline, ctrl+x is used as a sort of leader key to initiate
multi-key bindings. I don't think I'll implement multi-key combinations
any time soon, so now ctrl+x stands for 'edit in eXternal editor'.
2022-08-07 00:30:36 +02:00
9ebe2361a9 Move cursor one word left/right 2022-08-07 00:25:53 +02:00
51d03c6fe2 Fix moving to end of last line 2022-08-07 00:01:27 +02:00
4bf6d80988 Move to start/end of editor line 2022-08-06 23:54:53 +02:00
ba35a606a8 Increase F1 key binding column width 2022-08-06 23:54:43 +02:00
0d3131facd Add more readline-like key bindings 2022-08-06 23:54:22 +02:00
bfbdec4396 Move editor key handling to one place 2022-08-06 23:39:56 +02:00
f48a4a6416 Remove trailing newline of externally edited text 2022-08-06 23:39:56 +02:00
c4d3f5ba4d Move cursor in message editor vertically 2022-08-06 23:39:56 +02:00
8b66de44e0 Increase delay between log requests 2022-08-06 02:04:09 +02:00
114 changed files with 13493 additions and 8796 deletions

75
.github/workflows/build.yml vendored Normal file
View 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"

8
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"files.insertFinalNewline": true,
"rust-analyzer.cargo.features": "all",
"rust-analyzer.imports.granularity.enforce": true,
"rust-analyzer.imports.granularity.group": "crate",
"rust-analyzer.imports.group.enable": true,
"evenBetterToml.formatter.columnWidth": 100,
}

View file

@ -3,8 +3,308 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Procedure when bumping the version number:
1. Update dependencies in a separate commit
2. Set version number in `Cargo.toml`
3. Add new section in this changelog
4. Run `cargo run help-config > CONFIG.md`
5. Commit with message `Bump version to X.Y.Z`
6. Create tag named `vX.Y.Z`
7. Push `master` and the new tag
## 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
- Option to export to stdout via `--out -`
- `--verbose` flag
### Changed
- Non-export info is now printed to stderr instead of stdout
- Recognizes links without scheme (e.g. `euphoria.io` instead of `https://euphoria.io`)
- Rooms waiting for reconnect are no longer sorted to bottom in default sort order
### Fixed
- Mentions not being stopped by `>`
## v0.5.2 - 2023-01-14
### Added
- Key binding to open present page
### Changed
- Always connect to &rl2dev in ephemeral mode
- Reduce amount of messages per &rl2dev log request
## v0.5.1 - 2022-11-27
### Changed
- Increase reconnect delay to one minute
- Print errors that occurred while cove was running more compactly
## v0.5.0 - 2022-09-26
### Added
- Key bindings to navigate nick list
- Room deletion confirmation popup
- Message inspection popup
- Session inspection popup
- Error popup when external editor fails
- `rooms_sort_order` config option
### Changed
- Use nick changes to detect sessions for nick list
- Support Unicode 15
### Fixed
- Cursor being visible through popups
- Cursor in lists when highlighted item moves off-screen
- User disappearing from nick list when only one of their sessions disconnects
## v0.4.0 - 2022-09-01
### Added
- Config file and `--config` cli option
- `data_dir` config option
- `ephemeral` config option
- `offline` config option and `--offline` cli flag
- `euph.rooms.<name>.autojoin` config option
- `euph.rooms.<name>.username` config option
- `euph.rooms.<name>.force_username` config option
- `euph.rooms.<name>.password` config option
- Key binding to change rooms sort order
- Key bindings to connect to/disconnect from all rooms
- Key bindings to connect to autojoin rooms/disconnect from non-autojoin rooms
- Key bindings to move to parent/root message
- Key bindings to view and open links in a message
### Changed
- Some key bindings in the rooms list
### Fixed
- Rooms being stuck in "Connecting" state
## v0.3.0 - 2022-08-22
### Added
- Account login and logout
- Authentication dialog for password-protected rooms
- Error popups in rooms when something goes wrong
- `--ephemeral` flag that prevents cove from storing data permanently
- 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
## 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
- More readline-esque editor key bindings
- Key bindings to move to prev/next sibling
- Key binding to center cursor on screen
- More scrolling key bindings
- JSON message export
- Export output path templating
- 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
## v0.1.0 - 2022-08-06
Initial release

711
CONFIG.md Normal file
View 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.

1745
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,40 +1,72 @@
[package]
name = "cove"
version = "0.1.0"
edition = "2021"
[workspace]
resolver = "3"
members = ["cove", "cove-*"]
[dependencies]
anyhow = "1.0.58"
async-trait = "0.1.56"
clap = { version = "3.2.14", features = ["derive"] }
crossterm = "0.24.0"
directories = "4.0.1"
edit = "0.1.4"
futures = "0.3.21"
log = { version = "0.4.17", features = ["std"] }
palette = { version = "0.6.1", default-features = false, features = ["std"] }
parking_lot = "0.12.1"
rand = "0.8.5"
rusqlite = { version = "0.28.0", features = ["bundled", "time"] }
serde = { version = "1.0.140", features = ["derive"] }
serde_json = "1.0.82"
thiserror = "1.0.31"
tokio = { version = "1.20.0", features = ["full"] }
unicode-segmentation = "1.9.0"
unicode-width = "0.1.9"
cookie = "0.16.0"
[workspace.package]
version = "0.9.3"
edition = "2024"
[dependencies.time]
version = "0.3.11"
features = ["macros", "formatting", "parsing", "serde"]
[workspace.dependencies]
anyhow = "1.0.97"
async-trait = "0.1.87"
clap = { version = "4.5.32", features = ["derive", "deprecated"] }
cookie = "0.18.1"
crossterm = "0.28.1"
directories = "6.0.0"
edit = "0.1.5"
jiff = "0.2.4"
linkify = "0.10.0"
log = { version = "0.4.26", features = ["std"] }
open = "5.3.2"
parking_lot = "0.12.3"
proc-macro2 = "1.0.94"
quote = "1.0.40"
rusqlite = { version = "0.31.0", features = ["bundled", "time"] }
rustls = "0.23.23"
serde = { version = "1.0.219", features = ["derive"] }
serde_either = "0.2.1"
serde_json = "1.0.140"
syn = "2.0.100"
thiserror = "2.0.12"
tokio = { version = "1.44.1", features = ["full"] }
toml = "0.8.20"
unicode-width = "0.2.0"
[dependencies.tokio-tungstenite]
version = "0.17.2"
features = ["rustls-tls-native-roots"]
[workspace.dependencies.euphoxide]
git = "https://github.com/Garmelon/euphoxide.git"
tag = "v0.6.1"
features = ["bot"]
[dependencies.toss]
[workspace.dependencies.toss]
git = "https://github.com/Garmelon/toss.git"
rev = "5957e8e5508a3772b2229fc9d8ac30ce4173d356"
tag = "v0.3.4"
# [patch."https://github.com/Garmelon/toss.git"]
# toss = { path = "../toss/" }
[workspace.dependencies.vault]
git = "https://github.com/Garmelon/vault.git"
tag = "v0.4.0"
features = ["tokio"]
[workspace.lints]
rust.unsafe_code = { level = "forbid", priority = 1 }
# Lint groups
rust.deprecated_safe = "warn"
rust.future_incompatible = "warn"
rust.keyword_idents = "warn"
rust.rust_2018_idioms = "warn"
rust.unused = "warn"
# Individual lints
rust.non_local_definitions = "warn"
rust.redundant_imports = "warn"
rust.redundant_lifetimes = "warn"
rust.single_use_lifetimes = "warn"
rust.unit_bindings = "warn"
rust.unnameable_types = "warn"
rust.unused_crate_dependencies = "warn"
rust.unused_import_braces = "warn"
rust.unused_lifetimes = "warn"
rust.unused_qualifications = "warn"
# Clippy
clippy.use_self = "warn"
[profile.dev.package."*"]
opt-level = 3

View file

@ -1,56 +1,18 @@
# cove
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded
Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
real-time chat platform.
![A very meta screenshot](screenshot.png)
It runs on Linux, Windows and macOS.
It runs on Linux, Windows, and macOS.
## Manual installation
## Installing cove
This section contains instructions on how to install cove by compiling it yourself.
It doesn't assume you know how to program, but it does assume basic familiarity with the command line on your platform of choice.
Cove runs in the terminal, after all.
Download a binary of your choice from the
[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest).
### Installing rustup
Cove is written in Rust, so the first step is to install rustup. Either install
it from your package manager of choice (if you have one) or use the
[installer](https://rustup.rs/).
Test your installation by running `rustup --version` and `cargo --version`. If
rustup is installed correctly, both of these should show a version number.
Cove is designed on the current version of the stable toolchain. If cove doesn't
compile, you can try switching to the stable toolchain and updating it using the
following commands:
```bash
$ rustup default stable
$ rustup update
```
### Installing cove
To install or update to the latest release of cove, run the following command:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --branch latest
```
If you like to live dangerously and want to install or update to the latest,
bleeding-edge, possibly-broken commit from the repo's main branch, run the
following command:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove
```
To install a specific version of cove, run the following command and substitute
in the full version you want to install:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0
```
### Using cove
## Using cove
To start cove, simply run `cove` in your terminal. For more info about the
available subcommands such as exporting room logs or resetting cookies, run
@ -60,3 +22,12 @@ If you delete rooms, cove's vault (the database it stores messages and other
things in) won't automatically shrink. If it takes up too much space, try
running `cove gc` and waiting for it to finish. This isn't done automatically
because it can take quite a while.
## Configuring cove
A complete list of config options is available in the [CONFIG.md](CONFIG.md)
file or via `cove help-config`.
When launched, cove prints the location it is loading its config file from. To
configure cove, create a config file at that location. This location can be
changed via the `--config` command line option.

0
cove-config/CONFIG.md Normal file
View file

15
cove-config/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"),
}
}

View 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
View 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
View 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
View 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

9
cove/src/euph.rs Normal file
View file

@ -0,0 +1,9 @@
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
View 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)]);
}
}

316
cove/src/euph/room.rs Normal file
View file

@ -0,0 +1,316 @@
use std::{convert::Infallible, time::Duration};
use euphoxide::{
api::{
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply,
Time, UserId, packet::ParsedPacket,
},
bot::instance::{ConnSnapshot, Event, Instance, InstanceConfig},
conn::{self, ConnTx, Joined},
};
use log::{debug, info, warn};
use tokio::{select, sync::oneshot};
use crate::{macros::logging_unwrap, vault::EuphRoomVault};
const LOG_INTERVAL: Duration = Duration::from_secs(10);
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum State {
Disconnected,
Connecting,
Connected(ConnTx, conn::State),
Stopped,
}
impl State {
pub fn conn_tx(&self) -> Option<&ConnTx> {
if let Self::Connected(conn_tx, _) = self {
Some(conn_tx)
} else {
None
}
}
pub fn joined(&self) -> Option<&Joined> {
match self {
Self::Connected(_, conn::State::Joined(joined)) => Some(joined),
_ => None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not connected to room")]
NotConnected,
}
#[derive(Debug)]
pub struct Room {
vault: EuphRoomVault,
ephemeral: bool,
instance: Instance,
state: State,
/// `None` before any `snapshot-event`, then either `Some(None)` or
/// `Some(Some(id))`. Reset whenever connection is lost.
last_msg_id: Option<Option<MessageId>>,
/// `Some` while `Self::regularly_request_logs` is running. Set to `None` to
/// drop the sender and stop the task.
log_request_canary: Option<oneshot::Sender<Infallible>>,
}
impl Room {
pub fn new<F>(vault: EuphRoomVault, instance_config: InstanceConfig, on_event: F) -> Self
where
F: Fn(Event) + std::marker::Send + Sync + 'static,
{
Self {
ephemeral: vault.vault().vault().ephemeral(),
instance: instance_config.build(on_event),
state: State::Disconnected,
last_msg_id: None,
log_request_canary: None,
vault,
}
}
pub fn stopped(&self) -> bool {
self.instance.stopped()
}
pub fn instance(&self) -> &Instance {
&self.instance
}
pub fn state(&self) -> &State {
&self.state
}
fn conn_tx(&self) -> Result<&ConnTx, Error> {
self.state.conn_tx().ok_or(Error::NotConnected)
}
pub async fn handle_event(&mut self, event: Event) {
match event {
Event::Connecting(_) => {
self.state = State::Connecting;
// Juuust to make sure
self.last_msg_id = None;
self.log_request_canary = None;
}
Event::Connected(_, ConnSnapshot { conn_tx, state }) => {
if !self.ephemeral {
let (tx, rx) = oneshot::channel();
self.log_request_canary = Some(tx);
let vault_clone = self.vault.clone();
let conn_tx_clone = conn_tx.clone();
debug!("{}: spawning log request task", self.instance.config().room);
tokio::task::spawn(async move {
select! {
_ = rx => {},
_ = Self::regularly_request_logs(vault_clone, conn_tx_clone) => {},
}
});
}
self.state = State::Connected(conn_tx, state);
let cookies = &*self.instance.config().server.cookies;
let cookies = cookies.lock().unwrap().clone();
let domain = self.vault.room().domain.clone();
logging_unwrap!(self.vault.vault().set_cookies(domain, cookies).await);
}
Event::Packet(_, packet, ConnSnapshot { conn_tx, state }) => {
self.state = State::Connected(conn_tx, state);
self.on_packet(packet).await;
}
Event::Disconnected(_) => {
self.state = State::Disconnected;
self.last_msg_id = None;
self.log_request_canary = None;
}
Event::Stopped(_) => {
self.state = State::Stopped;
}
}
}
async fn regularly_request_logs(vault: EuphRoomVault, conn_tx: ConnTx) {
// TODO Make log downloading smarter
// Possible log-related mechanics. Some of these could also run in some
// sort of "repair logs" mode that can be started via some key binding.
// For now, this is just a list of ideas.
//
// Download room history until there are no more gaps between now and
// the first known message.
//
// Download room history until reaching the beginning of the room's
// history.
//
// Check if the last known message still exists on the server. If it
// doesn't, do a binary search to find the server's last message and
// delete all older messages.
//
// Untruncate messages in the history, as well as new messages.
//
// Try to retrieve messages that are not in the room log by retrieving
// them by id.
//
// Redownload messages that are already known to find any edits and
// deletions that happened while the client was offline.
//
// Delete messages marked as deleted as well as all their children.
loop {
tokio::time::sleep(LOG_INTERVAL).await;
Self::request_logs(&vault, &conn_tx).await;
}
}
async fn request_logs(vault: &EuphRoomVault, conn_tx: &ConnTx) {
let before = match logging_unwrap!(vault.last_span().await) {
Some((None, _)) => return, // Already at top of room history
Some((Some(before), _)) => Some(before),
None => None,
};
debug!("{:?}: requesting logs", vault.room());
let _ = conn_tx.send(Log { n: 1000, before }).await;
// The code handling incoming events and replies also handles
// `LogReply`s, so we don't need to do anything special here.
}
fn own_user_id(&self) -> Option<UserId> {
if let State::Connected(_, state) = &self.state {
Some(match state {
conn::State::Joining(joining) => joining.hello.as_ref()?.session.id.clone(),
conn::State::Joined(joined) => joined.session.id.clone(),
})
} else {
None
}
}
async fn on_packet(&mut self, packet: ParsedPacket) {
let room_name = &self.instance.config().room;
let Ok(data) = &packet.content else {
return;
};
match data {
Data::BounceEvent(_) => {}
Data::DisconnectEvent(_) => {}
Data::HelloEvent(_) => {}
Data::JoinEvent(d) => {
debug!("{room_name}: {:?} joined", d.0.name);
}
Data::LoginEvent(_) => {}
Data::LogoutEvent(_) => {}
Data::NetworkEvent(d) => {
warn!("{room_name}: network event ({})", d.r#type);
}
Data::NickEvent(d) => {
debug!("{room_name}: {:?} renamed to {:?}", d.from, d.to);
}
Data::EditMessageEvent(_) => {
info!("{room_name}: a message was edited");
}
Data::PartEvent(d) => {
debug!("{room_name}: {:?} left", d.0.name);
}
Data::PingEvent(_) => {}
Data::PmInitiateEvent(d) => {
// TODO Show info popup and automatically join PM room
info!(
"{room_name}: {:?} initiated a pm from &{}",
d.from_nick, d.from_room
);
}
Data::SendEvent(SendEvent(msg)) | Data::SendReply(SendReply(msg)) => {
let own_user_id = self.own_user_id();
if let Some(last_msg_id) = &mut self.last_msg_id {
logging_unwrap!(
self.vault
.add_msg(Box::new(msg.clone()), *last_msg_id, own_user_id)
.await
);
*last_msg_id = Some(msg.id);
}
}
Data::SnapshotEvent(d) => {
info!("{room_name}: successfully joined");
logging_unwrap!(self.vault.join(Time::now()).await);
self.last_msg_id = Some(d.log.last().map(|m| m.id));
logging_unwrap!(
self.vault
.add_msgs(d.log.clone(), None, self.own_user_id())
.await
);
}
Data::LogReply(d) => {
logging_unwrap!(
self.vault
.add_msgs(d.log.clone(), d.before, self.own_user_id())
.await
);
}
_ => {}
}
}
pub fn auth(&self, password: String) -> Result<(), Error> {
self.conn_tx()?.send_only(Auth {
r#type: AuthOption::Passcode,
passcode: Some(password),
});
Ok(())
}
pub fn log(&self) -> Result<(), Error> {
let conn_tx_clone = self.conn_tx()?.clone();
let vault_clone = self.vault.clone();
tokio::task::spawn(async move { Self::request_logs(&vault_clone, &conn_tx_clone).await });
Ok(())
}
pub fn nick(&self, name: String) -> Result<(), Error> {
self.conn_tx()?.send_only(Nick { name });
Ok(())
}
pub fn send(
&self,
parent: Option<MessageId>,
content: String,
) -> Result<oneshot::Receiver<MessageId>, Error> {
let reply = self.conn_tx()?.send(Send { content, parent });
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
if let Ok(reply) = reply.await {
let _ = tx.send(reply.0.id);
}
});
Ok(rx)
}
pub fn login(&self, email: String, password: String) -> Result<(), Error> {
self.conn_tx()?.send_only(Login {
namespace: "email".to_string(),
id: email,
password,
});
Ok(())
}
pub fn logout(&self) -> Result<(), Error> {
self.conn_tx()?.send_only(Logout {});
Ok(())
}
}

View 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))
}
}
}

96
cove/src/euph/util.rs Normal file
View file

@ -0,0 +1,96 @@
use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
sync::LazyLock,
};
use crossterm::style::{Color, Stylize};
use euphoxide::{Emoji, api::UserId};
use toss::{Style, Styled};
pub static EMOJI: LazyLock<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].
///
/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`.
///
/// [1]: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
assert!((0.0..=360.0).contains(&h), "h must be in range [0, 360]");
assert!((0.0..=1.0).contains(&s), "s must be in range [0, 1]");
assert!((0.0..=1.0).contains(&l), "l must be in range [0, 1]");
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h / 60.0;
let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = match () {
_ if h_prime < 1.0 => (c, x, 0.0),
_ if h_prime < 2.0 => (x, c, 0.0),
_ if h_prime < 3.0 => (0.0, c, x),
_ if h_prime < 4.0 => (0.0, x, c),
_ if h_prime < 5.0 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let (r, g, b) = (r1 + m, g1 + m, b1 + m);
// The rgb values in the range [0,1] are each split into 256 segments of the
// same length, which are then assigned to the 256 possible values of an u8.
((r * 256.0) as u8, (g * 256.0) as u8, (b * 256.0) as u8)
}
pub fn nick_color(nick: &str) -> (u8, u8, u8) {
let hue = euphoxide::nick::hue(&EMOJI, nick) as f32;
hsl_to_rgb(hue, 1.0, 0.72)
}
pub fn nick_style(nick: &str, base: Style) -> Style {
let (r, g, b) = nick_color(nick);
base.bold().with(Color::Rgb { r, g, b })
}
pub fn style_nick(nick: &str, base: Style) -> Styled {
Styled::new(EMOJI.replace(nick), nick_style(nick, base))
}
pub fn style_nick_exact(nick: &str, base: Style) -> Styled {
Styled::new(nick, nick_style(nick, base))
}
pub fn style_mention(mention: &str, base: Style) -> Styled {
let nick = mention
.strip_prefix('@')
.expect("mention must start with @");
Styled::new(EMOJI.replace(mention), nick_style(nick, base))
}
pub fn style_mention_exact(mention: &str, base: Style) -> Styled {
let nick = mention
.strip_prefix('@')
.expect("mention must start with @");
Styled::new(mention, nick_style(nick, base))
}
pub fn user_id_emoji(user_id: &UserId) -> String {
let mut hasher = DefaultHasher::new();
user_id.0.hash(&mut hasher);
let hash = hasher.finish();
let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()];
emoji.clone()
}

158
cove/src/export.rs Normal file
View file

@ -0,0 +1,158 @@
//! Export logs from the vault to plain text files.
use std::{
fs::File,
io::{self, BufWriter, Write},
};
use crate::vault::{EuphRoomVault, EuphVault, RoomIdentifier};
mod json;
mod text;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum Format {
/// Human-readable tree-structured messages.
Text,
/// Array of message objects in the same format as the euphoria API uses.
Json,
/// Message objects in the same format as the euphoria API uses, one per
/// line (https://jsonlines.org/).
JsonLines,
}
impl Format {
fn name(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
Self::JsonLines => "json lines",
}
}
fn extension(&self) -> &'static str {
match self {
Self::Text => "txt",
Self::Json => "json",
Self::JsonLines => "jsonl",
}
}
}
#[derive(Debug, clap::Parser)]
pub struct Args {
rooms: Vec<String>,
/// Export all rooms.
#[arg(long, short)]
all: bool,
/// Domain to resolve the room names with.
#[arg(long, short, default_value = "euphoria.leet.nu")]
domain: String,
/// Format of the output file.
#[arg(long, short, value_enum, default_value_t = Format::Text)]
format: Format,
/// Location of the output file
///
/// May include the following placeholders:
/// `%r` - room name
/// `%e` - format extension
/// A literal `%` can be written as `%%`.
///
/// If the value ends with a `/`, it is assumed to point to a directory and
/// `%r.%e` will be appended.
///
/// If the value is a literal `-`, the export will be written to stdout. To
/// write to a file named `-`, you can use `./-`.
///
/// Must be a valid utf-8 encoded string.
#[arg(long, short, default_value_t = Into::into("%r.%e"))]
#[arg(verbatim_doc_comment)]
out: String,
}
async fn export_room<W: Write>(
vault: &EuphRoomVault,
out: &mut W,
format: Format,
) -> anyhow::Result<()> {
match format {
Format::Text => text::export(vault, out).await?,
Format::Json => json::export(vault, out).await?,
Format::JsonLines => json::export_lines(vault, out).await?,
}
Ok(())
}
pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
if args.out.ends_with('/') {
args.out.push_str("%r.%e");
}
let rooms = if args.all {
let mut rooms = vault
.rooms()
.await?
.into_iter()
.map(|id| id.name)
.collect::<Vec<_>>();
rooms.sort_unstable();
rooms
} else {
let mut rooms = args.rooms.clone();
rooms.dedup();
rooms
};
if rooms.is_empty() {
eprintln!("No rooms to export");
}
for room in rooms {
if args.out == "-" {
eprintln!("Exporting &{room} as {} to stdout", args.format.name());
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
let mut stdout = BufWriter::new(io::stdout());
export_room(&vault, &mut stdout, args.format).await?;
stdout.flush()?;
} else {
let out = format_out(&args.out, &room, args.format);
eprintln!("Exporting &{room} as {} to {out}", args.format.name());
let vault = vault.room(RoomIdentifier::new(args.domain.clone(), room));
let mut file = BufWriter::new(File::create(out)?);
export_room(&vault, &mut file, args.format).await?;
file.flush()?;
}
}
Ok(())
}
fn format_out(out: &str, room: &str, format: Format) -> String {
let mut result = String::new();
let mut special = false;
for char in out.chars() {
if special {
match char {
'r' => result.push_str(room),
'e' => result.push_str(format.extension()),
'%' => result.push('%'),
_ => {
result.push('%');
result.push(char);
}
}
special = false;
} else if char == '%' {
special = true;
} else {
result.push(char);
}
}
result
}

63
cove/src/export/json.rs Normal file
View file

@ -0,0 +1,63 @@
use std::io::Write;
use crate::vault::EuphRoomVault;
const CHUNK_SIZE: usize = 10000;
pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
write!(file, "[")?;
let mut total = 0;
let mut last_msg_id = None;
loop {
let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?;
last_msg_id = Some(match messages.last() {
Some(last_msg) => last_msg.id,
None => break, // No more messages, export finished
});
for message in messages {
if total == 0 {
writeln!(file)?;
} else {
writeln!(file, ",")?;
}
serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D
total += 1;
}
if total % 100000 == 0 {
eprintln!(" {total} messages");
}
}
write!(file, "\n]")?;
eprintln!(" {total} messages in total");
Ok(())
}
pub async fn export_lines<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
let mut total = 0;
let mut last_msg_id = None;
loop {
let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?;
last_msg_id = Some(match messages.last() {
Some(last_msg) => last_msg.id,
None => break, // No more messages, export finished
});
for message in messages {
serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D
writeln!(file)?;
total += 1;
}
if total % 100000 == 0 {
eprintln!(" {total} messages");
}
}
eprintln!(" {total} messages in total");
Ok(())
}

78
cove/src/export/text.rs Normal file
View file

@ -0,0 +1,78 @@
use std::io::Write;
use euphoxide::api::MessageId;
use unicode_width::UnicodeWidthStr;
use crate::{euph::SmallMessage, store::Tree, vault::EuphRoomVault};
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
const TIME_EMPTY: &str = " ";
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
let mut exported_trees = 0;
let mut exported_msgs = 0;
let mut root_id = vault.first_root_id().await?;
while let Some(some_root_id) = root_id {
let tree = vault.tree(some_root_id).await?;
write_tree(out, &tree, some_root_id, 0)?;
root_id = vault.next_root_id(some_root_id).await?;
exported_trees += 1;
exported_msgs += tree.len();
if exported_trees % 10000 == 0 {
eprintln!(" {exported_trees} trees, {exported_msgs} messages")
}
}
eprintln!(" {exported_trees} trees, {exported_msgs} messages in total");
Ok(())
}
fn write_tree<W: Write>(
out: &mut W,
tree: &Tree<SmallMessage>,
id: MessageId,
indent: usize,
) -> anyhow::Result<()> {
let indent_string = "| ".repeat(indent);
if let Some(msg) = tree.msg(&id) {
write_msg(out, &indent_string, msg)?;
} else {
write_placeholder(out, &indent_string)?;
}
if let Some(children) = tree.children(&id) {
for child in children {
write_tree(out, tree, *child, indent + 1)?;
}
}
Ok(())
}
fn write_msg<W: Write>(
file: &mut W,
indent_string: &str,
msg: &SmallMessage,
) -> anyhow::Result<()> {
let nick = &msg.nick;
let nick_empty = " ".repeat(nick.width());
for (i, line) in msg.content.lines().enumerate() {
if i == 0 {
let time = msg.time.as_timestamp().strftime(TIME_FORMAT);
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
} else {
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
}
}
Ok(())
}
fn write_placeholder<W: Write>(file: &mut W, indent_string: &str) -> anyhow::Result<()> {
writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?;
Ok(())
}

245
cove/src/logger.rs Normal file
View file

@ -0,0 +1,245 @@
use std::{convert::Infallible, sync::Arc, vec};
use async_trait::async_trait;
use crossterm::style::Stylize;
use jiff::Timestamp;
use log::{Level, LevelFilter, Log};
use parking_lot::Mutex;
use tokio::sync::mpsc;
use toss::{Style, Styled};
use crate::{
store::{Msg, MsgStore, Path, Tree},
ui::ChatMsg,
};
#[derive(Debug, Clone)]
pub struct LogMsg {
id: usize,
time: Timestamp,
level: Level,
content: String,
}
impl Msg for LogMsg {
type Id = usize;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
None
}
fn seen(&self) -> bool {
true
}
fn last_possible_id() -> Self::Id {
Self::Id::MAX
}
}
impl ChatMsg for LogMsg {
fn time(&self) -> Option<Timestamp> {
Some(self.time)
}
fn styled(&self) -> (Styled, Styled) {
let nick_style = match self.level {
Level::Error => Style::new().bold().red(),
Level::Warn => Style::new().bold().yellow(),
Level::Info => Style::new().bold().green(),
Level::Debug => Style::new().bold().blue(),
Level::Trace => Style::new().bold().magenta(),
};
let nick = Styled::new(format!("{}", self.level), nick_style);
let content = Styled::new_plain(&self.content);
(nick, content)
}
fn edit(_nick: &str, _content: &str) -> (Styled, Styled) {
panic!("log is not editable")
}
fn pseudo(_nick: &str, _content: &str) -> (Styled, Styled) {
panic!("log is not editable")
}
}
/// Prints all error messages when dropped.
pub struct LoggerGuard {
messages: Arc<Mutex<Vec<LogMsg>>>,
}
impl Drop for LoggerGuard {
fn drop(&mut self) {
let guard = self.messages.lock();
let mut error_encountered = false;
for msg in &*guard {
if msg.level == Level::Error {
if !error_encountered {
eprintln!();
eprintln!("The following errors occurred while cove was running:");
}
error_encountered = true;
eprintln!("{}", msg.content);
}
}
if error_encountered {
eprintln!();
}
}
}
#[derive(Debug, Clone)]
pub struct Logger {
event_tx: mpsc::UnboundedSender<()>,
messages: Arc<Mutex<Vec<LogMsg>>>,
}
#[async_trait]
impl MsgStore<LogMsg> for Logger {
type Error = Infallible;
async fn path(&self, id: &usize) -> Result<Path<usize>, Self::Error> {
Ok(Path::new(vec![*id]))
}
async fn msg(&self, id: &usize) -> Result<Option<LogMsg>, Self::Error> {
Ok(self.messages.lock().get(*id).cloned())
}
async fn tree(&self, root_id: &usize) -> Result<Tree<LogMsg>, Self::Error> {
let msgs = self
.messages
.lock()
.get(*root_id)
.map(|msg| vec![msg.clone()])
.unwrap_or_default();
Ok(Tree::new(*root_id, msgs))
}
async fn first_root_id(&self) -> Result<Option<usize>, Self::Error> {
let empty = self.messages.lock().is_empty();
Ok(Some(0).filter(|_| !empty))
}
async fn last_root_id(&self) -> Result<Option<usize>, Self::Error> {
Ok(self.messages.lock().len().checked_sub(1))
}
async fn prev_root_id(&self, root_id: &usize) -> Result<Option<usize>, Self::Error> {
Ok(root_id.checked_sub(1))
}
async fn next_root_id(&self, root_id: &usize) -> Result<Option<usize>, Self::Error> {
let len = self.messages.lock().len();
Ok(root_id.checked_add(1).filter(|t| *t < len))
}
async fn oldest_msg_id(&self) -> Result<Option<usize>, Self::Error> {
self.first_root_id().await
}
async fn newest_msg_id(&self) -> Result<Option<usize>, Self::Error> {
self.last_root_id().await
}
async fn older_msg_id(&self, id: &usize) -> Result<Option<usize>, Self::Error> {
self.prev_root_id(id).await
}
async fn newer_msg_id(&self, id: &usize) -> Result<Option<usize>, Self::Error> {
self.next_root_id(id).await
}
async fn oldest_unseen_msg_id(&self) -> Result<Option<usize>, Self::Error> {
Ok(None)
}
async fn newest_unseen_msg_id(&self) -> Result<Option<usize>, Self::Error> {
Ok(None)
}
async fn older_unseen_msg_id(&self, _id: &usize) -> Result<Option<usize>, Self::Error> {
Ok(None)
}
async fn newer_unseen_msg_id(&self, _id: &usize) -> Result<Option<usize>, Self::Error> {
Ok(None)
}
async fn unseen_msgs_count(&self) -> Result<usize, Self::Error> {
Ok(0)
}
async fn set_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> {
Ok(())
}
async fn set_older_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> {
Ok(())
}
}
impl Log for Logger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
if metadata.level() <= Level::Info {
return true;
}
let target = metadata.target();
if target.starts_with("cove")
|| target.starts_with("euphoxide::bot")
|| target.starts_with("euphoxide::live")
{
return true;
}
false
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let mut guard = self.messages.lock();
let msg = LogMsg {
id: guard.len(),
time: Timestamp::now(),
level: record.level(),
content: format!("<{}> {}", record.target(), record.args()),
};
guard.push(msg);
let _ = self.event_tx.send(());
}
fn flush(&self) {}
}
impl Logger {
pub fn init(verbose: bool) -> (Self, LoggerGuard, mpsc::UnboundedReceiver<()>) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let logger = Self {
event_tx,
messages: Arc::new(Mutex::new(Vec::new())),
};
let guard = LoggerGuard {
messages: logger.messages.clone(),
};
log::set_max_level(if verbose {
LevelFilter::Debug
} else {
LevelFilter::Info
});
log::set_boxed_logger(Box::new(logger.clone())).expect("logger already set");
(logger, guard, event_rx)
}
}

12
cove/src/macros.rs Normal file
View 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
View 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());
}

View file

@ -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;
@ -9,6 +6,11 @@ pub trait Msg {
type Id: Clone + Debug + Hash + Eq + Ord;
fn id(&self) -> Self::Id;
fn parent(&self) -> Option<Self::Id>;
fn seen(&self) -> bool;
fn nick_emoji(&self) -> Option<String> {
None
}
fn last_possible_id() -> Self::Id;
}
@ -22,12 +24,16 @@ impl<I> Path<I> {
Self(segments)
}
pub fn push(&mut self, segment: I) {
self.0.push(segment)
pub fn parent_segments(&self) -> impl Iterator<Item = &I> {
self.0.iter().take(self.0.len() - 1)
}
pub fn first(&self) -> &I {
self.0.first().expect("path is not empty")
self.0.first().expect("path is empty")
}
pub fn into_first(self) -> I {
self.0.into_iter().next().expect("path is empty")
}
}
@ -89,6 +95,15 @@ impl<M: Msg> Tree<M> {
self.children.get(id).map(|c| c as &[M::Id])
}
pub fn subtree_size(&self, id: &M::Id) -> usize {
let children = self.children(id).unwrap_or_default();
let mut result = children.len();
for child in children {
result += self.subtree_size(child);
}
result
}
pub fn siblings(&self, id: &M::Id) -> Option<&[M::Id]> {
if let Some(parent) = self.parent(id) {
self.children(&parent)
@ -116,16 +131,26 @@ impl<M: Msg> Tree<M> {
}
}
#[allow(dead_code)]
#[async_trait]
pub trait MsgStore<M: Msg> {
async fn path(&self, id: &M::Id) -> Path<M::Id>;
async fn tree(&self, tree_id: &M::Id) -> Tree<M>;
async fn first_tree_id(&self) -> Option<M::Id>;
async fn last_tree_id(&self) -> Option<M::Id>;
async fn prev_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>;
async fn next_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>;
async fn oldest_msg_id(&self) -> Option<M::Id>;
async fn newest_msg_id(&self) -> Option<M::Id>;
async fn older_msg_id(&self, id: &M::Id) -> Option<M::Id>;
async fn newer_msg_id(&self, id: &M::Id) -> Option<M::Id>;
type Error;
async fn path(&self, id: &M::Id) -> Result<Path<M::Id>, Self::Error>;
async fn msg(&self, id: &M::Id) -> Result<Option<M>, Self::Error>;
async fn tree(&self, root_id: &M::Id) -> Result<Tree<M>, Self::Error>;
async fn first_root_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn last_root_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn prev_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn next_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn oldest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn newest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn older_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn newer_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn oldest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn newest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
async fn older_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn newer_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
async fn unseen_msgs_count(&self) -> Result<usize, Self::Error>;
async fn set_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>;
async fn set_older_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>;
}

311
cove/src/ui.rs Normal file
View file

@ -0,0 +1,311 @@
use std::{
convert::Infallible,
io,
sync::{Arc, Weak},
time::{Duration, Instant},
};
use cove_config::Config;
use cove_input::InputEvent;
use jiff::tz::TimeZone;
use parking_lot::FairMutex;
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender, error::TryRecvError},
task,
};
use toss::{Terminal, WidgetExt, widgets::BoxedAsync};
use crate::{
logger::{LogMsg, Logger},
macros::logging_unwrap,
util::InfallibleExt,
vault::Vault,
};
pub use self::chat::ChatMsg;
use self::{chat::ChatState, rooms::Rooms, widgets::ListState};
mod chat;
mod euph;
mod key_bindings;
mod rooms;
mod util;
mod widgets;
/// Time to spend batch processing events before redrawing the screen.
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
/// Error for anything that can go wrong while rendering.
#[derive(Debug, thiserror::Error)]
pub enum UiError {
#[error("{0}")]
Vault(#[from] vault::tokio::Error<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,
Term(crossterm::event::Event),
Euph(euphoxide::bot::instance::Event),
}
enum EventHandleResult {
Redraw,
Continue,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Main,
Log,
}
pub struct Ui {
config: &'static Config,
event_tx: UnboundedSender<UiEvent>,
mode: Mode,
rooms: Rooms,
log_chat: ChatState<LogMsg, Logger>,
key_bindings_visible: bool,
key_bindings_list: ListState<Infallible>,
}
impl Ui {
const POLL_DURATION: Duration = Duration::from_millis(100);
pub async fn run(
config: &'static Config,
tz: TimeZone,
terminal: &mut Terminal,
vault: Vault,
logger: Logger,
logger_rx: UnboundedReceiver<()>,
) -> anyhow::Result<()> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let crossterm_lock = Arc::new(FairMutex::new(()));
// Prepare and start crossterm event polling task
let weak_crossterm_lock = Arc::downgrade(&crossterm_lock);
let event_tx_clone = event_tx.clone();
let crossterm_event_task = task::spawn_blocking(|| {
Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock)
});
// Run main UI.
//
// If the run_main method exits at any point or if this `run` method is
// not awaited any more, the crossterm_lock Arc should be deallocated,
// meaning the crossterm_event_task will also stop after at most
// `Self::POLL_DURATION`.
//
// On the other hand, if the crossterm_event_task stops for any reason,
// the rest of the UI is also shut down and the client stops.
let mut ui = Self {
config,
event_tx: event_tx.clone(),
mode: Mode::Main,
rooms: Rooms::new(config, tz.clone(), vault, event_tx.clone()).await,
log_chat: ChatState::new(logger, tz),
key_bindings_visible: false,
key_bindings_list: ListState::new(),
};
tokio::select! {
e = ui.run_main(terminal, event_rx, crossterm_lock) => e?,
_ = Self::update_on_log_event(logger_rx, &event_tx) => (),
e = crossterm_event_task => e??,
}
Ok(())
}
fn poll_crossterm_events(
tx: UnboundedSender<UiEvent>,
lock: Weak<FairMutex<()>>,
) -> io::Result<()> {
loop {
let Some(lock) = lock.upgrade() else {
return Ok(());
};
let _guard = lock.lock();
if crossterm::event::poll(Self::POLL_DURATION)? {
let event = crossterm::event::read()?;
if tx.send(UiEvent::Term(event)).is_err() {
return Ok(());
}
}
}
}
async fn update_on_log_event(
mut logger_rx: UnboundedReceiver<()>,
event_tx: &UnboundedSender<UiEvent>,
) {
loop {
if logger_rx.recv().await.is_none() {
return;
}
if event_tx.send(UiEvent::LogChanged).is_err() {
return;
}
}
}
async fn run_main(
&mut self,
terminal: &mut Terminal,
mut event_rx: UnboundedReceiver<UiEvent>,
crossterm_lock: Arc<FairMutex<()>>,
) -> Result<(), UiError> {
let mut redraw = true;
loop {
// Redraw if necessary
if redraw {
redraw = false;
terminal.present_async_widget(self.widget().await).await?;
if terminal.measuring_required() {
let _guard = crossterm_lock.lock();
terminal.measure_widths()?;
if self.event_tx.send(UiEvent::GraphemeWidthsChanged).is_err() {
return Ok(());
}
}
}
// Handle events (in batches)
let Some(mut event) = event_rx.recv().await else {
return Ok(());
};
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
loop {
match self.handle_event(terminal, &crossterm_lock, event).await {
EventHandleResult::Redraw => redraw = true,
EventHandleResult::Continue => {}
EventHandleResult::Stop => return Ok(()),
}
if Instant::now() >= end_time {
break;
}
event = match event_rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return Ok(()),
};
}
}
}
async fn widget(&mut self) -> BoxedAsync<'_, UiError> {
let widget = match self.mode {
Mode::Main => self.rooms.widget().await,
Mode::Log => self.log_chat.widget(String::new(), true),
};
if self.key_bindings_visible {
let popup = key_bindings::widget(&mut self.key_bindings_list, self.config);
popup.desync().above(widget).boxed_async()
} else {
widget
}
}
async fn handle_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: UiEvent,
) -> EventHandleResult {
match event {
UiEvent::GraphemeWidthsChanged => EventHandleResult::Redraw,
UiEvent::LogChanged if self.mode == Mode::Log => EventHandleResult::Redraw,
UiEvent::LogChanged => EventHandleResult::Continue,
UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw,
UiEvent::Term(event) => {
self.handle_term_event(terminal, crossterm_lock.clone(), event)
.await
}
UiEvent::Euph(event) => {
if self.rooms.handle_euph_event(event).await {
EventHandleResult::Redraw
} else {
EventHandleResult::Continue
}
}
}
}
async fn handle_term_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: Arc<FairMutex<()>>,
event: crossterm::event::Event,
) -> EventHandleResult {
let mut event = InputEvent::new(event, terminal, crossterm_lock);
let keys = &self.config.keys;
if event.matches(&keys.general.exit) {
return EventHandleResult::Stop;
}
// Key bindings list overrides any other bindings if visible
if self.key_bindings_visible {
if event.matches(&keys.general.abort) || event.matches(&keys.general.help) {
self.key_bindings_visible = false;
return EventHandleResult::Redraw;
}
if key_bindings::handle_input_event(&mut self.key_bindings_list, &mut event, keys) {
return EventHandleResult::Redraw;
}
// ... and does not let anything below the popup receive events
return EventHandleResult::Continue;
}
if event.matches(&keys.general.help) {
self.key_bindings_visible = true;
return EventHandleResult::Redraw;
}
match self.mode {
Mode::Main => {
if event.matches(&keys.general.log) {
self.mode = Mode::Log;
return EventHandleResult::Redraw;
}
if self.rooms.handle_input_event(&mut event, keys).await {
return EventHandleResult::Redraw;
}
}
Mode::Log => {
if event.matches(&keys.general.abort) || event.matches(&keys.general.log) {
self.mode = Mode::Main;
return EventHandleResult::Redraw;
}
let reaction = self
.log_chat
.handle_input_event(&mut event, keys, false)
.await;
let reaction = logging_unwrap!(reaction);
if reaction.handled() {
return EventHandleResult::Redraw;
}
}
}
EventHandleResult::Continue
}
}

186
cove/src/ui/chat.rs Normal file
View 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
View 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
View 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,
})
}
}

View 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
View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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
View 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(())
}
}

8
cove/src/ui/euph.rs Normal file
View file

@ -0,0 +1,8 @@
mod account;
mod auth;
mod inspect;
mod links;
mod nick;
mod nick_list;
mod popup;
pub mod room;

195
cove/src/ui/euph/account.rs Normal file
View 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
View 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
}

134
cove/src/ui/euph/inspect.rs Normal file
View file

@ -0,0 +1,134 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize;
use euphoxide::{
api::{Message, NickEvent, SessionView},
conn::SessionInfo,
};
use toss::{Style, Styled, Widget, widgets::Text};
use crate::ui::{UiError, widgets::Popup};
use super::popup::PopupResult;
macro_rules! line {
( $text:ident, $name:expr, $val:expr ) => {
$text = $text
.then($name, Style::new().cyan())
.then_plain(format!(" {}\n", $val));
};
( $text:ident, $name:expr, $val:expr, debug ) => {
$text = $text
.then($name, Style::new().cyan())
.then_plain(format!(" {:?}\n", $val));
};
( $text:ident, $name:expr, $val:expr, optional ) => {
if let Some(val) = $val {
$text = $text
.then($name, Style::new().cyan())
.then_plain(format!(" {val}\n"));
} else {
$text = $text
.then($name, Style::new().cyan())
.then_plain(" ")
.then("none", Style::new().italic().grey())
.then_plain("\n");
}
};
( $text:ident, $name:expr, $val:expr, yes or no ) => {
$text = $text.then($name, Style::new().cyan()).then_plain(if $val {
" yes\n"
} else {
" no\n"
});
};
}
fn session_view_lines(mut text: Styled, session: &SessionView) -> Styled {
line!(text, "id", session.id);
line!(text, "name", session.name);
line!(text, "name (raw)", session.name, debug);
line!(text, "server_id", session.server_id);
line!(text, "server_era", session.server_era);
line!(text, "session_id", session.session_id.0);
line!(text, "is_staff", session.is_staff, yes or no);
line!(text, "is_manager", session.is_manager, yes or no);
line!(
text,
"client_address",
session.client_address.as_ref(),
optional
);
line!(
text,
"real_client_address",
session.real_client_address.as_ref(),
optional
);
text
}
fn nick_event_lines(mut text: Styled, event: &NickEvent) -> Styled {
line!(text, "id", event.id);
line!(text, "name", event.to);
line!(text, "name (raw)", event.to, debug);
line!(text, "session_id", event.session_id.0);
text
}
fn message_lines(mut text: Styled, msg: &Message) -> Styled {
line!(text, "id", msg.id.0);
line!(text, "parent", msg.parent.map(|p| p.0), optional);
line!(text, "previous_edit_id", msg.previous_edit_id, optional);
line!(text, "time", msg.time.0);
line!(text, "encryption_key_id", &msg.encryption_key_id, optional);
line!(text, "edited", msg.edited.map(|t| t.0), optional);
line!(text, "deleted", msg.deleted.map(|t| t.0), optional);
line!(text, "truncated", msg.truncated, yes or no);
text
}
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> + use<> {
let heading_style = Style::new().bold();
let text = match session {
SessionInfo::Full(session) => {
let text = Styled::new("Full session", heading_style).then_plain("\n");
session_view_lines(text, session)
}
SessionInfo::Partial(event) => {
let text = Styled::new("Partial session", heading_style).then_plain("\n");
nick_event_lines(text, event)
}
};
Popup::new(Text::new(text), "Inspect session")
}
pub fn message_widget(msg: &Message) -> impl Widget<UiError> + use<> {
let heading_style = Style::new().bold();
let mut text = Styled::new("Message", heading_style).then_plain("\n");
text = message_lines(text, msg);
text = text
.then_plain("\n")
.then("Sender", heading_style)
.then_plain("\n");
text = session_view_lines(text, &msg.sender);
Popup::new(Text::new(text), "Inspect message")
}
pub fn handle_input_event(event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult {
if event.matches(&keys.general.abort) {
return PopupResult::Close;
}
PopupResult::NotHandled
}

192
cove/src/ui/euph/links.rs Normal file
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
}
}

View 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")
}
}

View 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
View 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
View 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
View 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(())
}
}

View 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
View 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()
}

79
cove/src/vault.rs Normal file
View file

@ -0,0 +1,79 @@
use std::{fs, path::Path};
use rusqlite::Connection;
use vault::{Action, tokio::TokioVault};
pub use self::euph::{EuphRoomVault, EuphVault, RoomIdentifier};
mod euph;
mod migrate;
mod prepare;
#[derive(Debug, Clone)]
pub struct Vault {
tokio_vault: TokioVault,
ephemeral: bool,
}
struct GcAction;
impl Action for GcAction {
type Output = ();
type Error = rusqlite::Error;
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
conn.execute_batch("ANALYZE; VACUUM;")
}
}
impl Vault {
pub fn ephemeral(&self) -> bool {
self.ephemeral
}
pub async fn close(&self) {
self.tokio_vault.stop().await;
}
pub async fn gc(&self) -> Result<(), vault::tokio::Error<rusqlite::Error>> {
self.tokio_vault.execute(GcAction).await
}
pub fn euph(&self) -> EuphVault {
EuphVault::new(self.clone())
}
}
fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result<Vault> {
conn.pragma_update(None, "foreign_keys", true)?;
conn.pragma_update(None, "trusted_schema", false)?;
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
Ok(Vault {
tokio_vault,
ephemeral,
})
}
pub fn launch(path: &Path) -> rusqlite::Result<Vault> {
// If this fails, rusqlite will complain about not being able to open the db
// file, which saves me from adding a separate vault error type.
let _ = fs::create_dir_all(path.parent().expect("path to file"));
let conn = Connection::open(path)?;
// Setting locking mode before journal mode so no shared memory files
// (*-shm) need to be created by sqlite. Apparently, setting the journal
// mode is also enough to immediately acquire the exclusive lock even if the
// database was already using WAL.
// https://sqlite.org/pragma.html#pragma_locking_mode
conn.pragma_update(None, "locking_mode", "exclusive")?;
conn.pragma_update(None, "journal_mode", "wal")?;
launch_from_connection(conn, false)
}
pub fn launch_in_memory() -> rusqlite::Result<Vault> {
let conn = Connection::open_in_memory()?;
launch_from_connection(conn, true)
}

1243
cove/src/vault/euph.rs Normal file

File diff suppressed because it is too large Load diff

224
cove/src/vault/migrate.rs Normal file
View 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(())
}

133
cove/src/vault/prepare.rs Normal file
View file

@ -0,0 +1,133 @@
use rusqlite::Connection;
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
eprintln!("Preparing vault");
// Cache ids of tree roots.
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_trees (
domain TEXT NOT NULL,
room TEXT NOT NULL,
id INT NOT NULL,
PRIMARY KEY (domain, room, id)
) STRICT;
INSERT INTO euph_trees (domain, room, id)
SELECT domain, room, id
FROM euph_msgs
WHERE parent IS NULL
UNION
SELECT domain, room, parent
FROM euph_msgs
WHERE parent IS NOT NULL
AND NOT EXISTS(
SELECT *
FROM euph_msgs AS parents
WHERE parents.domain = euph_msgs.domain
AND parents.room = euph_msgs.room
AND parents.id = euph_msgs.parent
);
CREATE TEMPORARY TRIGGER et_delete_room
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_trees
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 (domain, room, id)
VALUES (new.domain, new.room, new.id);
END;
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
AFTER INSERT ON main.euph_msgs
WHEN new.parent IS NOT NULL
BEGIN
DELETE FROM euph_trees
WHERE domain = new.domain
AND room = new.room
AND id = new.id;
INSERT OR IGNORE INTO euph_trees (domain, room, id)
SELECT *
FROM (VALUES (new.domain, new.room, new.parent))
WHERE NOT EXISTS(
SELECT *
FROM euph_msgs
WHERE domain = new.domain
AND room = new.room
AND id = new.parent
AND parent IS NOT NULL
);
END;
",
)?;
// Cache amount of unseen messages per room.
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_unseen_counts (
domain TEXT NOT NULL,
room TEXT NOT NULL,
amount INTEGER NOT NULL,
PRIMARY KEY (domain, room)
) STRICT;
-- There must be an entry for every existing room.
INSERT INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, 0
FROM euph_rooms;
INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, COUNT(*)
FROM euph_msgs
WHERE NOT seen
GROUP BY domain, room;
CREATE TEMPORARY TRIGGER euc_insert_room
AFTER INSERT ON main.euph_rooms
BEGIN
INSERT INTO euph_unseen_counts (domain, room, amount)
VALUES (new.domain, new.room, 0);
END;
CREATE TEMPORARY TRIGGER euc_delete_room
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_unseen_counts
WHERE domain = old.domain
AND room = old.room;
END;
CREATE TEMPORARY TRIGGER euc_insert_msg
AFTER INSERT ON main.euph_msgs
WHEN NOT new.seen
BEGIN
UPDATE euph_unseen_counts
SET amount = amount + 1
WHERE domain = new.domain
AND room = new.room;
END;
CREATE TEMPORARY TRIGGER euc_update_msg
AFTER UPDATE OF seen ON main.euph_msgs
WHEN old.seen != new.seen
BEGIN
UPDATE euph_unseen_counts
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
WHERE domain = new.domain
AND room = new.room;
END;
",
)?;
Ok(())
}

2
cove/src/version.rs Normal file
View file

@ -0,0 +1,2 @@
pub const NAME: &str = env!("CARGO_PKG_NAME");
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

View file

@ -1,10 +0,0 @@
pub mod api;
mod conn;
mod room;
mod small_message;
mod util;
pub use conn::{Joined, Joining, Status};
pub use room::Room;
pub use small_message::SmallMessage;
pub use util::{hue, nick_color, nick_style};

View file

@ -1,13 +0,0 @@
//! Models the euphoria API at <http://api.euphoria.io/>.
mod events;
pub mod packet;
mod room_cmds;
mod session_cmds;
mod types;
pub use events::*;
pub use packet::Data;
pub use room_cmds::*;
pub use session_cmds::*;
pub use types::*;

View file

@ -1,170 +0,0 @@
//! Asynchronous events.
use serde::{Deserialize, Serialize};
use super::{AuthOption, Message, PersonalAccountView, SessionView, Snowflake, Time, UserId};
/// Indicates that access to a room is denied.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BounceEvent {
/// The reason why access was denied.
pub reason: Option<String>,
/// Authentication options that may be used.
pub auth_options: Option<Vec<AuthOption>>,
/// Internal use only.
pub agent_id: Option<UserId>,
/// Internal use only.
pub ip: Option<String>,
}
/// Indicates that the session is being closed. The client will subsequently be
/// disconnected.
///
/// If the disconnect reason is `authentication changed`, the client should
/// immediately reconnect.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisconnectEvent {
/// The reason for disconnection.
pub reason: String,
}
/// Sent by the server to the client when a session is started.
///
/// It includes information about the client's authentication and associated
/// identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloEvent {
/// The id of the agent or account logged into this session.
pub id: UserId,
/// Details about the user's account, if the session is logged in.
pub account: Option<PersonalAccountView>,
/// Details about the session.
pub session: SessionView,
/// If true, then the account has an explicit access grant to the current
/// room.
pub account_has_access: Option<bool>,
/// Whether the account's email address has been verified.
pub account_email_verified: Option<bool>,
/// If true, the session is connected to a private room.
pub room_is_private: bool,
/// The version of the code being run and served by the server.
pub version: String,
}
/// Indicates a session just joined the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinEvent(pub SessionView);
/// Sent to all sessions of an agent when that agent is logged in (except for
/// the session that issued the login command).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginEvent {
pub account_id: Snowflake,
}
/// Sent to all sessions of an agent when that agent is logged out (except for
/// the session that issued the logout command).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutEvent;
/// Indicates some server-side event that impacts the presence of sessions in a
/// room.
///
/// If the network event type is `partition`, then this should be treated as a
/// [`PartEvent`] for all sessions connected to the same server id/era combo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkEvent {
/// The type of network event; for now, always `partition`.
pub r#type: String,
/// The id of the affected server.
pub server_id: String,
/// The era of the affected server.
pub server_era: String,
}
/// Announces a nick change by another session in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickEvent {
/// The id of the session this name applies to.
pub session_id: String,
/// The id of the agent or account logged into the session.
pub id: UserId,
/// The previous name associated with the session.
pub from: String,
/// The name associated with the session henceforth.
pub to: String,
}
/// Indicates that a message in the room has been modified or deleted.
///
/// If the client offers a user interface and the indicated message is currently
/// displayed, it should update its display accordingly.
///
/// The event packet includes a snapshot of the message post-edit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditMessageEvent {
/// The id of the edit.
pub edit_id: Snowflake,
/// The snapshot of the message post-edit.
#[serde(flatten)]
pub message: Message,
}
/// Indicates a session just disconnected from the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartEvent(pub SessionView);
/// Represents a server-to-client ping.
///
/// The client should send back a ping-reply with the same value for the time
/// field as soon as possible (or risk disconnection).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingEvent {
/// A unix timestamp according to the server's clock.
pub time: Time,
/// The expected time of the next ping event, according to the server's
/// clock.
pub next: Time,
}
/// Informs the client that another user wants to chat with them privately.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiateEvent {
/// The id of the user inviting the client to chat privately.
pub from: UserId,
/// The nick of the inviting user.
pub from_nick: String,
/// The room where the invitation was sent from.
pub from_room: String,
/// The private chat can be accessed at `/room/pm:<pm_id>`.
pub pm_id: Snowflake,
}
/// Indicates a message received by the room from another session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendEvent(pub Message);
/// Indicates that a session has successfully joined a room.
///
/// It also offers a snapshot of the rooms state and recent history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotEvent {
/// The id of the agent or account logged into this session.
pub identity: UserId,
/// The globally unique id of this session.
pub session_id: String,
/// The servers version identifier.
pub version: String,
/// The list of all other sessions joined to the room (excluding this
/// session).
pub listing: Vec<SessionView>,
/// The most recent messages posted to the room (currently up to 100).
pub log: Vec<Message>,
/// The acting nick of the session; if omitted, client set nick before
/// speaking.
pub nick: Option<String>,
/// If given, this room is for private chat with the given nick.
pub pm_with_nick: Option<String>,
/// If given, this room is for private chat with the given user.
pub pm_with_user_id: Option<String>,
}

View file

@ -1,192 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::PacketType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Packet {
pub id: Option<String>,
pub r#type: PacketType,
pub data: Option<Value>,
#[serde(skip_serializing)]
pub error: Option<String>,
#[serde(default, skip_serializing)]
pub throttled: bool,
#[serde(skip_serializing)]
pub throttled_reason: Option<String>,
}
pub trait Command {
type Reply;
}
macro_rules! packets {
( $( $name:ident, )*) => {
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Data {
$( $name(super::$name), )*
Unimplemented,
}
impl Data {
pub fn from_value(ptype: PacketType, value: Value) -> serde_json::Result<Self> {
Ok(match ptype {
$( PacketType::$name => Self::$name(serde_json::from_value(value)?), )*
_ => Self::Unimplemented,
})
}
pub fn into_value(self) -> serde_json::Result<Value> {
Ok(match self{
$( Self::$name(p) => serde_json::to_value(p)?, )*
Self::Unimplemented => panic!("using unimplemented data"),
})
}
pub fn packet_type(&self) -> PacketType {
match self {
$( Self::$name(_) => PacketType::$name, )*
Self::Unimplemented => panic!("using unimplemented data"),
}
}
}
$(
impl From<super::$name> for Data {
fn from(p: super::$name) -> Self {
Self::$name(p)
}
}
impl TryFrom<Data> for super::$name{
type Error = ();
fn try_from(value: Data) -> Result<Self, Self::Error> {
match value {
Data::$name(p) => Ok(p),
_ => Err(())
}
}
}
)*
};
}
macro_rules! commands {
( $( $cmd:ident => $rpl:ident, )* ) => {
$(
impl Command for super::$cmd {
type Reply = super::$rpl;
}
)*
};
}
packets! {
BounceEvent,
DisconnectEvent,
HelloEvent,
JoinEvent,
LoginEvent,
LogoutEvent,
NetworkEvent,
NickEvent,
EditMessageEvent,
PartEvent,
PingEvent,
PmInitiateEvent,
SendEvent,
SnapshotEvent,
Auth,
AuthReply,
Ping,
PingReply,
GetMessage,
GetMessageReply,
Log,
LogReply,
Nick,
NickReply,
PmInitiate,
PmInitiateReply,
Send,
SendReply,
Who,
WhoReply,
}
commands! {
Auth => AuthReply,
Ping => PingReply,
GetMessage => GetMessageReply,
Log => LogReply,
Nick => NickReply,
PmInitiate => PmInitiateReply,
Send => SendReply,
Who => WhoReply,
}
#[derive(Debug, Clone)]
pub struct ParsedPacket {
pub id: Option<String>,
pub r#type: PacketType,
pub content: Result<Data, String>,
pub throttled: Option<String>,
}
impl ParsedPacket {
pub fn from_packet(packet: Packet) -> serde_json::Result<Self> {
let id = packet.id;
let r#type = packet.r#type;
let content = if let Some(error) = packet.error {
Err(error)
} else {
let data = packet.data.unwrap_or_default();
Ok(Data::from_value(r#type, data)?)
};
let throttled = if packet.throttled {
let reason = packet
.throttled_reason
.unwrap_or_else(|| "no reason given".to_string());
Some(reason)
} else {
None
};
Ok(Self {
id,
r#type,
content,
throttled,
})
}
pub fn into_packet(self) -> serde_json::Result<Packet> {
let id = self.id;
let r#type = self.r#type;
let throttled = self.throttled.is_some();
let throttled_reason = self.throttled;
Ok(match self.content {
Ok(data) => Packet {
id,
r#type,
data: Some(data.into_value()?),
error: None,
throttled,
throttled_reason,
},
Err(error) => Packet {
id,
r#type,
data: None,
error: Some(error),
throttled,
throttled_reason,
},
})
}
}

View file

@ -1,116 +0,0 @@
//! Chat room commands.
use serde::{Deserialize, Serialize};
use super::{Message, SessionView, Snowflake, UserId};
/// Retrieve the full content of a single message in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetMessage {
/// The id of the message to retrieve.
pub id: Snowflake,
}
/// The message retrieved by [`GetMessage`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetMessageReply(pub Message);
/// Request messages from the room's message log.
///
/// This can be used to supplement the log provided by snapshot-event (for
/// example, when scrolling back further in history).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Log {
/// Maximum number of messages to return (up to 1000).
pub n: usize,
/// Return messages prior to this snowflake.
pub before: Option<Snowflake>,
}
/// List of messages from the room's message log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogReply {
/// List of messages returned.
pub log: Vec<Message>,
/// Messages prior to this snowflake were returned.
pub before: Option<Snowflake>,
}
/// Set the name you present to the room.
///
/// This name applies to all messages sent during this session, until the nick
/// command is called again.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nick {
/// The requested name (maximum length 36 bytes).
pub name: String,
}
/// Confirms the [`Nick`] command.
///
/// Returns the session's former and new names (the server may modify the
/// requested nick).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickReply {
/// The id of the session this name applies to.
pub session_id: String,
/// The id of the agent or account logged into the session.
pub id: UserId,
/// The previous name associated with the session.
pub from: String,
/// The name associated with the session henceforth.
pub to: String,
}
/// Constructs a virtual room for private messaging between the client and the
/// given [`UserId`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiate {
/// The id of the user to invite to chat privately.
pub user_id: UserId,
}
/// Provides the PMID for the requested private messaging room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiateReply {
/// The private chat can be accessed at `/room/pm:<pm_id>`.
pub pm_id: Snowflake,
/// The nickname of the recipient of the invitation.
pub to_nick: String,
}
/// Send a message to a room.
///
/// The session must be successfully joined with the room. This message will be
/// broadcast to all sessions joined with the room.
///
/// If the room is private, then the message content will be encrypted before it
/// is stored and broadcast to the rest of the room.
///
/// The caller of this command will not receive the corresponding
/// [`SendEvent`](super::SendEvent), but will receive the same information in
/// the [`SendReply`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Send {
/// The content of the message (client-defined).
pub content: String,
/// The id of the parent message, if any.
pub parent: Option<Snowflake>,
}
/// The message that was sent.
///
/// this includes the message id, which was populated by the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendReply(pub Message);
/// Request a list of sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Who;
/// Lists the sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhoReply {
/// A list of session views.
listing: Vec<SessionView>,
}

View file

@ -1,43 +0,0 @@
//! Session commands.
use serde::{Deserialize, Serialize};
use super::{AuthOption, Time};
/// Attempt to join a private room.
///
/// This should be sent in response to a bounce event at the beginning of a
/// session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Auth {
/// The method of authentication.
pub r#type: AuthOption,
/// Use this field for [`AuthOption::Passcode`] authentication.
pub passcode: Option<String>,
}
/// Reports whether the [`Auth`] command succeeded.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthReply {
/// True if authentication succeeded.
pub success: bool,
/// If [`Self::success`] was false, the reason for failure.
pub reason: Option<String>,
}
/// Initiate a client-to-server ping.
///
/// The server will send back a [`PingReply`] with the same timestamp as soon as
/// possible.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
/// An arbitrary value, intended to be a unix timestamp.
pub time: Time,
}
/// Response to a [`Ping`] command or [`PingEvent`](super::PingEvent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingReply {
/// The timestamp of the ping being replied to.
pub time: Option<Time>,
}

View file

@ -1,371 +0,0 @@
//! Field types.
// TODO Add newtype wrappers for different kinds of IDs?
// Serde's derive macros generate this warning and I can't turn it off locally,
// so I'm turning it off for the entire module.
#![allow(clippy::use_self)]
use std::fmt;
use serde::{de, ser, Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
/// Describes an account and its preferred name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountView {
/// The id of the account.
pub id: Snowflake,
/// The name that the holder of the account goes by.
pub name: String,
}
/// Mode of authentication.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthOption {
/// Authentication with a passcode, where a key is derived from the passcode
/// to unlock an access grant.
Passcode,
}
/// A node in a room's log.
///
/// It corresponds to a chat message, or a post, or any broadcasted event in a
/// room that should appear in the log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
/// The id of the message (unique within a room).
pub id: Snowflake,
/// The id of the message's parent, or null if top-level.
pub parent: Option<Snowflake>,
/// The edit id of the most recent edit of this message, or null if it's
/// never been edited.
pub previous_edit_id: Option<Snowflake>,
/// The unix timestamp of when the message was posted.
pub time: Time,
/// The view of the sender's session.
pub sender: SessionView,
/// The content of the message (client-defined).
pub content: String,
/// The id of the key that encrypts the message in storage.
pub encryption_key_id: Option<String>,
/// The unix timestamp of when the message was last edited.
pub edited: Option<Time>,
/// The unix timestamp of when the message was deleted.
pub deleted: Option<Time>,
/// If true, then the full content of this message is not included (see
/// [`GetMessage`](super::GetMessage) to obtain the message with full
/// content).
#[serde(default)]
pub truncated: bool,
}
/// The type of a packet.
///
/// Not all of these types have their corresponding data modeled as a struct.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PacketType {
// Asynchronous events
/// See [`BounceEvent`](super::BounceEvent).
BounceEvent,
/// See [`DisconnectEvent`](super::DisconnectEvent).
DisconnectEvent,
/// See [`HelloEvent`](super::HelloEvent).
HelloEvent,
/// See [`JoinEvent`](super::JoinEvent).
JoinEvent,
/// See [`LoginEvent`](super::LoginEvent).
LoginEvent,
/// See [`LogoutEvent`](super::LogoutEvent).
LogoutEvent,
/// See [`NetworkEvent`](super::NetworkEvent).
NetworkEvent,
/// See [`NickEvent`](super::NickEvent).
NickEvent,
/// See [`EditMessageEvent`](super::EditMessageEvent).
EditMessageEvent,
/// See [`PartEvent`](super::PartEvent).
PartEvent,
/// See [`PingEvent`](super::PingEvent).
PingEvent,
/// See [`PmInitiateEvent`](super::PmInitiateEvent).
PmInitiateEvent,
/// See [`SendEvent`](super::SendEvent).
SendEvent,
/// See [`SnapshotEvent`](super::SnapshotEvent).
SnapshotEvent,
// Session commands
/// See [`Auth`](super::Auth).
Auth,
/// See [`AuthReply`](super::AuthReply).
AuthReply,
/// See [`Ping`](super::Ping).
Ping,
/// See [`PingReply`](super::PingReply).
PingReply,
// Chat room commands
/// See [`GetMessage`](super::GetMessage).
GetMessage,
/// See [`GetMessageReply`](super::GetMessageReply).
GetMessageReply,
/// See [`Log`](super::Log).
Log,
/// See [`LogReply`](super::LogReply).
LogReply,
/// See [`Nick`](super::Nick).
Nick,
/// See [`NickReply`](super::NickReply).
NickReply,
/// See [`PmInitiate`](super::PmInitiate).
PmInitiate,
/// See [`PmInitiateReply`](super::PmInitiateReply).
PmInitiateReply,
/// See [`Send`](super::Send).
Send,
/// See [`SendReply`](super::SendReply).
SendReply,
/// See [`Who`](super::Who).
Who,
/// See [`WhoReply`](super::WhoReply).
WhoReply,
// Account commands
/// Not implemented.
ChangeEmail,
/// Not implemented.
ChangeEmailReply,
/// Not implemented.
ChangeName,
/// Not implemented.
ChangeNameReply,
/// Not implemented.
ChangePassword,
/// Not implemented.
ChangePasswordReply,
/// Not implemented.
Login,
/// Not implemented.
LoginReply,
/// Not implemented.
Logout,
/// Not implemented.
LogoutReply,
/// Not implemented.
RegisterAccount,
/// Not implemented.
RegisterAccountReply,
/// Not implemented.
ResendVerificationEmail,
/// Not implemented.
ResendVerificationEmailReply,
/// Not implemented.
ResetPassword,
/// Not implemented.
ResetPasswordReply,
// Room host commands
/// Not implemented.
Ban,
/// Not implemented.
BanReply,
/// Not implemented.
EditMessage,
/// Not implemented.
EditMessageReply,
/// Not implemented.
GrantAccess,
/// Not implemented.
GrantAccessReply,
/// Not implemented.
GrantManager,
/// Not implemented.
GrantManagerReply,
/// Not implemented.
RevokeAccess,
/// Not implemented.
RevokeAccessReply,
/// Not implemented.
RevokeManager,
/// Not implemented.
RevokeManagerReply,
/// Not implemented.
Unban,
/// Not implemented.
UnbanReply,
// Staff commands
/// Not implemented.
StaffCreateRoom,
/// Not implemented.
StaffCreateRoomReply,
/// Not implemented.
StaffEnrollOtp,
/// Not implemented.
StaffEnrollOtpReply,
/// Not implemented.
StaffGrantManager,
/// Not implemented.
StaffGrantManagerReply,
/// Not implemented.
StaffInvade,
/// Not implemented.
StaffInvadeReply,
/// Not implemented.
StaffLockRoom,
/// Not implemented.
StaffLockRoomReply,
/// Not implemented.
StaffRevokeAccess,
/// Not implemented.
StaffRevokeAccessReply,
/// Not implemented.
StaffValidateOtp,
/// Not implemented.
StaffValidateOtpReply,
/// Not implemented.
UnlockStaffCapability,
/// Not implemented.
UnlockStaffCapabilityReply,
}
impl fmt::Display for PacketType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match serde_json::to_value(self) {
Ok(Value::String(s)) => write!(f, "{}", s),
_ => Err(fmt::Error),
}
}
}
/// Describes an account to its owner.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalAccountView {
/// The id of the account.
pub id: Snowflake,
/// The name that the holder of the account goes by.
pub name: String,
/// The account's email address.
pub email: String,
}
/// Describes a session and its identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionView {
/// The id of an agent or account (or bot).
pub id: UserId,
/// The name-in-use at the time this view was captured.
pub name: String,
/// The id of the server that captured this view.
pub server_id: String,
/// The era of the server that captured this view.
pub server_era: String,
/// Id of the session, unique across all sessions globally.
pub session_id: String,
/// If true, this session belongs to a member of staff.
#[serde(default)]
pub is_staff: bool,
/// If true, this session belongs to a manager of the room.
#[serde(default)]
pub is_manager: bool,
/// For hosts and staff, the virtual address of the client.
pub client_address: Option<String>,
/// For staff, the real address of the client.
pub real_client_address: Option<String>,
}
/// A 13-character string, usually used as aunique identifier for some type of object.
///
/// It is the base-36 encoding of an unsigned, 64-bit integer.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Snowflake(pub u64);
impl Snowflake {
pub const MAX: Self = Snowflake(u64::MAX);
}
impl Serialize for Snowflake {
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Convert u64 to base36 string
let mut n = self.0;
let mut result = String::with_capacity(13);
for _ in 0..13 {
let c = char::from_digit((n % 36) as u32, 36).unwrap();
result.insert(0, c);
n /= 36;
}
result.serialize(serializer)
}
}
struct SnowflakeVisitor;
impl de::Visitor<'_> for SnowflakeVisitor {
type Value = Snowflake;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "a base36 string of length 13")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
// Convert base36 string to u64
if v.len() != 13 {
return Err(E::invalid_length(v.len(), &self));
}
let n = u64::from_str_radix(v, 36)
.map_err(|_| E::invalid_value(de::Unexpected::Str(v), &self))?;
Ok(Snowflake(n))
}
}
impl<'de> Deserialize<'de> for Snowflake {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(SnowflakeVisitor)
}
}
/// Time is specified as a signed 64-bit integer, giving the number of seconds
/// since the Unix Epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Time(#[serde(with = "time::serde::timestamp")] pub OffsetDateTime);
impl Time {
pub fn now() -> Self {
Self(OffsetDateTime::now_utc().replace_millisecond(0).unwrap())
}
}
/// Identifies a user.
///
/// The prefix of this value (up to the colon) indicates a type of session,
/// while the suffix is a unique value for that type of session.
///
/// It is possible for this value to have no prefix and colon, and there is no
/// fixed format for the unique value.
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserId(pub String);
#[derive(Debug, PartialEq, Eq)]
pub enum SessionType {
Agent,
Account,
Bot,
}
impl UserId {
pub fn session_type(&self) -> Option<SessionType> {
if self.0.starts_with("agent:") {
Some(SessionType::Agent)
} else if self.0.starts_with("account:") {
Some(SessionType::Account)
} else if self.0.starts_with("bot:") {
Some(SessionType::Bot)
} else {
None
}
}
}

View file

@ -1,466 +0,0 @@
//! Connection state modeling.
// TODO Catch errors differently when sending into mpsc/oneshot
use std::collections::HashMap;
use std::convert::Infallible;
use std::time::Duration;
use anyhow::bail;
use futures::channel::oneshot;
use futures::stream::{SplitSink, SplitStream};
use futures::{SinkExt, StreamExt};
use log::warn;
use rand::Rng;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::{select, task, time};
use tokio_tungstenite::{tungstenite, MaybeTlsStream, WebSocketStream};
use crate::replies::{self, PendingReply, Replies};
use super::api::packet::{Command, Packet, ParsedPacket};
use super::api::{
BounceEvent, Data, HelloEvent, PersonalAccountView, Ping, PingReply, SessionView,
SnapshotEvent, Time, UserId,
};
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
/// Timeout used for any kind of reply from the server, including to ws and euph
/// pings. Also used as the time in-between pings.
const TIMEOUT: Duration = Duration::from_secs(30); // TODO Make configurable
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("connection closed")]
ConnectionClosed,
#[error("packet timed out")]
TimedOut,
#[error("incorrect reply type")]
IncorrectReplyType,
#[error("{0}")]
Euph(String),
}
#[derive(Debug)]
enum Event {
Message(tungstenite::Message),
SendCmd(Data, oneshot::Sender<PendingReply<Result<Data, String>>>),
SendRpl(Option<String>, Data),
Status(oneshot::Sender<Status>),
DoPings,
}
impl Event {
fn send_cmd<C: Into<Data>>(
cmd: C,
rpl: oneshot::Sender<PendingReply<Result<Data, String>>>,
) -> Self {
Self::SendCmd(cmd.into(), rpl)
}
fn send_rpl<C: Into<Data>>(id: Option<String>, rpl: C) -> Self {
Self::SendRpl(id, rpl.into())
}
}
#[derive(Debug, Clone, Default)]
pub struct Joining {
pub hello: Option<HelloEvent>,
pub snapshot: Option<SnapshotEvent>,
pub bounce: Option<BounceEvent>,
}
impl Joining {
fn on_data(&mut self, data: &Data) -> anyhow::Result<()> {
match data {
Data::BounceEvent(p) => self.bounce = Some(p.clone()),
Data::HelloEvent(p) => self.hello = Some(p.clone()),
Data::SnapshotEvent(p) => self.snapshot = Some(p.clone()),
d @ (Data::JoinEvent(_)
| Data::NetworkEvent(_)
| Data::NickEvent(_)
| Data::EditMessageEvent(_)
| Data::PartEvent(_)
| Data::PmInitiateEvent(_)
| Data::SendEvent(_)) => bail!("unexpected {}", d.packet_type()),
_ => {}
}
Ok(())
}
fn joined(&self) -> Option<Joined> {
if let (Some(hello), Some(snapshot)) = (&self.hello, &self.snapshot) {
let mut session = hello.session.clone();
if let Some(nick) = &snapshot.nick {
session.name = nick.clone();
}
let listing = snapshot
.listing
.iter()
.cloned()
.map(|s| (s.id.clone(), s))
.collect::<HashMap<_, _>>();
Some(Joined {
session,
account: hello.account.clone(),
listing,
})
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct Joined {
pub session: SessionView,
pub account: Option<PersonalAccountView>,
pub listing: HashMap<UserId, SessionView>,
}
impl Joined {
fn on_data(&mut self, data: &Data) {
match data {
Data::JoinEvent(p) => {
self.listing.insert(p.0.id.clone(), p.0.clone());
}
Data::SendEvent(p) => {
self.listing
.insert(p.0.sender.id.clone(), p.0.sender.clone());
}
Data::PartEvent(p) => {
self.listing.remove(&p.0.id);
}
Data::NetworkEvent(p) => {
if p.r#type == "partition" {
self.listing.retain(|_, s| {
!(s.server_id == p.server_id && s.server_era == p.server_era)
});
}
}
Data::NickEvent(p) => {
if let Some(session) = self.listing.get_mut(&p.id) {
session.name = p.to.clone();
}
}
Data::NickReply(p) => {
assert_eq!(self.session.id, p.id);
self.session.name = p.to.clone();
}
// The who reply is broken and can't be trusted right now, so we'll
// not even look at it.
_ => {}
}
}
}
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum Status {
Joining(Joining),
Joined(Joined),
}
struct State {
ws_tx: SplitSink<WsStream, tungstenite::Message>,
last_id: usize,
replies: Replies<String, Result<Data, String>>,
packet_tx: mpsc::UnboundedSender<Data>,
last_ws_ping: Option<Vec<u8>>,
last_ws_pong: Option<Vec<u8>>,
last_euph_ping: Option<Time>,
last_euph_pong: Option<Time>,
status: Status,
}
impl State {
async fn run(
ws: WsStream,
mut tx_canary: mpsc::UnboundedReceiver<Infallible>,
rx_canary: oneshot::Receiver<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
mut event_rx: mpsc::UnboundedReceiver<Event>,
packet_tx: mpsc::UnboundedSender<Data>,
) {
let (ws_tx, mut ws_rx) = ws.split();
let mut state = Self {
ws_tx,
last_id: 0,
replies: Replies::new(TIMEOUT),
packet_tx,
last_ws_ping: None,
last_ws_pong: None,
last_euph_ping: None,
last_euph_pong: None,
status: Status::Joining(Joining::default()),
};
select! {
_ = tx_canary.recv() => (),
_ = rx_canary => (),
_ = Self::listen(&mut ws_rx, &event_tx) => (),
_ = Self::send_ping_events(&event_tx) => (),
_ = state.handle_events(&event_tx, &mut event_rx) => (),
}
}
async fn listen(
ws_rx: &mut SplitStream<WsStream>,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
while let Some(msg) = ws_rx.next().await {
event_tx.send(Event::Message(msg?))?;
}
Ok(())
}
async fn send_ping_events(event_tx: &mpsc::UnboundedSender<Event>) -> anyhow::Result<()> {
loop {
event_tx.send(Event::DoPings)?;
time::sleep(TIMEOUT).await;
}
}
async fn handle_events(
&mut self,
event_tx: &mpsc::UnboundedSender<Event>,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
) -> anyhow::Result<()> {
while let Some(ev) = event_rx.recv().await {
self.replies.purge();
match ev {
Event::Message(msg) => self.on_msg(msg, event_tx)?,
Event::SendCmd(data, reply_tx) => self.on_send_cmd(data, reply_tx).await?,
Event::SendRpl(id, data) => self.on_send_rpl(id, data).await?,
Event::Status(reply_tx) => self.on_status(reply_tx),
Event::DoPings => self.do_pings(event_tx).await?,
}
}
Ok(())
}
fn on_msg(
&mut self,
msg: tungstenite::Message,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
match msg {
tungstenite::Message::Text(t) => self.on_packet(serde_json::from_str(&t)?, event_tx)?,
tungstenite::Message::Binary(_) => bail!("unexpected binary message"),
tungstenite::Message::Ping(_) => {}
tungstenite::Message::Pong(p) => self.last_ws_pong = Some(p),
tungstenite::Message::Close(_) => {}
tungstenite::Message::Frame(_) => {}
}
Ok(())
}
fn on_packet(
&mut self,
packet: Packet,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
let packet = ParsedPacket::from_packet(packet)?;
// Complete pending replies if the packet has an id
if let Some(id) = &packet.id {
self.replies.complete(id, packet.content.clone());
}
// Play a game of table tennis
match &packet.content {
Ok(Data::PingReply(p)) => self.last_euph_pong = p.time,
Ok(Data::PingEvent(p)) => {
let reply = PingReply { time: Some(p.time) };
event_tx.send(Event::send_rpl(packet.id.clone(), reply))?;
}
// TODO Handle disconnect event?
_ => {}
}
// Update internal state
if let Ok(data) = &packet.content {
match &mut self.status {
Status::Joining(joining) => {
joining.on_data(data)?;
if let Some(joined) = joining.joined() {
self.status = Status::Joined(joined);
}
}
Status::Joined(joined) => joined.on_data(data),
}
}
// Shovel events and successful replies into self.packet_tx. Assumes
// that no even ever errors and that erroring replies are not
// interesting.
if let Ok(data) = packet.content {
self.packet_tx.send(data)?;
}
Ok(())
}
async fn on_send_cmd(
&mut self,
data: Data,
reply_tx: oneshot::Sender<PendingReply<Result<Data, String>>>,
) -> anyhow::Result<()> {
// Overkill of universe-heat-death-like proportions
self.last_id = self.last_id.wrapping_add(1);
let id = format!("{}", self.last_id);
let packet = ParsedPacket {
id: Some(id.clone()),
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.into_packet()?;
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?);
self.ws_tx.send(msg).await?;
let _ = reply_tx.send(self.replies.wait_for(id));
Ok(())
}
async fn on_send_rpl(&mut self, id: Option<String>, data: Data) -> anyhow::Result<()> {
let packet = ParsedPacket {
id,
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.into_packet()?;
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?);
self.ws_tx.send(msg).await?;
Ok(())
}
fn on_status(&mut self, reply_tx: oneshot::Sender<Status>) {
let _ = reply_tx.send(self.status.clone());
}
async fn do_pings(&mut self, event_tx: &mpsc::UnboundedSender<Event>) -> anyhow::Result<()> {
// Check old ws ping
if self.last_ws_ping.is_some() && self.last_ws_ping != self.last_ws_pong {
warn!("server missed ws ping");
bail!("server missed ws ping")
}
// Send new ws ping
let mut ws_payload = [0_u8; 8];
rand::thread_rng().fill(&mut ws_payload);
self.last_ws_ping = Some(ws_payload.to_vec());
self.ws_tx
.send(tungstenite::Message::Ping(ws_payload.to_vec()))
.await?;
// Check old euph ping
if self.last_euph_ping.is_some() && self.last_euph_ping != self.last_euph_pong {
warn!("server missed euph ping");
bail!("server missed euph ping")
}
// Send new euph ping
let euph_payload = Time::now();
self.last_euph_ping = Some(euph_payload);
let (tx, _) = oneshot::channel();
event_tx.send(Event::send_cmd(Ping { time: euph_payload }, tx))?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ConnTx {
#[allow(dead_code)]
canary: mpsc::UnboundedSender<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
}
impl ConnTx {
pub async fn send<C>(&self, cmd: C) -> Result<C::Reply, Error>
where
C: Command + Into<Data>,
C::Reply: TryFrom<Data>,
{
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::SendCmd(cmd.into(), tx))
.map_err(|_| Error::ConnectionClosed)?;
let pending_reply = rx
.await
// This should only happen if something goes wrong during encoding
// of the packet or while sending it through the websocket. Assuming
// the first doesn't happen, the connection is probably closed.
.map_err(|_| Error::ConnectionClosed)?;
let data = pending_reply
.get()
.await
.map_err(|e| match e {
replies::Error::TimedOut => Error::TimedOut,
replies::Error::Canceled => Error::ConnectionClosed,
})?
.map_err(Error::Euph)?;
data.try_into().map_err(|_| Error::IncorrectReplyType)
}
pub async fn status(&self) -> Result<Status, Error> {
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::Status(tx))
.map_err(|_| Error::ConnectionClosed)?;
rx.await.map_err(|_| Error::ConnectionClosed)
}
}
#[derive(Debug)]
pub struct ConnRx {
#[allow(dead_code)]
canary: oneshot::Sender<Infallible>,
packet_rx: mpsc::UnboundedReceiver<Data>,
}
impl ConnRx {
pub async fn recv(&mut self) -> Result<Data, Error> {
self.packet_rx.recv().await.ok_or(Error::ConnectionClosed)
}
}
// TODO Combine ConnTx and ConnRx and implement Stream + Sink?
pub fn wrap(ws: WsStream) -> (ConnTx, ConnRx) {
let (tx_canary_tx, tx_canary_rx) = mpsc::unbounded_channel();
let (rx_canary_tx, rx_canary_rx) = oneshot::channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (packet_tx, packet_rx) = mpsc::unbounded_channel();
task::spawn(State::run(
ws,
tx_canary_rx,
rx_canary_rx,
event_tx.clone(),
event_rx,
packet_tx,
));
let tx = ConnTx {
canary: tx_canary_tx,
event_tx,
};
let rx = ConnRx {
canary: rx_canary_tx,
packet_rx,
};
(tx, rx)
}

View file

@ -1,369 +0,0 @@
use std::convert::Infallible;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::bail;
use cookie::{Cookie, CookieJar};
use log::{error, info, warn};
use parking_lot::Mutex;
use tokio::sync::{mpsc, oneshot};
use tokio::{select, task};
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::handshake::client::Response;
use tokio_tungstenite::tungstenite::http::{header, HeaderValue};
use crate::euph::api::Time;
use crate::macros::ok_or_return;
use crate::ui::UiEvent;
use crate::vault::{EuphVault, Vault};
use super::api::{Data, Log, Nick, Send, Snowflake};
use super::conn::{self, ConnRx, ConnTx, Status};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("room stopped")]
Stopped,
}
#[derive(Debug)]
enum Event {
Connected(ConnTx),
Disconnected,
Data(Box<Data>),
Status(oneshot::Sender<Option<Status>>),
RequestLogs,
Nick(String),
Send(Option<Snowflake>, String, oneshot::Sender<Snowflake>),
}
#[derive(Debug)]
struct State {
name: String,
vault: EuphVault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
conn_tx: Option<ConnTx>,
/// `None` before any `snapshot-event`, then either `Some(None)` or
/// `Some(Some(id))`.
last_msg_id: Option<Option<Snowflake>>,
requesting_logs: Arc<Mutex<bool>>,
}
impl State {
async fn run(
mut self,
canary: oneshot::Receiver<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
mut event_rx: mpsc::UnboundedReceiver<Event>,
) {
let vault = self.vault.clone();
let name = self.name.clone();
let result = select! {
_ = canary => Ok(()),
_ = Self::reconnect(&vault, &name, &event_tx) => Ok(()),
_ = Self::regularly_request_logs(&event_tx) => Ok(()),
e = self.handle_events(&mut event_rx) => e,
};
if let Err(e) = result {
error!("e&{name}: {}", e);
}
}
async fn reconnect(
vault: &EuphVault,
name: &str,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
loop {
info!("e&{}: connecting", name);
if let Some((conn_tx, mut conn_rx)) = Self::connect(vault, name).await? {
info!("e&{}: connected", name);
event_tx.send(Event::Connected(conn_tx))?;
while let Ok(data) = conn_rx.recv().await {
event_tx.send(Event::Data(Box::new(data)))?;
}
info!("e&{}: disconnected", name);
event_tx.send(Event::Disconnected)?;
} else {
info!("e&{}: could not connect", name);
}
tokio::time::sleep(Duration::from_secs(5)).await; // TODO Make configurable
}
}
async fn get_cookies(vault: &Vault) -> String {
let cookie_jar = vault.euph_cookies().await;
let cookies = cookie_jar
.iter()
.map(|c| format!("{}", c.stripped()))
.collect::<Vec<_>>();
cookies.join("; ")
}
fn update_cookies(vault: &Vault, response: &Response) {
let mut cookie_jar = CookieJar::new();
for (name, value) in response.headers() {
if name == header::SET_COOKIE {
let value_str = ok_or_return!(value.to_str());
let cookie = ok_or_return!(Cookie::from_str(value_str));
cookie_jar.add(cookie);
}
}
vault.set_euph_cookies(cookie_jar);
}
async fn connect(vault: &EuphVault, name: &str) -> anyhow::Result<Option<(ConnTx, ConnRx)>> {
let uri = format!("wss://euphoria.io/room/{name}/ws?h=1");
let mut request = uri.into_client_request().expect("valid request");
let cookies = Self::get_cookies(vault.vault()).await;
let cookies = HeaderValue::from_str(&cookies).expect("valid cookies");
request.headers_mut().append(header::COOKIE, cookies);
match tokio_tungstenite::connect_async(request).await {
Ok((ws, response)) => {
Self::update_cookies(vault.vault(), &response);
Ok(Some(conn::wrap(ws)))
}
Err(tungstenite::Error::Http(resp)) if resp.status().is_client_error() => {
bail!("room {name} doesn't exist");
}
Err(tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_)) => {
bail!("format error for room {name}");
}
Err(_) => Ok(None),
}
}
async fn regularly_request_logs(event_tx: &mpsc::UnboundedSender<Event>) {
loop {
tokio::time::sleep(Duration::from_secs(2)).await; // TODO Make configurable
let _ = event_tx.send(Event::RequestLogs);
}
}
async fn handle_events(
&mut self,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
) -> anyhow::Result<()> {
while let Some(event) = event_rx.recv().await {
match event {
Event::Connected(conn_tx) => self.conn_tx = Some(conn_tx),
Event::Disconnected => {
self.conn_tx = None;
self.last_msg_id = None;
}
Event::Data(data) => self.on_data(*data).await?,
Event::Status(reply_tx) => self.on_status(reply_tx).await,
Event::RequestLogs => self.on_request_logs(),
Event::Nick(name) => self.on_nick(name),
Event::Send(parent, content, id_tx) => self.on_send(parent, content, id_tx),
}
}
Ok(())
}
async fn on_data(&mut self, data: Data) -> anyhow::Result<()> {
match data {
Data::BounceEvent(_) => {}
Data::DisconnectEvent(d) => {
warn!("e&{}: disconnected for reason {:?}", self.name, d.reason);
}
Data::HelloEvent(_) => {}
Data::JoinEvent(d) => {
info!("e&{}: {:?} joined", self.name, d.0.name);
}
Data::LoginEvent(_) => {}
Data::LogoutEvent(_) => {}
Data::NetworkEvent(d) => {
info!("e&{}: network event ({})", self.name, d.r#type);
}
Data::NickEvent(d) => {
info!("e&{}: {:?} renamed to {:?}", self.name, d.from, d.to);
}
Data::EditMessageEvent(_) => {
info!("e&{}: a message was edited", self.name);
}
Data::PartEvent(d) => {
info!("e&{}: {:?} left", self.name, d.0.name);
}
Data::PingEvent(_) => {}
Data::PmInitiateEvent(d) => {
info!(
"e&{}: {:?} initiated a pm from &{}",
self.name, d.from_nick, d.from_room
);
}
Data::SendEvent(d) => {
if let Some(last_msg_id) = &mut self.last_msg_id {
let id = d.0.id;
self.vault.add_message(d.0, *last_msg_id);
*last_msg_id = Some(id);
} else {
bail!("send event before snapshot event");
}
}
Data::SnapshotEvent(d) => {
info!("e&{}: successfully joined", self.name);
self.vault.join(Time::now());
self.last_msg_id = Some(d.log.last().map(|m| m.id));
self.vault.add_messages(d.log, None);
}
Data::LogReply(d) => {
self.vault.add_messages(d.log, d.before);
}
Data::SendReply(d) => {
if let Some(last_msg_id) = &mut self.last_msg_id {
let id = d.0.id;
self.vault.add_message(d.0, *last_msg_id);
*last_msg_id = Some(id);
} else {
bail!("send reply before snapshot event");
}
}
_ => {}
}
let _ = self.ui_event_tx.send(UiEvent::Redraw);
Ok(())
}
async fn on_status(&self, reply_tx: oneshot::Sender<Option<Status>>) {
let status = if let Some(conn_tx) = &self.conn_tx {
conn_tx.status().await.ok()
} else {
None
};
let _ = reply_tx.send(status);
}
fn on_request_logs(&self) {
if let Some(conn_tx) = &self.conn_tx {
// Check whether logs are already being requested
let mut guard = self.requesting_logs.lock();
if *guard {
return;
} else {
*guard = true;
}
drop(guard);
// No logs are being requested and we've reserved our spot, so let's
// request some logs!
let vault = self.vault.clone();
let conn_tx = conn_tx.clone();
let requesting_logs = self.requesting_logs.clone();
task::spawn(async move {
let result = Self::request_logs(vault, conn_tx).await;
*requesting_logs.lock() = false;
result
});
}
}
async fn request_logs(vault: EuphVault, conn_tx: ConnTx) -> anyhow::Result<()> {
let before = match vault.last_span().await {
Some((None, _)) => return Ok(()), // Already at top of room history
Some((Some(before), _)) => Some(before),
None => None,
};
let _ = conn_tx.send(Log { n: 1000, before }).await?;
// The code handling incoming events and replies also handles
// `LogReply`s, so we don't need to do anything special here.
Ok(())
}
fn on_nick(&self, name: String) {
if let Some(conn_tx) = &self.conn_tx {
let conn_tx = conn_tx.clone();
task::spawn(async move {
let _ = conn_tx.send(Nick { name }).await;
});
}
}
fn on_send(
&self,
parent: Option<Snowflake>,
content: String,
id_tx: oneshot::Sender<Snowflake>,
) {
if let Some(conn_tx) = &self.conn_tx {
let conn_tx = conn_tx.clone();
task::spawn(async move {
if let Ok(reply) = conn_tx.send(Send { content, parent }).await {
let _ = id_tx.send(reply.0.id);
}
});
}
}
}
#[derive(Debug)]
pub struct Room {
#[allow(dead_code)]
canary: oneshot::Sender<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
}
impl Room {
pub fn new(vault: EuphVault, ui_event_tx: mpsc::UnboundedSender<UiEvent>) -> Self {
let (canary_tx, canary_rx) = oneshot::channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let state = State {
name: vault.room().to_string(),
vault,
ui_event_tx,
conn_tx: None,
last_msg_id: None,
requesting_logs: Arc::new(Mutex::new(false)),
};
task::spawn(state.run(canary_rx, event_tx.clone(), event_rx));
Self {
canary: canary_tx,
event_tx,
}
}
pub fn stopped(&self) -> bool {
self.event_tx.is_closed()
}
pub async fn status(&self) -> Result<Option<Status>, Error> {
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::Status(tx))
.map_err(|_| Error::Stopped)?;
rx.await.map_err(|_| Error::Stopped)
}
pub fn nick(&self, name: String) -> Result<(), Error> {
self.event_tx
.send(Event::Nick(name))
.map_err(|_| Error::Stopped)
}
pub fn send(
&self,
parent: Option<Snowflake>,
content: String,
) -> Result<oneshot::Receiver<Snowflake>, Error> {
let (id_tx, id_rx) = oneshot::channel();
self.event_tx
.send(Event::Send(parent, content, id_tx))
.map(|_| id_rx)
.map_err(|_| Error::Stopped)
}
}

View file

@ -1,172 +0,0 @@
use crossterm::style::{Color, ContentStyle, Stylize};
use time::OffsetDateTime;
use toss::styled::Styled;
use crate::store::Msg;
use crate::ui::ChatMsg;
use super::api::{Snowflake, Time};
use super::util;
fn nick_char(ch: char) -> bool {
// Closely following the heim mention regex:
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
match ch {
',' | '.' | '!' | '?' | ';' | '&' | '<' | '\'' | '"' => false,
_ => !ch.is_whitespace(),
}
}
fn nick_char_(ch: Option<&char>) -> bool {
ch.filter(|c| nick_char(**c)).is_some()
}
fn room_char(ch: char) -> bool {
// Basically just \w, see also
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
ch.is_ascii_alphanumeric() || ch == '_'
}
fn room_char_(ch: Option<&char>) -> bool {
ch.filter(|c| room_char(**c)).is_some()
}
// TODO Allocate less?
fn highlight_content(content: &str, base_style: ContentStyle) -> Styled {
let mut result = Styled::default();
let mut current = String::new();
let mut chars = content.chars().peekable();
let mut possible_room_or_mention = true;
while let Some(char) = chars.next() {
match char {
'@' if possible_room_or_mention && nick_char_(chars.peek()) => {
result = result.then(&current, base_style);
current.clear();
let mut nick = String::new();
while let Some(ch) = chars.peek() {
if nick_char(*ch) {
nick.push(*ch);
} else {
break;
}
chars.next();
}
let (r, g, b) = util::nick_color(&nick);
let style = base_style.with(Color::Rgb { r, g, b }).bold();
result = result.then("@", style).then(nick, style);
}
'&' if possible_room_or_mention && room_char_(chars.peek()) => {
result = result.then(&current, base_style);
current.clear();
let mut room = "&".to_string();
while let Some(ch) = chars.peek() {
if room_char(*ch) {
room.push(*ch);
} else {
break;
}
chars.next();
}
let style = base_style.blue().bold();
result = result.then(room, style);
}
_ => current.push(char),
}
// More permissive than the heim web client
possible_room_or_mention = !char.is_alphanumeric();
}
result = result.then(current, base_style);
result
}
#[derive(Debug, Clone)]
pub struct SmallMessage {
pub id: Snowflake,
pub parent: Option<Snowflake>,
pub time: Time,
pub nick: String,
pub content: String,
}
fn as_me(content: &str) -> Option<&str> {
content.strip_prefix("/me")
}
fn style_me() -> ContentStyle {
ContentStyle::default().grey().italic()
}
fn styled_nick(nick: &str) -> Styled {
Styled::new_plain("[")
.then(nick, util::nick_style(nick))
.then_plain("]")
}
fn styled_nick_me(nick: &str) -> Styled {
let style = style_me();
Styled::new("*", style).then(nick, util::nick_style(nick).italic())
}
fn styled_content(content: &str) -> Styled {
highlight_content(content.trim(), ContentStyle::default())
}
fn styled_content_me(content: &str) -> Styled {
let style = style_me();
highlight_content(content.trim(), style).then("*", style)
}
fn styled_editor_content(content: &str) -> Styled {
let style = if as_me(content).is_some() {
style_me()
} else {
ContentStyle::default()
};
highlight_content(content, style)
}
impl Msg for SmallMessage {
type Id = Snowflake;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
self.parent
}
fn last_possible_id() -> Self::Id {
Snowflake::MAX
}
}
impl ChatMsg for SmallMessage {
fn time(&self) -> OffsetDateTime {
self.time.0
}
fn styled(&self) -> (Styled, Styled) {
Self::pseudo(&self.nick, &self.content)
}
fn edit(nick: &str, content: &str) -> (Styled, Styled) {
(styled_nick(nick), styled_editor_content(content))
}
fn pseudo(nick: &str, content: &str) -> (Styled, Styled) {
if let Some(content) = as_me(content) {
(styled_nick_me(nick), styled_content_me(content))
} else {
(styled_nick(nick), styled_content(content))
}
}
}

View file

@ -1,48 +0,0 @@
use crossterm::style::{Color, ContentStyle, Stylize};
use palette::{FromColor, Hsl, RgbHue, Srgb};
fn normalize(text: &str) -> String {
// TODO Remove emoji names?
text.chars()
.filter(|&c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
.map(|c| c.to_ascii_lowercase())
.collect()
}
/// A re-implementation of [euphoria's nick hue hashing algorithm][0].
///
/// [0]: https://github.com/euphoria-io/heim/blob/master/client/lib/hueHash.js
fn hue_hash(text: &str, offset: i64) -> u8 {
let mut val = 0_i32;
for bibyte in text.encode_utf16() {
let char_val = (bibyte as i32).wrapping_mul(439) % 256;
val = val.wrapping_mul(33).wrapping_add(char_val);
}
let val: i64 = val as i64 + 2_i64.pow(31);
((val + offset) % 255) as u8
}
const GREENIE_OFFSET: i64 = 148 - 192; // 148 - hue_hash("greenie", 0)
pub fn hue(text: &str) -> u8 {
let normalized = normalize(text);
if normalized.is_empty() {
hue_hash(text, GREENIE_OFFSET)
} else {
hue_hash(&normalized, GREENIE_OFFSET)
}
}
pub fn nick_color(nick: &str) -> (u8, u8, u8) {
let hue = RgbHue::from(hue(nick) as f32);
let color = Hsl::new(hue, 1.0, 0.72);
Srgb::from_color(color)
.into_format::<u8>()
.into_components()
}
pub fn nick_style(nick: &str) -> ContentStyle {
let (r, g, b) = nick_color(nick);
ContentStyle::default().bold().with(Color::Rgb { r, g, b })
}

View file

@ -1,95 +0,0 @@
//! Export logs from the vault to plain text files.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use time::format_description::FormatItem;
use time::macros::format_description;
use unicode_width::UnicodeWidthStr;
use crate::euph::api::Snowflake;
use crate::euph::SmallMessage;
use crate::store::{MsgStore, Tree};
use crate::vault::Vault;
const TIME_FORMAT: &[FormatItem<'_>] =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
const TIME_EMPTY: &str = " ";
pub async fn export(vault: &Vault, room: String, file: &Path) -> anyhow::Result<()> {
println!("Exporting &{room} to {}", file.to_string_lossy());
let mut file = BufWriter::new(File::create(file)?);
let vault = vault.euph(room);
let mut exported_trees = 0;
let mut exported_msgs = 0;
let mut tree_id = vault.first_tree_id().await;
while let Some(some_tree_id) = tree_id {
let tree = vault.tree(&some_tree_id).await;
write_tree(&mut file, &tree, some_tree_id, 0)?;
tree_id = vault.next_tree_id(&some_tree_id).await;
exported_trees += 1;
exported_msgs += tree.len();
if exported_trees % 10000 == 0 {
println!("Exported {exported_trees} trees, {exported_msgs} messages")
}
}
println!("Exported {exported_trees} trees, {exported_msgs} messages in total");
Ok(())
}
fn write_tree(
file: &mut BufWriter<File>,
tree: &Tree<SmallMessage>,
id: Snowflake,
indent: usize,
) -> anyhow::Result<()> {
let indent_string = "| ".repeat(indent);
if let Some(msg) = tree.msg(&id) {
write_msg(file, &indent_string, msg)?;
} else {
write_placeholder(file, &indent_string)?;
}
if let Some(children) = tree.children(&id) {
for child in children {
write_tree(file, tree, *child, indent + 1)?;
}
}
Ok(())
}
fn write_msg(
file: &mut BufWriter<File>,
indent_string: &str,
msg: &SmallMessage,
) -> anyhow::Result<()> {
let nick = &msg.nick;
let nick_empty = " ".repeat(nick.width());
for (i, line) in msg.content.lines().enumerate() {
if i == 0 {
let time = msg
.time
.0
.format(TIME_FORMAT)
.expect("time can be formatted");
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
} else {
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
}
}
Ok(())
}
fn write_placeholder(file: &mut BufWriter<File>, indent_string: &str) -> anyhow::Result<()> {
writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?;
Ok(())
}

View file

@ -1,161 +0,0 @@
use std::sync::Arc;
use std::vec;
use async_trait::async_trait;
use crossterm::style::{ContentStyle, Stylize};
use log::{Level, Log};
use parking_lot::Mutex;
use time::OffsetDateTime;
use tokio::sync::mpsc;
use toss::styled::Styled;
use crate::store::{Msg, MsgStore, Path, Tree};
use crate::ui::ChatMsg;
#[derive(Debug, Clone)]
pub struct LogMsg {
id: usize,
time: OffsetDateTime,
level: Level,
content: String,
}
impl Msg for LogMsg {
type Id = usize;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
None
}
fn last_possible_id() -> Self::Id {
Self::Id::MAX
}
}
impl ChatMsg for LogMsg {
fn time(&self) -> OffsetDateTime {
self.time
}
fn styled(&self) -> (Styled, Styled) {
let nick_style = match self.level {
Level::Error => ContentStyle::default().bold().red(),
Level::Warn => ContentStyle::default().bold().yellow(),
Level::Info => ContentStyle::default().bold().green(),
Level::Debug => ContentStyle::default().bold().blue(),
Level::Trace => ContentStyle::default().bold().magenta(),
};
let nick = Styled::new(format!("{}", self.level), nick_style);
let content = Styled::new_plain(&self.content);
(nick, content)
}
fn edit(_nick: &str, _content: &str) -> (Styled, Styled) {
panic!("log is not editable")
}
fn pseudo(_nick: &str, _content: &str) -> (Styled, Styled) {
panic!("log is not editable")
}
}
#[derive(Debug, Clone)]
pub struct Logger {
event_tx: mpsc::UnboundedSender<()>,
messages: Arc<Mutex<Vec<LogMsg>>>,
}
#[async_trait]
impl MsgStore<LogMsg> for Logger {
async fn path(&self, id: &usize) -> Path<usize> {
Path::new(vec![*id])
}
async fn tree(&self, tree_id: &usize) -> Tree<LogMsg> {
let msgs = self
.messages
.lock()
.get(*tree_id)
.map(|msg| vec![msg.clone()])
.unwrap_or_default();
Tree::new(*tree_id, msgs)
}
async fn first_tree_id(&self) -> Option<usize> {
let empty = self.messages.lock().is_empty();
Some(0).filter(|_| !empty)
}
async fn last_tree_id(&self) -> Option<usize> {
self.messages.lock().len().checked_sub(1)
}
async fn prev_tree_id(&self, tree_id: &usize) -> Option<usize> {
tree_id.checked_sub(1)
}
async fn next_tree_id(&self, tree_id: &usize) -> Option<usize> {
let len = self.messages.lock().len();
tree_id.checked_add(1).filter(|t| *t < len)
}
async fn oldest_msg_id(&self) -> Option<usize> {
self.first_tree_id().await
}
async fn newest_msg_id(&self) -> Option<usize> {
self.last_tree_id().await
}
async fn older_msg_id(&self, id: &usize) -> Option<usize> {
self.prev_tree_id(id).await
}
async fn newer_msg_id(&self, id: &usize) -> Option<usize> {
self.next_tree_id(id).await
}
}
impl Log for Logger {
fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let mut guard = self.messages.lock();
let msg = LogMsg {
id: guard.len(),
time: OffsetDateTime::now_utc(),
level: record.level(),
content: format!("<{}> {}", record.target(), record.args()),
};
guard.push(msg);
let _ = self.event_tx.send(());
}
fn flush(&self) {}
}
impl Logger {
pub fn init(level: Level) -> (Self, mpsc::UnboundedReceiver<()>) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let logger = Self {
event_tx,
messages: Arc::new(Mutex::new(Vec::new())),
};
log::set_boxed_logger(Box::new(logger.clone())).expect("logger already set");
log::set_max_level(level.to_level_filter());
(logger, event_rx)
}
}

View file

@ -1,31 +0,0 @@
macro_rules! some_or_return {
($e:expr) => {
match $e {
Some(result) => result,
None => return,
}
};
($e:expr, $ret:expr) => {
match $e {
Some(result) => result,
None => return $ret,
}
};
}
pub(crate) use some_or_return;
macro_rules! ok_or_return {
($e:expr) => {
match $e {
Ok(result) => result,
Err(_) => return,
}
};
($e:expr, $ret:expr) => {
match $e {
Ok(result) => result,
Err(_) => return $ret,
}
};
}
pub(crate) use ok_or_return;

View file

@ -1,121 +0,0 @@
#![deny(unsafe_code)]
// Rustc lint groups
#![warn(future_incompatible)]
#![warn(rust_2018_idioms)]
// Rustc lints
#![warn(noop_method_call)]
#![warn(single_use_lifetimes)]
#![warn(trivial_numeric_casts)]
#![warn(unused_crate_dependencies)]
#![warn(unused_extern_crates)]
#![warn(unused_import_braces)]
#![warn(unused_lifetimes)]
#![warn(unused_qualifications)]
// Clippy lints
#![warn(clippy::use_self)]
// TODO Enable warn(unreachable_pub)?
// TODO Clean up use and manipulation of toss Pos and Size
mod euph;
mod export;
mod logger;
mod macros;
mod replies;
mod store;
mod ui;
mod vault;
use std::path::PathBuf;
use clap::Parser;
use cookie::CookieJar;
use directories::ProjectDirs;
use log::info;
use toss::terminal::Terminal;
use ui::Ui;
use vault::Vault;
use crate::logger::Logger;
#[derive(Debug, clap::Subcommand)]
enum Command {
/// Run the client interactively (default).
Run,
/// Export logs for a single room as a plain text file.
Export { room: String, file: PathBuf },
/// Compact and clean up vault.
Gc,
/// Clear euphoria session cookies.
ClearCookies,
}
impl Default for Command {
fn default() -> Self {
Self::Run
}
}
#[derive(Debug, clap::Parser)]
struct Args {
/// Path to a directory for cove to store its data in.
#[clap(long, short)]
data_dir: Option<PathBuf>,
/// Measure the width of characters as displayed by the terminal emulator
/// instead of guessing the width.
#[clap(long, short, action)]
measure_widths: bool,
#[clap(subcommand)]
command: Option<Command>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let data_dir = if let Some(data_dir) = args.data_dir {
data_dir
} else {
let dirs =
ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories");
dirs.data_dir().to_path_buf()
};
println!("Data dir: {}", data_dir.to_string_lossy());
let vault = vault::launch(&data_dir.join("vault.db"))?;
match args.command.unwrap_or_default() {
Command::Run => run(&vault, args.measure_widths).await?,
Command::Export { room, file } => export::export(&vault, room, &file).await?,
Command::Gc => {
println!("Cleaning up and compacting vault");
println!("This may take a while...");
vault.gc().await;
}
Command::ClearCookies => {
println!("Clearing cookies");
vault.set_euph_cookies(CookieJar::new());
}
}
vault.close().await;
println!("Goodbye!");
Ok(())
}
async fn run(vault: &Vault, measure_widths: bool) -> anyhow::Result<()> {
let (logger, logger_rx) = Logger::init(log::Level::Debug);
info!(
"Welcome to {} {}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
let mut terminal = Terminal::new()?;
terminal.set_measuring(measure_widths);
Ui::run(&mut terminal, vault.clone(), logger, logger_rx).await?;
drop(terminal); // So the vault can print again
Ok(())
}

View file

@ -1,68 +0,0 @@
use std::collections::HashMap;
use std::hash::Hash;
use std::result;
use std::time::Duration;
use tokio::sync::oneshot::{self, Receiver, Sender};
use tokio::time;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("timed out")]
TimedOut,
#[error("canceled")]
Canceled,
}
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug)]
pub struct PendingReply<R> {
timeout: Duration,
result: Receiver<R>,
}
impl<R> PendingReply<R> {
pub async fn get(self) -> Result<R> {
let result = time::timeout(self.timeout, self.result).await;
match result {
Err(_) => Err(Error::TimedOut),
Ok(Err(_)) => Err(Error::Canceled),
Ok(Ok(value)) => Ok(value),
}
}
}
#[derive(Debug)]
pub struct Replies<I, R> {
timeout: Duration,
pending: HashMap<I, Sender<R>>,
}
impl<I: Eq + Hash, R> Replies<I, R> {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
pending: HashMap::new(),
}
}
pub fn wait_for(&mut self, id: I) -> PendingReply<R> {
let (tx, rx) = oneshot::channel();
self.pending.insert(id, tx);
PendingReply {
timeout: self.timeout,
result: rx,
}
}
pub fn complete(&mut self, id: &I, result: R) {
if let Some(tx) = self.pending.remove(id) {
let _ = tx.send(result);
}
}
pub fn purge(&mut self) {
self.pending.retain(|_, tx| !tx.is_closed());
}
}

292
src/ui.rs
View file

@ -1,292 +0,0 @@
mod chat;
mod input;
mod room;
mod rooms;
mod util;
mod widgets;
use std::convert::Infallible;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use crossterm::event::{Event, KeyCode, MouseEvent};
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::logger::{LogMsg, Logger};
use crate::vault::Vault;
pub use self::chat::ChatMsg;
use self::chat::ChatState;
use self::input::{key, KeyBindingsList, KeyEvent};
use self::rooms::Rooms;
use self::widgets::layer::Layer;
use self::widgets::list::ListState;
use self::widgets::BoxedWidget;
/// Time to spend batch processing events before redrawing the screen.
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
#[derive(Debug)]
pub enum UiEvent {
Redraw,
Term(Event),
}
enum EventHandleResult {
Continue,
Stop,
}
enum Mode {
Main,
Log,
}
pub struct Ui {
event_tx: UnboundedSender<UiEvent>,
mode: Mode,
rooms: Rooms,
log_chat: ChatState<LogMsg, Logger>,
key_bindings_list: Option<ListState<Infallible>>,
}
impl Ui {
const POLL_DURATION: Duration = Duration::from_millis(100);
pub async fn run(
terminal: &mut Terminal,
vault: Vault,
logger: Logger,
logger_rx: UnboundedReceiver<()>,
) -> anyhow::Result<()> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let crossterm_lock = Arc::new(FairMutex::new(()));
// Prepare and start crossterm event polling task
let weak_crossterm_lock = Arc::downgrade(&crossterm_lock);
let event_tx_clone = event_tx.clone();
let crossterm_event_task = task::spawn_blocking(|| {
Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock)
});
// Run main UI.
//
// If the run_main method exits at any point or if this `run` method is
// not awaited any more, the crossterm_lock Arc should be deallocated,
// meaning the crossterm_event_task will also stop after at most
// `Self::POLL_DURATION`.
//
// On the other hand, if the crossterm_event_task stops for any reason,
// the rest of the UI is also shut down and the client stops.
let mut ui = Self {
event_tx: event_tx.clone(),
mode: Mode::Main,
rooms: Rooms::new(vault, event_tx.clone()),
log_chat: ChatState::new(logger),
key_bindings_list: None,
};
tokio::select! {
e = ui.run_main(terminal, event_rx, crossterm_lock) => Ok(e),
_ = Self::update_on_log_event(logger_rx, &event_tx) => Ok(Ok(())),
e = crossterm_event_task => e,
}?
}
fn poll_crossterm_events(
tx: UnboundedSender<UiEvent>,
lock: Weak<FairMutex<()>>,
) -> anyhow::Result<()> {
while let Some(lock) = lock.upgrade() {
let _guard = lock.lock();
if crossterm::event::poll(Self::POLL_DURATION)? {
let event = crossterm::event::read()?;
tx.send(UiEvent::Term(event))?;
}
}
Ok(())
}
async fn update_on_log_event(
mut logger_rx: UnboundedReceiver<()>,
event_tx: &UnboundedSender<UiEvent>,
) {
while let Some(()) = logger_rx.recv().await {
if event_tx.send(UiEvent::Redraw).is_err() {
break;
}
}
}
async fn run_main(
&mut self,
terminal: &mut Terminal,
mut event_rx: UnboundedReceiver<UiEvent>,
crossterm_lock: Arc<FairMutex<()>>,
) -> anyhow::Result<()> {
// Initial render so we don't show a blank screen until the first event
terminal.autoresize()?;
terminal.frame().reset();
self.widget().await.render(terminal.frame()).await;
terminal.present()?;
loop {
// 1. Measure grapheme widths if required
if terminal.measuring_required() {
let _guard = crossterm_lock.lock();
terminal.measure_widths()?;
self.event_tx.send(UiEvent::Redraw)?;
}
// 2. Handle events (in batches)
let mut event = match event_rx.recv().await {
Some(event) => event,
None => return Ok(()),
};
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
loop {
// Render in-between events so the next event is handled in an
// up-to-date state. The results of these intermediate renders
// will be thrown away before the final render.
terminal.autoresize()?;
self.widget().await.render(terminal.frame()).await;
let result = match event {
UiEvent::Redraw => EventHandleResult::Continue,
UiEvent::Term(Event::Key(event)) => {
self.handle_key_event(event.into(), terminal, &crossterm_lock)
.await
}
UiEvent::Term(Event::Mouse(event)) => self.handle_mouse_event(event).await?,
UiEvent::Term(Event::Resize(_, _)) => EventHandleResult::Continue,
};
match result {
EventHandleResult::Continue => {}
EventHandleResult::Stop => return Ok(()),
}
if Instant::now() >= end_time {
break;
}
event = match event_rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return Ok(()),
};
}
// 3. Render and present final state
terminal.autoresize()?;
terminal.frame().reset();
self.widget().await.render(terminal.frame()).await;
terminal.present()?;
}
}
async fn widget(&mut self) -> BoxedWidget {
let widget = match self.mode {
Mode::Main => self.rooms.widget().await,
Mode::Log => self.log_chat.widget(String::new()).into(),
};
if let Some(key_bindings_list) = &self.key_bindings_list {
let mut bindings = KeyBindingsList::new(key_bindings_list);
self.list_key_bindings(&mut bindings).await;
Layer::new(vec![widget, bindings.widget()]).into()
} else {
widget
}
}
fn show_key_bindings(&mut self) {
if self.key_bindings_list.is_none() {
self.key_bindings_list = Some(ListState::new())
}
}
async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("ctrl+c", "quit cove");
bindings.binding("F1, ?", "show this menu");
bindings.binding("F12", "toggle log");
bindings.empty();
match self.mode {
Mode::Main => self.rooms.list_key_bindings(bindings).await,
Mode::Log => self.log_chat.list_key_bindings(bindings, false).await,
}
}
async fn handle_key_event(
&mut self,
event: KeyEvent,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> EventHandleResult {
if let key!(Ctrl + 'c') = event {
// Exit unconditionally on ctrl+c. Previously, shift+q would also
// unconditionally exit, but that interfered with typing text in
// inline editors.
return EventHandleResult::Stop;
}
// Key bindings list overrides any other bindings if visible
if let Some(key_bindings_list) = &mut self.key_bindings_list {
match event {
key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None,
key!('k') | key!(Up) => key_bindings_list.scroll_up(1),
key!('j') | key!(Down) => key_bindings_list.scroll_down(1),
_ => {}
}
return EventHandleResult::Continue;
}
match event {
key!(F 1) => {
self.key_bindings_list = Some(ListState::new());
return EventHandleResult::Continue;
}
key!(F 12) => {
self.mode = match self.mode {
Mode::Main => Mode::Log,
Mode::Log => Mode::Main,
};
return EventHandleResult::Continue;
}
_ => {}
}
let handled = match self.mode {
Mode::Main => {
self.rooms
.handle_key_event(terminal, crossterm_lock, event)
.await
}
Mode::Log => self
.log_chat
.handle_key_event(terminal, crossterm_lock, event, false)
.await
.handled(),
};
// Pressing '?' should only open the key bindings list if it doesn't
// interfere with any part of the main UI, such as entering text in a
// text editor.
if !handled {
if let key!('?') = event {
self.show_key_bindings();
}
}
EventHandleResult::Continue
}
async fn handle_mouse_event(
&mut self,
_event: MouseEvent,
) -> anyhow::Result<EventHandleResult> {
Ok(EventHandleResult::Continue)
}
}

View file

@ -1,147 +0,0 @@
mod blocks;
mod tree;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::FairMutex;
use time::OffsetDateTime;
use toss::frame::{Frame, Size};
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use self::tree::{TreeView, TreeViewState};
use super::input::{KeyBindingsList, KeyEvent};
use super::widgets::Widget;
///////////
// Trait //
///////////
pub trait ChatMsg {
fn time(&self) -> OffsetDateTime;
fn styled(&self) -> (Styled, Styled);
fn edit(nick: &str, content: &str) -> (Styled, Styled);
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
}
///////////
// State //
///////////
pub enum Mode {
Tree,
// Thread,
// Flat,
}
pub struct ChatState<M: Msg, S: MsgStore<M>> {
store: S,
mode: Mode,
tree: TreeViewState<M, S>,
// thread: ThreadView,
// flat: FlatView,
}
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
pub fn new(store: S) -> Self {
Self {
mode: Mode::Tree,
tree: TreeViewState::new(store.clone()),
store,
}
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
pub fn store(&self) -> &S {
&self.store
}
pub fn widget(&self, nick: String) -> Chat<M, S> {
match self.mode {
Mode::Tree => Chat::Tree(self.tree.widget(nick)),
}
}
}
pub enum Reaction<M: Msg> {
NotHandled,
Handled,
Composed {
parent: Option<M::Id>,
content: String,
},
}
impl<M: Msg> Reaction<M> {
pub fn handled(&self) -> bool {
!matches!(self, Self::NotHandled)
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
match self.mode {
Mode::Tree => self.tree.list_key_bindings(bindings, can_compose).await,
}
}
pub async fn handle_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
can_compose: bool,
) -> Reaction<M> {
match self.mode {
Mode::Tree => {
self.tree
.handle_key_event(terminal, crossterm_lock, event, can_compose)
.await
}
}
}
/// A [`Reaction::Composed`] message was sent, either successfully or
/// unsuccessfully.
///
/// If successful, include the message's id as an argument. If unsuccessful,
/// instead pass a `None`.
pub async fn sent(&mut self, id: Option<M::Id>) {
match self.mode {
Mode::Tree => self.tree.sent(id).await,
}
}
}
////////////
// Widget //
////////////
pub enum Chat<M: Msg, S: MsgStore<M>> {
Tree(TreeView<M, S>),
}
#[async_trait]
impl<M, S> Widget for Chat<M, S>
where
M: Msg + ChatMsg,
M::Id: Send + Sync,
S: MsgStore<M> + Send + Sync,
{
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size {
match self {
Self::Tree(tree) => tree.size(frame, max_width, max_height),
}
}
async fn render(self: Box<Self>, frame: &mut Frame) {
match *self {
Self::Tree(tree) => Box::new(tree).render(frame).await,
}
}
}

View file

@ -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;
}
}

View file

@ -1,362 +0,0 @@
mod cursor;
mod layout;
mod tree_blocks;
mod widgets;
use std::sync::Arc;
use async_trait::async_trait;
use crossterm::event::KeyCode;
use parking_lot::FairMutex;
use tokio::sync::Mutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use crate::ui::input::{key, KeyBindingsList, KeyEvent};
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::Widget;
use self::cursor::Cursor;
use super::{ChatMsg, Reaction};
///////////
// State //
///////////
enum Correction {
MakeCursorVisible,
MoveCursorToVisibleArea,
}
struct InnerTreeViewState<M: Msg, S: MsgStore<M>> {
store: S,
last_cursor: Cursor<M::Id>,
last_cursor_line: i32,
cursor: Cursor<M::Id>,
/// Scroll the view on the next render. Positive values scroll up and
/// negative values scroll down.
scroll: i32,
correction: Option<Correction>,
editor: EditorState,
}
impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
fn new(store: S) -> Self {
Self {
store,
last_cursor: Cursor::Bottom,
last_cursor_line: 0,
cursor: Cursor::Bottom,
scroll: 0,
correction: None,
editor: EditorState::new(),
}
}
pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("j/k, ↓/↑", "move cursor up/down");
bindings.binding("h/l, ←/→", "move cursor chronologically");
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", "scroll up/down one screen");
}
async fn handle_movement_key_event(&mut self, frame: &mut Frame, event: KeyEvent) -> bool {
let chat_height = frame.size().height - 3;
match event {
key!('k') | key!(Up) => self.move_cursor_up().await,
key!('j') | key!(Down) => self.move_cursor_down().await,
key!('h') | key!(Left) => self.move_cursor_older().await,
key!('l') | key!(Right) => self.move_cursor_newer().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') => self.scroll_up(chat_height.saturating_sub(1).into()),
key!(Ctrl + 'f') => self.scroll_down(chat_height.saturating_sub(1).into()),
_ => return false,
}
true
}
pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.empty();
bindings.binding("r", "reply to message");
bindings.binding_ctd("(inline if possible, otherwise directly)");
bindings.binding("R", "reply to message (opposite of R)");
bindings.binding("t", "start a new thread");
}
async fn handle_edit_initiating_key_event(
&mut self,
event: KeyEvent,
id: Option<M::Id>,
) -> bool {
match event {
key!('r') => {
if let Some(parent) = self.parent_for_normal_reply().await {
self.cursor = Cursor::editor(id, parent);
self.correction = Some(Correction::MakeCursorVisible);
}
}
key!('R') => {
if let Some(parent) = self.parent_for_alternate_reply().await {
self.cursor = Cursor::editor(id, parent);
self.correction = Some(Correction::MakeCursorVisible);
}
}
key!('t') | key!('T') => {
self.cursor = Cursor::editor(id, None);
self.correction = Some(Correction::MakeCursorVisible);
}
_ => return false,
}
true
}
pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
self.list_movement_key_bindings(bindings);
if can_compose {
self.list_edit_initiating_key_bindings(bindings);
}
}
async fn handle_normal_key_event(
&mut self,
frame: &mut Frame,
event: KeyEvent,
can_compose: bool,
id: Option<M::Id>,
) -> bool {
if self.handle_movement_key_event(frame, event).await {
true
} else if can_compose {
self.handle_edit_initiating_key_event(event, id).await
} else {
false
}
}
pub fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "close editor");
bindings.binding("enter", "send message");
bindings.binding("←/→", "move cursor left/right");
bindings.binding("backspace", "delete before cursor");
bindings.binding("delete", "delete after cursor");
bindings.binding("ctrl+e", "edit in $EDITOR");
bindings.binding("ctrl+l", "clear editor contents");
}
fn handle_editor_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
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);
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 };
}
}
// Enter with *any* modifier pressed - if ctrl and shift don't
// work, maybe alt does
KeyEvent {
code: KeyCode::Enter,
..
} => self.editor.insert_char('\n'),
key!(Char ch) => self.editor.insert_char(ch),
key!(Left) => self.editor.move_cursor_left(),
key!(Right) => self.editor.move_cursor_right(),
key!(Backspace) => self.editor.backspace(),
key!(Delete) => self.editor.delete(),
key!(Ctrl + 'e') => self.editor.edit_externally(terminal, crossterm_lock),
key!(Ctrl + 'l') => self.editor.clear(),
_ => return Reaction::NotHandled,
}
self.correction = Some(Correction::MakeCursorVisible);
Reaction::Handled
}
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
bindings.heading("Chat");
match &self.cursor {
Cursor::Bottom | Cursor::Msg(_) => {
self.list_normal_key_bindings(bindings, can_compose);
}
Cursor::Editor { .. } => self.list_editor_key_bindings(bindings),
Cursor::Pseudo { .. } => {
self.list_movement_key_bindings(bindings);
}
}
}
async fn handle_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
can_compose: bool,
) -> Reaction<M> {
match &self.cursor {
Cursor::Bottom => {
if self
.handle_normal_key_event(terminal.frame(), event, can_compose, None)
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
Cursor::Msg(id) => {
let id = id.clone();
if self
.handle_normal_key_event(terminal.frame(), event, can_compose, Some(id))
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
Cursor::Editor {
coming_from,
parent,
} => self.handle_editor_key_event(
terminal,
crossterm_lock,
event,
coming_from.clone(),
parent.clone(),
),
Cursor::Pseudo { .. } => {
if self
.handle_movement_key_event(terminal.frame(), event)
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
}
}
fn sent(&mut self, id: Option<M::Id>) {
if let Cursor::Pseudo { coming_from, .. } = &self.cursor {
if let Some(id) = id {
self.last_cursor = Cursor::Msg(id.clone());
self.cursor = Cursor::Msg(id);
self.editor.clear();
} else {
self.cursor = match coming_from {
Some(id) => Cursor::Msg(id.clone()),
None => Cursor::Bottom,
};
};
}
}
}
pub struct TreeViewState<M: Msg, S: MsgStore<M>>(Arc<Mutex<InnerTreeViewState<M, S>>>);
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
pub fn new(store: S) -> Self {
Self(Arc::new(Mutex::new(InnerTreeViewState::new(store))))
}
pub fn widget(&self, nick: String) -> TreeView<M, S> {
TreeView {
inner: self.0.clone(),
nick,
}
}
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
self.0.lock().await.list_key_bindings(bindings, can_compose);
}
pub async fn handle_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
can_compose: bool,
) -> Reaction<M> {
self.0
.lock()
.await
.handle_key_event(terminal, crossterm_lock, event, can_compose)
.await
}
pub async fn sent(&mut self, id: Option<M::Id>) {
self.0.lock().await.sent(id)
}
}
////////////
// Widget //
////////////
pub struct TreeView<M: Msg, S: MsgStore<M>> {
inner: Arc<Mutex<InnerTreeViewState<M, S>>>,
nick: String,
}
#[async_trait]
impl<M, S> Widget for TreeView<M, S>
where
M: Msg + ChatMsg,
M::Id: Send + Sync,
S: MsgStore<M> + Send + Sync,
{
fn size(&self, _frame: &mut Frame, _max_width: Option<u16>, _max_height: Option<u16>) -> Size {
Size::ZERO
}
async fn render(self: Box<Self>, frame: &mut Frame) {
let mut guard = self.inner.lock().await;
let blocks = guard.relayout(&self.nick, frame).await;
let size = frame.size();
for block in blocks.into_blocks().blocks {
frame.push(
Pos::new(0, block.top_line),
Size::new(size.width, block.height as u16),
);
block.widget.render(frame).await;
frame.pop();
}
}
}

View file

@ -1,414 +0,0 @@
//! Moving the cursor around.
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(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
*id = child.clone();
true
} else {
false
}
}
fn find_last_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
*id = child.clone();
true
} else {
false
}
}
/// Move to the previous sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_prev_sibling(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
if let Some(prev_sibling) = tree.prev_sibling(id) {
*id = prev_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the previous tree.
if let Some(prev_tree_id) = store.prev_tree_id(tree.root()).await {
*tree = store.tree(&prev_tree_id).await;
*id = prev_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the next sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_next_sibling(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
if let Some(next_sibling) = tree.next_sibling(id) {
*id = next_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the next tree.
if let Some(next_tree_id) = store.next_tree_id(tree.root()).await {
*tree = store.tree(&next_tree_id).await;
*id = next_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the previous message, or don't move if this is not possible.
async fn find_prev_msg(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
// Move to previous sibling, then to its last child
// If not possible, move to parent
if Self::find_prev_sibling(store, tree, id).await {
while Self::find_last_child(tree, id) {}
true
} else {
Self::find_parent(tree, id)
}
}
/// Move to the next message, or don't move if this is not possible.
async fn find_next_msg(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
if Self::find_first_child(tree, id) {
return true;
}
if Self::find_next_sibling(store, tree, id).await {
return true;
}
// Temporary id to avoid modifying the original one if no parent-sibling
// can be found.
let mut tmp_id = id.clone();
while Self::find_parent(tree, &mut tmp_id) {
if Self::find_next_sibling(store, tree, &mut tmp_id).await {
*id = tmp_id;
return true;
}
}
false
}
pub async fn move_cursor_up(&mut self) {
match &mut self.cursor {
Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => {
if let Some(last_tree_id) = self.store.last_tree_id().await {
let tree = self.store.tree(&last_tree_id).await;
let mut id = last_tree_id;
while Self::find_last_child(&tree, &mut id) {}
self.cursor = Cursor::Msg(id);
}
}
Cursor::Msg(ref mut msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
Self::find_prev_msg(&self.store, &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(&tree, &mut id) {}
self.cursor = Cursor::Msg(id);
}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_down(&mut self) {
match &mut self.cursor {
Cursor::Msg(ref mut msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
if !Self::find_next_msg(&self.store, &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(&tree, &mut id) {}
// Now we're at the previous message
if Self::find_next_msg(&self.store, &mut tree, &mut id).await {
self.cursor = Cursor::Msg(id);
} else {
self.cursor = Cursor::Bottom;
}
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_older(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.older_msg_id(id).await {
*id = prev_id;
}
}
Cursor::Bottom | Cursor::Pseudo { .. } => {
if let Some(id) = self.store.newest_msg_id().await {
self.cursor = Cursor::Msg(id);
}
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_newer(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.newer_msg_id(id).await {
*id = prev_id;
} else {
self.cursor = Cursor::Bottom;
}
}
Cursor::Pseudo { .. } => {
self.cursor = Cursor::Bottom;
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_to_top(&mut self) {
if let Some(first_tree_id) = self.store.first_tree_id().await {
self.cursor = Cursor::Msg(first_tree_id);
self.correction = Some(Correction::MakeCursorVisible);
}
}
pub async fn move_cursor_to_bottom(&mut self) {
self.cursor = Cursor::Bottom;
// Not really necessary; only here for consistency with other methods
self.correction = Some(Correction::MakeCursorVisible);
}
pub fn scroll_up(&mut self, amount: i32) {
self.scroll += amount;
self.correction = Some(Correction::MoveCursorToVisibleArea);
}
pub fn scroll_down(&mut self, amount: i32) {
self.scroll -= amount;
self.correction = Some(Correction::MoveCursorToVisibleArea);
}
pub async fn parent_for_normal_reply(&self) -> Option<Option<M::Id>> {
match &self.cursor {
Cursor::Bottom => Some(None),
Cursor::Msg(id) => {
let path = self.store.path(id).await;
let tree = self.store.tree(path.first()).await;
Some(Some(if tree.next_sibling(id).is_some() {
// A reply to a message that has further siblings should be a
// direct reply. An indirect reply might end up a lot further
// down in the current conversation.
id.clone()
} else if let Some(parent) = tree.parent(id) {
// A reply to a message without younger siblings should be
// an indirect reply so as not to create unnecessarily deep
// threads. In the case that our message has children, this
// might get a bit confusing. I'm not sure yet how well this
// "smart" reply actually works in practice.
parent
} else {
// When replying to a top-level message, it makes sense to avoid
// creating unnecessary new threads.
id.clone()
}))
}
_ => None,
}
}
pub async fn parent_for_alternate_reply(&self) -> Option<Option<M::Id>> {
match &self.cursor {
Cursor::Bottom => Some(None),
Cursor::Msg(id) => {
let path = self.store.path(id).await;
let tree = self.store.tree(path.first()).await;
Some(Some(if tree.next_sibling(id).is_none() {
// The opposite of replying normally
id.clone()
} else if let Some(parent) = tree.parent(id) {
// The opposite of replying normally
parent
} else {
// The same as replying normally, still to avoid creating
// unnecessary new threads
id.clone()
}))
}
_ => None,
}
}
}
/*
pub async fn move_up_sibling<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_prev_sibling(store, &mut tree, &mut cursor.id)
.await;
} else if let Some(last_tree) = store.last_tree().await {
// I think moving to the root of the last tree makes the most sense
// here. Alternatively, we could just not move the cursor, but that
// wouldn't be very useful.
*cursor = Some(Cursor::new(last_tree));
}
// If neither condition holds, we can't set a cursor because there's no
// message to move to.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_down_sibling<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_next_sibling(store, &mut tree, &mut cursor.id)
.await;
}
// If that condition doesn't hold, we're already at the bottom in
// cursor-less mode and can't move further down anyways.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
// TODO move_older[_unseen]
// TODO move_newer[_unseen]
pub async fn center_cursor<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
if let Some(cursor) = cursor {
cursor.proportion = 0.5;
// Correcting the offset just to make sure that this function
// behaves nicely if the cursor has too many lines.
let old_blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
let old_cursor_id = Some(cursor.id.clone());
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
*/

View file

@ -1,496 +0,0 @@
use toss::frame::Frame;
use crate::store::{Msg, MsgStore, Path, Tree};
use crate::ui::chat::blocks::Block;
use crate::ui::widgets::empty::Empty;
use crate::ui::ChatMsg;
use super::tree_blocks::{BlockId, Root, TreeBlocks};
use super::{widgets, Correction, Cursor, InnerTreeViewState};
const SCROLLOFF: i32 = 2;
const MIN_CONTENT_HEIGHT: i32 = 10;
fn scrolloff(height: i32) -> i32 {
let scrolloff = (height - MIN_CONTENT_HEIGHT).max(0) / 2;
scrolloff.min(SCROLLOFF)
}
impl<M: Msg + ChatMsg, S: MsgStore<M>> InnerTreeViewState<M, S> {
async fn cursor_path(&self, cursor: &Cursor<M::Id>) -> Path<M::Id> {
match cursor {
Cursor::Msg(id) => self.store.path(id).await,
Cursor::Bottom
| Cursor::Editor { parent: None, .. }
| Cursor::Pseudo { parent: None, .. } => Path::new(vec![M::last_possible_id()]),
Cursor::Editor {
parent: Some(parent),
..
}
| Cursor::Pseudo {
parent: Some(parent),
..
} => {
let mut path = self.store.path(parent).await;
path.push(M::last_possible_id());
path
}
}
}
fn cursor_line(&self, blocks: &TreeBlocks<M::Id>) -> i32 {
if let Cursor::Bottom = self.cursor {
// The value doesn't matter as it will always be ignored.
0
} else {
blocks
.blocks()
.find(&BlockId::from_cursor(&self.cursor))
.expect("no cursor found")
.top_line
}
}
fn contains_cursor(&self, blocks: &TreeBlocks<M::Id>) -> bool {
blocks
.blocks()
.find(&BlockId::from_cursor(&self.cursor))
.is_some()
}
fn editor_block(&self, nick: &str, frame: &mut Frame, indent: usize) -> Block<BlockId<M::Id>> {
let (widget, cursor_row) = widgets::editor::<M>(frame, indent, nick, &self.editor);
let cursor_row = cursor_row as i32;
Block::new(frame, BlockId::Cursor, widget).focus(cursor_row..cursor_row + 1)
}
fn pseudo_block(&self, nick: &str, frame: &mut Frame, indent: usize) -> Block<BlockId<M::Id>> {
let widget = widgets::pseudo::<M>(indent, nick, &self.editor);
Block::new(frame, BlockId::Cursor, widget)
}
fn layout_subtree(
&self,
nick: &str,
frame: &mut Frame,
tree: &Tree<M>,
indent: usize,
id: &M::Id,
blocks: &mut TreeBlocks<M::Id>,
) {
// Ghost cursor in front, for positioning according to last cursor line
if self.last_cursor.refers_to(id) {
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
blocks.blocks_mut().push_back(block);
}
// Main message body
let highlighted = self.cursor.refers_to(id);
let widget = if let Some(msg) = tree.msg(id) {
widgets::msg(highlighted, indent, msg)
} else {
widgets::msg_placeholder(highlighted, indent)
};
let block = Block::new(frame, BlockId::Msg(id.clone()), widget);
blocks.blocks_mut().push_back(block);
// Children, recursively
if let Some(children) = tree.children(id) {
for child in children {
self.layout_subtree(nick, frame, tree, indent + 1, child, blocks);
}
}
// Trailing ghost cursor, for positioning according to last cursor line
if self.last_cursor.refers_to_last_child_of(id) {
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
blocks.blocks_mut().push_back(block);
}
// Trailing editor or pseudomessage
if self.cursor.refers_to_last_child_of(id) {
match self.cursor {
Cursor::Editor { .. } => {
blocks
.blocks_mut()
.push_back(self.editor_block(nick, frame, indent + 1))
}
Cursor::Pseudo { .. } => {
blocks
.blocks_mut()
.push_back(self.pseudo_block(nick, frame, indent + 1))
}
_ => {}
}
}
}
fn layout_tree(&self, nick: &str, frame: &mut Frame, tree: Tree<M>) -> TreeBlocks<M::Id> {
let root = Root::Tree(tree.root().clone());
let mut blocks = TreeBlocks::new(root.clone(), root);
self.layout_subtree(nick, frame, &tree, 0, tree.root(), &mut blocks);
blocks
}
fn layout_bottom(&self, nick: &str, frame: &mut Frame) -> TreeBlocks<M::Id> {
let mut blocks = TreeBlocks::new(Root::Bottom, Root::Bottom);
// Ghost cursor, for positioning according to last cursor line
if let Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } =
self.last_cursor
{
let block = Block::new(frame, BlockId::LastCursor, Empty::new());
blocks.blocks_mut().push_back(block);
}
match self.cursor {
Cursor::Bottom => {
let block = Block::new(frame, BlockId::Cursor, Empty::new());
blocks.blocks_mut().push_back(block);
}
Cursor::Editor { parent: None, .. } => blocks
.blocks_mut()
.push_back(self.editor_block(nick, frame, 0)),
Cursor::Pseudo { parent: None, .. } => blocks
.blocks_mut()
.push_back(self.pseudo_block(nick, frame, 0)),
_ => {}
}
blocks
}
async fn expand_to_top(&self, nick: &str, frame: &mut Frame, blocks: &mut TreeBlocks<M::Id>) {
let top_line = 0;
while blocks.blocks().top_line > top_line {
let top_root = blocks.top_root();
let prev_tree_id = match top_root {
Root::Bottom => self.store.last_tree_id().await,
Root::Tree(tree_id) => self.store.prev_tree_id(tree_id).await,
};
let prev_tree_id = match prev_tree_id {
Some(tree_id) => tree_id,
None => break,
};
let prev_tree = self.store.tree(&prev_tree_id).await;
blocks.prepend(self.layout_tree(nick, frame, prev_tree));
}
}
async fn expand_to_bottom(
&self,
nick: &str,
frame: &mut Frame,
blocks: &mut TreeBlocks<M::Id>,
) {
let bottom_line = frame.size().height as i32 - 1;
while blocks.blocks().bottom_line < bottom_line {
let bottom_root = blocks.bottom_root();
let next_tree_id = match bottom_root {
Root::Bottom => break,
Root::Tree(tree_id) => self.store.next_tree_id(tree_id).await,
};
if let Some(next_tree_id) = next_tree_id {
let next_tree = self.store.tree(&next_tree_id).await;
blocks.append(self.layout_tree(nick, frame, next_tree));
} else {
blocks.append(self.layout_bottom(nick, frame));
}
}
}
async fn fill_screen_and_clamp_scrolling(
&self,
nick: &str,
frame: &mut Frame,
blocks: &mut TreeBlocks<M::Id>,
) {
let top_line = 0;
let bottom_line = frame.size().height as i32 - 1;
self.expand_to_top(nick, frame, blocks).await;
if blocks.blocks().top_line > top_line {
blocks.blocks_mut().set_top_line(0);
}
self.expand_to_bottom(nick, frame, blocks).await;
if blocks.blocks().bottom_line < bottom_line {
blocks.blocks_mut().set_bottom_line(bottom_line);
}
self.expand_to_top(nick, frame, blocks).await;
}
async fn layout_last_cursor_seed(
&self,
nick: &str,
frame: &mut Frame,
last_cursor_path: &Path<M::Id>,
) -> TreeBlocks<M::Id> {
match &self.last_cursor {
Cursor::Bottom => {
let mut blocks = self.layout_bottom(nick, frame);
let bottom_line = frame.size().height as i32 - 1;
blocks.blocks_mut().set_bottom_line(bottom_line);
blocks
}
Cursor::Editor { parent: None, .. } | Cursor::Pseudo { parent: None, .. } => {
let mut blocks = self.layout_bottom(nick, frame);
blocks
.blocks_mut()
.recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line);
blocks
}
Cursor::Msg(_)
| Cursor::Editor {
parent: Some(_), ..
}
| Cursor::Pseudo {
parent: Some(_), ..
} => {
let root = last_cursor_path.first();
let tree = self.store.tree(root).await;
let mut blocks = self.layout_tree(nick, frame, tree);
blocks
.blocks_mut()
.recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line);
blocks
}
}
}
async fn layout_cursor_seed(
&self,
nick: &str,
frame: &mut Frame,
last_cursor_path: &Path<M::Id>,
cursor_path: &Path<M::Id>,
) -> TreeBlocks<M::Id> {
let bottom_line = frame.size().height as i32 - 1;
match &self.cursor {
Cursor::Bottom
| Cursor::Editor { parent: None, .. }
| Cursor::Pseudo { parent: None, .. } => {
let mut blocks = self.layout_bottom(nick, frame);
blocks.blocks_mut().set_bottom_line(bottom_line);
blocks
}
Cursor::Msg(_)
| Cursor::Editor {
parent: Some(_), ..
}
| Cursor::Pseudo {
parent: Some(_), ..
} => {
let root = cursor_path.first();
let tree = self.store.tree(root).await;
let mut blocks = self.layout_tree(nick, frame, tree);
let cursor_above_last = cursor_path < last_cursor_path;
let cursor_line = if cursor_above_last { 0 } else { bottom_line };
blocks
.blocks_mut()
.recalculate_offsets(&BlockId::from_cursor(&self.cursor), cursor_line);
blocks
}
}
}
async fn layout_initial_seed(
&self,
nick: &str,
frame: &mut Frame,
last_cursor_path: &Path<M::Id>,
cursor_path: &Path<M::Id>,
) -> TreeBlocks<M::Id> {
if let Cursor::Bottom = self.cursor {
self.layout_cursor_seed(nick, frame, last_cursor_path, cursor_path)
.await
} else {
self.layout_last_cursor_seed(nick, frame, last_cursor_path)
.await
}
}
fn scroll_so_cursor_is_visible(&self, frame: &mut Frame, blocks: &mut TreeBlocks<M::Id>) {
if matches!(self.cursor, Cursor::Bottom) {
return; // Cursor is locked to bottom
}
let block = blocks
.blocks()
.find(&BlockId::from_cursor(&self.cursor))
.expect("no cursor found");
let height = frame.size().height as i32;
let scrolloff = scrolloff(height);
let min_line = -block.focus.start + scrolloff;
let max_line = height - block.focus.end - scrolloff;
// If the message is higher than the available space, the top of the
// message should always be visible. I'm not using top_line.clamp(...)
// because the order of the min and max matters.
let top_line = block.top_line;
let new_top_line = top_line.min(max_line).max(min_line);
if new_top_line != top_line {
blocks.blocks_mut().offset(new_top_line - top_line);
}
}
/// Try to obtain a [`Cursor::Msg`] pointing to the block.
fn msg_id(block: &Block<BlockId<M::Id>>) -> Option<M::Id> {
match &block.id {
BlockId::Msg(id) => Some(id.clone()),
_ => None,
}
}
fn visible(block: &Block<BlockId<M::Id>>, first_line: i32, last_line: i32) -> bool {
(first_line + 1 - block.height..=last_line).contains(&block.top_line)
}
fn move_cursor_so_it_is_visible(
&mut self,
frame: &mut Frame,
blocks: &TreeBlocks<M::Id>,
) -> Option<M::Id> {
if !matches!(self.cursor, Cursor::Bottom | Cursor::Msg(_)) {
// In all other cases, there is no need to make the cursor visible
// since scrolling behaves differently enough.
return None;
}
let height = frame.size().height as i32;
let scrolloff = scrolloff(height);
let first_line = scrolloff;
let last_line = height - 1 - scrolloff;
let new_cursor = if matches!(self.cursor, Cursor::Bottom) {
blocks
.blocks()
.iter()
.rev()
.filter(|b| Self::visible(b, first_line, last_line))
.find_map(Self::msg_id)
} else {
let block = blocks
.blocks()
.find(&BlockId::from_cursor(&self.cursor))
.expect("no cursor found");
if Self::visible(block, first_line, last_line) {
return None;
} else if block.top_line < first_line {
blocks
.blocks()
.iter()
.filter(|b| Self::visible(b, first_line, last_line))
.find_map(Self::msg_id)
} else {
blocks
.blocks()
.iter()
.rev()
.filter(|b| Self::visible(b, first_line, last_line))
.find_map(Self::msg_id)
}
};
if let Some(id) = new_cursor {
self.cursor = Cursor::Msg(id.clone());
Some(id)
} else {
None
}
}
pub async fn relayout(&mut self, nick: &str, frame: &mut Frame) -> TreeBlocks<M::Id> {
// The basic idea is this:
//
// First, layout a full screen of blocks around self.last_cursor, using
// self.last_cursor_line for offset positioning. At this point, any
// outstanding scrolling is performed as well.
//
// Then, check if self.cursor is somewhere in these blocks. If it is, we
// now know the position of our own cursor. If it is not, it has jumped
// too far away from self.last_cursor and we'll need to render a new
// full screen of blocks around self.cursor before proceeding, using the
// cursor paths to determine the position of self.cursor on the screen.
//
// Now that we have a more-or-less accurate screen position of
// self.cursor, we can perform the actual cursor logic, i.e. make the
// cursor visible or move it so it is visible.
//
// This entire process is complicated by the different kinds of cursors.
let last_cursor_path = self.cursor_path(&self.last_cursor).await;
let cursor_path = self.cursor_path(&self.cursor).await;
let mut blocks = self
.layout_initial_seed(nick, frame, &last_cursor_path, &cursor_path)
.await;
blocks.blocks_mut().offset(self.scroll);
self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks)
.await;
if !self.contains_cursor(&blocks) {
blocks = self
.layout_cursor_seed(nick, frame, &last_cursor_path, &cursor_path)
.await;
self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks)
.await;
}
match self.correction {
Some(Correction::MakeCursorVisible) => {
self.scroll_so_cursor_is_visible(frame, &mut blocks);
self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks)
.await;
}
Some(Correction::MoveCursorToVisibleArea) => {
let new_cursor_msg_id = self.move_cursor_so_it_is_visible(frame, &blocks);
if let Some(cursor_msg_id) = new_cursor_msg_id {
// Moving the cursor invalidates our current blocks, so we sadly
// have to either perform an expensive operation or redraw the
// entire thing. I'm choosing the latter for now.
self.last_cursor = self.cursor.clone();
self.last_cursor_line = self.cursor_line(&blocks);
self.scroll = 0;
self.correction = None;
let last_cursor_path = self.store.path(&cursor_msg_id).await;
blocks = self
.layout_last_cursor_seed(nick, frame, &last_cursor_path)
.await;
self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks)
.await;
}
}
None => {}
}
self.last_cursor = self.cursor.clone();
self.last_cursor_line = self.cursor_line(&blocks);
self.scroll = 0;
self.correction = None;
blocks
}
}

View file

@ -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;
}
}

View file

@ -1,128 +0,0 @@
// TODO Remove mut in &mut Frame wherever applicable in this entire module
mod indent;
mod time;
use crossterm::style::{ContentStyle, Stylize};
use toss::frame::Frame;
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_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) -> BoxedWidget {
let (nick, content) = msg.styled();
HJoin::new(vec![
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) -> BoxedWidget {
HJoin::new(vec![
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((PLACEHOLDER, style_placeholder()))),
])
.into()
}
pub fn editor<M: ChatMsg>(
frame: &mut Frame,
indent: usize,
nick: &str,
editor: &EditorState,
) -> (BoxedWidget, usize) {
let (nick, content) = M::edit(nick, &editor.text());
let editor = editor.widget().highlight(|_| content);
let cursor_row = editor.cursor_row(frame);
let widget = HJoin::new(vec![
Segment::new(
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(
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()
}

View file

@ -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),
)
}
}
}

View file

@ -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()
}
}

View file

@ -1,123 +0,0 @@
use std::convert::Infallible;
use crossterm::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;
/// 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]
macro_rules! key {
// key!('a')
( $key:literal ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: false, } };
( Ctrl + $key:literal ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: true, alt: false, } };
( Alt + $key:literal ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: true, } };
// key!(Char(xyz))
( Char $key:pat ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: false, } };
( Ctrl + Char $key:pat ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: true, alt: false, } };
( Alt + Char $key:pat ) => { KeyEvent { code: KeyCode::Char($key), shift: _, ctrl: false, alt: true, } };
// key!(F(n))
( F $key:pat ) => { KeyEvent { code: KeyCode::F($key), shift: false, ctrl: false, alt: false, } };
( Shift + F $key:pat ) => { KeyEvent { code: KeyCode::F($key), shift: true, ctrl: false, alt: false, } };
( Ctrl + F $key:pat ) => { KeyEvent { code: KeyCode::F($key), shift: false, ctrl: true, alt: false, } };
( Alt + F $key:pat ) => { KeyEvent { code: KeyCode::F($key), shift: false, ctrl: false, alt: true, } };
// key!(other)
( $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: false, } };
( Shift + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: true, ctrl: false, alt: false, } };
( Ctrl + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: true, alt: false, } };
( Alt + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: true, } };
}
pub(crate) use key;
/// Helper wrapper around a list widget for a more consistent key binding style.
pub struct KeyBindingsList(List<Infallible>);
impl KeyBindingsList {
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(Text::new((binding, Self::binding_style()))).min_width(16)),
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(16)),
Segment::new(Text::new(description)),
]);
self.0.add_unsel(widget);
}
}

View file

@ -1,399 +0,0 @@
use std::iter;
use std::sync::Arc;
use crossterm::event::KeyCode;
use crossterm::style::{Color, ContentStyle, Stylize};
use parking_lot::FairMutex;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::{mpsc, oneshot};
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::euph::api::{SessionType, SessionView, Snowflake};
use crate::euph::{self, Joined, Status};
use crate::vault::EuphVault;
use super::chat::{ChatState, Reaction};
use super::input::{key, KeyBindingsList, KeyEvent};
use super::widgets::background::Background;
use super::widgets::border::Border;
use super::widgets::editor::EditorState;
use super::widgets::empty::Empty;
use super::widgets::float::Float;
use super::widgets::join::{HJoin, Segment, VJoin};
use super::widgets::layer::Layer;
use super::widgets::list::{List, ListState};
use super::widgets::padding::Padding;
use super::widgets::text::Text;
use super::widgets::BoxedWidget;
use super::UiEvent;
enum State {
Normal,
ChooseNick(EditorState),
}
pub struct EuphRoom {
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
room: Option<euph::Room>,
state: State,
chat: ChatState<euph::SmallMessage, EuphVault>,
last_msg_sent: Option<oneshot::Receiver<Snowflake>>,
nick_list: ListState<String>,
}
impl EuphRoom {
pub fn new(vault: EuphVault, ui_event_tx: mpsc::UnboundedSender<UiEvent>) -> Self {
Self {
ui_event_tx,
room: None,
state: State::Normal,
chat: ChatState::new(vault),
last_msg_sent: None,
nick_list: ListState::new(),
}
}
pub fn connect(&mut self) {
if self.room.is_none() {
self.room = Some(euph::Room::new(
self.chat.store().clone(),
self.ui_event_tx.clone(),
));
}
}
pub fn disconnect(&mut self) {
self.room = None;
}
pub async fn status(&self) -> Option<Option<Status>> {
if let Some(room) = &self.room {
room.status().await.ok()
} else {
None
}
}
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;
}
}
}
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;
}
}
}
}
pub async fn widget(&mut self) -> BoxedWidget {
self.stabilize_pseudo_msg().await;
let status = self.status().await;
let chat = match &status {
Some(Some(Status::Joined(joined))) => self.widget_with_nick_list(&status, joined),
_ => self.widget_without_nick_list(&status),
};
match &self.state {
State::Normal => chat,
State::ChooseNick(ed) => Layer::new(vec![
chat,
Float::new(Border::new(Background::new(
Padding::new(VJoin::new(vec![
Segment::new(Text::new("Choose nick ")),
Segment::new(
ed.widget()
.highlight(|s| Styled::new(s, euph::nick_style(s))),
),
]))
.left(1),
)))
.horizontal(0.5)
.vertical(0.5)
.into(),
])
.into(),
}
}
fn widget_without_nick_list(&self, status: &Option<Option<Status>>) -> BoxedWidget {
VJoin::new(vec![
Segment::new(Border::new(
Padding::new(self.status_widget(status)).horizontal(1),
)),
// TODO Use last known nick?
Segment::new(self.chat.widget(String::new())).expanding(true),
])
.into()
}
fn widget_with_nick_list(
&self,
status: &Option<Option<Status>>,
joined: &Joined,
) -> BoxedWidget {
HJoin::new(vec![
Segment::new(VJoin::new(vec![
Segment::new(Border::new(
Padding::new(self.status_widget(status)).horizontal(1),
)),
Segment::new(self.chat.widget(joined.session.name.clone())).expanding(true),
]))
.expanding(true),
Segment::new(Border::new(
Padding::new(self.nick_list_widget(joined)).right(1),
)),
])
.into()
}
fn status_widget(&self, status: &Option<Option<Status>>) -> BoxedWidget {
let room = self.chat.store().room();
let room_style = ContentStyle::default().bold().blue();
let mut info = Styled::new(format!("&{room}"), room_style);
info = match status {
None => info.then_plain(", archive"),
Some(None) => info.then_plain(", connecting..."),
Some(Some(Status::Joining(j))) if j.bounce.is_some() => {
info.then_plain(", auth required")
}
Some(Some(Status::Joining(_))) => info.then_plain(", joining..."),
Some(Some(Status::Joined(j))) => {
let nick = &j.session.name;
if nick.is_empty() {
info.then_plain(", present without nick")
} else {
let nick_style = euph::nick_style(nick);
info.then_plain(", present as ").then(nick, nick_style)
}
}
};
Text::new(info).into()
}
fn render_nick_list_row(
list: &mut List<String>,
session: &SessionView,
own_session: &SessionView,
) {
let id = session.session_id.clone();
let (name, style, style_inv) = if session.name.is_empty() {
let name = "lurk";
let style = ContentStyle::default().grey();
let style_inv = ContentStyle::default().black().on_grey();
(name, style, style_inv)
} else {
let name = &session.name as &str;
let (r, g, b) = euph::nick_color(name);
let color = Color::Rgb { r, g, b };
let style = ContentStyle::default().bold().with(color);
let style_inv = ContentStyle::default().bold().black().on(color);
(name, style, style_inv)
};
let perms = if session.is_staff {
"!"
} else if session.is_manager {
"*"
} else if session.id.session_type() == Some(SessionType::Account) {
"~"
} else {
""
};
let owner = if session.session_id == own_session.session_id {
">"
} else {
" "
};
let normal = Styled::new_plain(owner).then(name, style).then_plain(perms);
let selected = Styled::new_plain(owner)
.then(name, style_inv)
.then_plain(perms);
list.add_sel(
id,
Text::new(normal),
Background::new(Text::new(selected)).style(style_inv),
);
}
fn render_nick_list_section(
list: &mut List<String>,
name: &str,
sessions: &[&SessionView],
own_session: &SessionView,
) {
if sessions.is_empty() {
return;
}
let heading_style = ContentStyle::new().bold();
if !list.is_empty() {
list.add_unsel(Empty::new());
}
let row = Styled::new_plain(" ")
.then(name, heading_style)
.then_plain(format!(" ({})", sessions.len()));
list.add_unsel(Text::new(row));
for session in sessions {
Self::render_nick_list_row(list, session, own_session);
}
}
fn render_nick_list_rows(list: &mut List<String>, joined: &Joined) {
let mut people = vec![];
let mut bots = vec![];
let mut lurkers = vec![];
let mut nurkers = vec![];
let mut sessions = iter::once(&joined.session)
.chain(joined.listing.values())
.collect::<Vec<_>>();
sessions.sort_unstable_by_key(|s| &s.name);
for sess in sessions {
match sess.id.session_type() {
Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess),
Some(SessionType::Bot) => bots.push(sess),
_ if sess.name.is_empty() => lurkers.push(sess),
_ => people.push(sess),
}
}
people.sort_unstable_by_key(|s| (&s.name, &s.session_id));
bots.sort_unstable_by_key(|s| (&s.name, &s.session_id));
lurkers.sort_unstable_by_key(|s| &s.session_id);
nurkers.sort_unstable_by_key(|s| &s.session_id);
Self::render_nick_list_section(list, "People", &people, &joined.session);
Self::render_nick_list_section(list, "Bots", &bots, &joined.session);
Self::render_nick_list_section(list, "Lurkers", &lurkers, &joined.session);
Self::render_nick_list_section(list, "Nurkers", &nurkers, &joined.session);
}
fn nick_list_widget(&self, joined: &Joined) -> BoxedWidget {
let mut list = self.nick_list.widget();
Self::render_nick_list_rows(&mut list, joined);
list.into()
}
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.heading("Room");
match &self.state {
State::Normal => {
// TODO Use if-let chain
bindings.binding("esc", "leave room");
let can_compose = if let Some(room) = &self.room {
if let Ok(Some(Status::Joined(_))) = room.status().await {
bindings.binding("n", "change nick");
true
} else {
false
}
} else {
false
};
bindings.empty();
self.chat.list_key_bindings(bindings, can_compose).await;
}
State::ChooseNick(_) => {
bindings.binding("esc", "abort");
bindings.binding("enter", "set nick");
bindings.binding("←/→", "move cursor left/right");
bindings.binding("backspace", "delete before cursor");
bindings.binding("delete", "delete after cursor");
}
}
}
pub async fn handle_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> bool {
match &self.state {
State::Normal => {
// TODO Use if-let chain
if let Some(room) = &self.room {
if let Ok(Some(Status::Joined(joined))) = room.status().await {
match self
.chat
.handle_key_event(terminal, crossterm_lock, event, true)
.await
{
Reaction::NotHandled => {}
Reaction::Handled => return true,
Reaction::Composed { parent, content } => {
match room.send(parent, content) {
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
Err(_) => self.chat.sent(None).await,
}
return true;
}
}
if let key!('n') | key!('N') = event {
self.state = State::ChooseNick(EditorState::with_initial_text(
joined.session.name.clone(),
));
return true;
}
return false;
}
}
self.chat
.handle_key_event(terminal, crossterm_lock, event, false)
.await
.handled()
}
State::ChooseNick(ed) => {
match event {
key!(Esc) => self.state = State::Normal,
key!(Enter) => {
if let Some(room) = &self.room {
let _ = room.nick(ed.text());
}
self.state = State::Normal;
}
key!(Char ch) => ed.insert_char(ch),
key!(Backspace) => ed.backspace(),
key!(Left) => ed.move_cursor_left(),
key!(Right) => ed.move_cursor_right(),
key!(Delete) => ed.delete(),
_ => return false,
}
true
}
}
}
}

View file

@ -1,325 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::iter;
use std::sync::Arc;
use crossterm::event::KeyCode;
use crossterm::style::{ContentStyle, Stylize};
use parking_lot::FairMutex;
use tokio::sync::mpsc;
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::euph::api::SessionType;
use crate::euph::{Joined, Status};
use crate::vault::Vault;
use super::input::{key, KeyBindingsList, KeyEvent};
use super::room::EuphRoom;
use super::widgets::background::Background;
use super::widgets::border::Border;
use super::widgets::editor::EditorState;
use super::widgets::float::Float;
use super::widgets::join::{HJoin, Segment, VJoin};
use super::widgets::layer::Layer;
use super::widgets::list::{List, ListState};
use super::widgets::padding::Padding;
use super::widgets::text::Text;
use super::widgets::BoxedWidget;
use super::UiEvent;
enum State {
ShowList,
ShowRoom(String),
Connect(EditorState),
}
pub struct Rooms {
vault: Vault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
state: State,
list: ListState<String>,
euph_rooms: HashMap<String, EuphRoom>,
}
impl Rooms {
pub fn new(vault: Vault, ui_event_tx: mpsc::UnboundedSender<UiEvent>) -> Self {
Self {
vault,
ui_event_tx,
state: State::ShowList,
list: ListState::new(),
euph_rooms: HashMap::new(),
}
}
/// Remove rooms that are not running any more and can't be found in the db.
///
/// These kinds of rooms are either
/// - failed connection attempts, or
/// - rooms that were deleted from the db.
async fn stabilize_rooms(&mut self) {
let rooms_set = self
.vault
.euph_rooms()
.await
.into_iter()
.collect::<HashSet<_>>();
self.euph_rooms
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
for room in self.euph_rooms.values_mut() {
room.retain();
}
}
async fn room_names(&self) -> Vec<String> {
let mut rooms = self.vault.euph_rooms().await;
for room in self.euph_rooms.keys() {
rooms.push(room.clone());
}
rooms.sort_unstable();
rooms.dedup();
rooms
}
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
self.euph_rooms
.entry(name.clone())
.or_insert_with(|| EuphRoom::new(self.vault.euph(name), self.ui_event_tx.clone()))
}
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.get_or_insert_room(name.clone()).widget().await,
State::Connect(ed) => {
let room_style = ContentStyle::default().bold().blue();
Layer::new(vec![
self.rooms_widget().await,
Float::new(Border::new(Background::new(
Padding::new(VJoin::new(vec![
Segment::new(Text::new("Connect to ")),
Segment::new(HJoin::new(vec![
Segment::new(Text::new(("&", room_style))),
Segment::new(ed.widget().highlight(|s| Styled::new(s, room_style))),
])),
]))
.left(1),
)))
.horizontal(0.5)
.vertical(0.5)
.into(),
])
.into()
}
}
}
fn format_pbln(joined: &Joined) -> String {
let mut p = 0_usize;
let mut b = 0_usize;
let mut l = 0_usize;
let mut n = 0_usize;
for sess in iter::once(&joined.session).chain(joined.listing.values()) {
match sess.id.session_type() {
Some(SessionType::Bot) if sess.name.is_empty() => n += 1,
Some(SessionType::Bot) => b += 1,
_ if sess.name.is_empty() => l += 1,
_ => p += 1,
}
}
// There must always be either one p, b, l or n since we're including
// ourselves.
let mut result = vec![];
if p > 0 {
result.push(format!("{p}p"));
}
if b > 0 {
result.push(format!("{b}b"));
}
if l > 0 {
result.push(format!("{l}l"));
}
if n > 0 {
result.push(format!("{n}n"));
}
result.join(" ")
}
fn format_status(status: &Option<Status>) -> String {
match status {
None => " (connecting)".to_string(),
Some(Status::Joining(j)) if j.bounce.is_some() => " (auth required)".to_string(),
Some(Status::Joining(_)) => " (joining)".to_string(),
Some(Status::Joined(j)) => format!(" ({})", Self::format_pbln(j)),
}
}
async fn render_rows(&self, list: &mut List<String>, rooms: Vec<String>) {
let heading_style = ContentStyle::default().bold();
let heading = Styled::new("Rooms", heading_style).then_plain(format!(" ({})", rooms.len()));
list.add_unsel(Text::new(heading));
if rooms.is_empty() {
list.add_unsel(Text::new((
"Press F1 for key bindings",
ContentStyle::default().grey().italic(),
)))
}
for room in rooms {
let bg_style = ContentStyle::default();
let bg_sel_style = ContentStyle::default().black().on_white();
let room_style = ContentStyle::default().bold().blue();
let room_sel_style = ContentStyle::default().bold().black().on_white();
let mut normal = Styled::new(format!("&{room}"), room_style);
let mut selected = Styled::new(format!("&{room}"), room_sel_style);
if let Some(room) = self.euph_rooms.get(&room) {
if let Some(status) = room.status().await {
let status = Self::format_status(&status);
normal = normal.then(status.clone(), bg_style);
selected = selected.then(status, bg_sel_style);
}
};
list.add_sel(
room,
Text::new(normal),
Background::new(Text::new(selected)).style(bg_sel_style),
);
}
}
async fn rooms_widget(&self) -> BoxedWidget {
let rooms = self.room_names().await;
let mut list = self.list.widget().focus(true);
self.render_rows(&mut list, rooms).await;
list.into()
}
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
match &self.state {
State::ShowList => {
bindings.heading("Rooms");
bindings.binding("j/k, ↓/↑", "move cursor up/down");
bindings.binding("g, home", "move cursor to top");
bindings.binding("G, end", "move cursor to bottom");
bindings.binding("ctrl+y/e", "scroll up/down");
bindings.empty();
bindings.binding("enter", "enter selected room");
bindings.binding("c", "connect to selected room");
bindings.binding("C", "connect to new room");
bindings.binding("d", "disconnect from selected room");
bindings.binding("D", "delete room");
}
State::ShowRoom(name) => {
// Key bindings for leaving the room are a part of the room's
// list_key_bindings function since they may be shadowed by the
// nick selector or message editor.
if let Some(room) = self.euph_rooms.get(name) {
room.list_key_bindings(bindings).await;
} else {
// There should always be a room here already but I don't
// really want to panic in case it is not. If I show a
// message like this, it'll hopefully be reported if
// somebody ever encounters it.
bindings.binding_ctd("oops, this text should never be visible")
}
}
State::Connect(_) => {
bindings.heading("Rooms");
bindings.binding("esc", "abort");
bindings.binding("enter", "connect to room");
bindings.binding("←/→", "move cursor left/right");
bindings.binding("backspace", "delete before cursor");
bindings.binding("delete", "delete after cursor");
}
}
}
pub async fn handle_key_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> bool {
match &self.state {
State::ShowList => match event {
key!('k') | key!(Up) => self.list.move_cursor_up(),
key!('j') | key!(Down) => self.list.move_cursor_down(),
key!('g') | key!(Home) => self.list.move_cursor_to_top(),
key!('G') | key!(End) => self.list.move_cursor_to_bottom(),
key!(Ctrl + 'y') => self.list.scroll_up(1),
key!(Ctrl + 'e') => self.list.scroll_down(1),
key!(Enter) => {
if let Some(name) = self.list.cursor() {
self.state = State::ShowRoom(name);
}
}
key!('c') => {
if let Some(name) = self.list.cursor() {
self.get_or_insert_room(name).connect();
}
}
key!('C') => self.state = State::Connect(EditorState::new()),
key!('d') => {
if let Some(name) = self.list.cursor() {
self.get_or_insert_room(name).disconnect();
}
}
key!('D') => {
// TODO Check whether user wanted this via popup
if let Some(name) = self.list.cursor() {
self.euph_rooms.remove(&name);
self.vault.euph(name.clone()).delete();
}
}
_ => return false,
},
State::ShowRoom(name) => {
if self
.get_or_insert_room(name.clone())
.handle_key_event(terminal, crossterm_lock, event)
.await
{
return true;
}
if let key!(Esc) = event {
self.state = State::ShowList;
return true;
}
return false;
}
State::Connect(ed) => match event {
key!(Esc) => self.state = State::ShowList,
key!(Enter) => {
let name = ed.text();
if !name.is_empty() {
self.get_or_insert_room(name.clone()).connect();
self.state = State::ShowRoom(name);
}
}
key!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => ed.insert_char(ch),
key!(Left) => ed.move_cursor_left(),
key!(Right) => ed.move_cursor_right(),
key!(Backspace) => ed.backspace(),
key!(Delete) => ed.delete(),
_ => return false,
},
}
true
}
}

View file

@ -1,27 +0,0 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
pub fn prompt(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
initial_text: &str,
) -> Option<String> {
let content = {
let _guard = crossterm_lock.lock();
terminal.suspend().expect("could not suspend");
let content = edit::edit(initial_text);
terminal.unsuspend().expect("could not unsuspend");
content
};
// TODO Don't swipe this error under the rug
let content = content.ok()?;
if content.trim().is_empty() {
None
} else {
Some(content)
}
}

View file

@ -1,34 +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.
#![allow(dead_code)]
pub mod background;
pub mod border;
pub mod editor;
pub mod empty;
pub mod float;
pub mod join;
pub mod layer;
pub mod list;
pub mod padding;
pub mod resize;
pub mod rules;
pub mod text;
use async_trait::async_trait;
use toss::frame::{Frame, Size};
#[async_trait]
pub trait Widget {
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size;
async fn render(self: Box<Self>, frame: &mut Frame);
}
pub type BoxedWidget = Box<dyn Widget + Send>;
impl<W: 'static + Widget + Send> From<W> for BoxedWidget {
fn from(widget: W) -> Self {
Box::new(widget)
}
}

View file

@ -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;
}
}

View file

@ -1,49 +0,0 @@
use async_trait::async_trait;
use toss::frame::{Frame, Pos, Size};
use super::{BoxedWidget, Widget};
pub struct Border(BoxedWidget);
impl Border {
pub fn new<W: Into<BoxedWidget>>(inner: W) -> Self {
Self(inner.into())
}
}
#[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.0.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), "");
frame.write(Pos::new(right, 0), "");
frame.write(Pos::new(0, bottom), "");
frame.write(Pos::new(right, bottom), "");
for y in 1..bottom {
frame.write(Pos::new(0, y), "");
frame.write(Pos::new(right, y), "");
}
for x in 1..right {
frame.write(Pos::new(x, 0), "");
frame.write(Pos::new(x, bottom), "");
}
frame.push(Pos::new(1, 1), size - Size::new(2, 2));
self.0.render(frame).await;
frame.pop();
}
}

Some files were not shown because too many files have changed in this diff Show more