Compare commits

...

307 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
116 changed files with 13265 additions and 10101 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

@ -4,19 +4,228 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Procedure when bumping the version number:
1. Update dependencies in a separate commit
2. Set version number in `Cargo.toml`
3. Add new section in this changelog
4. Commit with message `Bump version to X.Y.Z`
5. Create tag named `vX.Y.Z`
6. Fast-forward branch `latest`
7. Push `master`, `latest` and the new tag
4. Run `cargo run help-config > CONFIG.md`
5. Commit with message `Bump version to X.Y.Z`
6. Create tag named `vX.Y.Z`
7. Push `master` and the new tag
## Unreleased
### Changed
- Display emoji user id hashes in the nick list
- Compile linux binary with older glibc version
## v0.9.3 - 2025-05-31
### Added
- Key bindings for emoji-based user id hashing
### Fixed
- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms
## v0.9.2 - 2025-03-14
### Added
- `bell_on_mention` config option
## v0.9.1 - 2025-03-01
### Fixed
- Rendering glitches with unicode-based width estimation
## v0.9.0 - 2025-02-23
### Added
- Unicode-based grapheme width estimation method
- `width_estimation_method` config option
- `--width-estimation-method` option
- Room links are now included in the `I` message links list
### Changed
- Updated documentation for `time_zone` config option
- When connecting to a room using `n` in the room list, the cursor now moves to that room
- Updated list of emoji names
### Removed
- Special handling of &rl2dev
### Fixed
- Nick color in rare edge cases
- Message link list rendering bug
## v0.8.3 - 2024-05-20
### Changed
- Updated list of emoji names
## v0.8.2 - 2024-04-25
### Changed
- Renamed `json-stream` export format to `json-lines` (see <https://jsonlines.org/>)
- Changed `json-lines` file extension from `.json` to `.jsonl`
### Fixed
- Crash when window is too small while empty message editor is visible
- Mistakes in output and docs
- Cove not cleaning up terminal state properly
## v0.8.1 - 2024-01-11
### Added
- Support for setting window title
- More information to room list heading
- Key bindings for live caesar cipher de- and encoding
### Removed
- Key binding to open present page
## v0.8.0 - 2024-01-04
### Added
- Support for multiple euph server domains
- Support for `TZ` environment variable
- `time_zone` config option
- `--domain` option to `cove export` command
- `--domain` option to `cove clear-cookies` command
- Domain field to "connect to new room" popup
- Welcome info box next to room list
### Changed
- The default euph domain is now https://euphoria.leet.nu/ everywhere
- The config file format was changed to support multiple euph servers with different domains.
Options previously located at `euph.rooms.*` should be reviewed and moved to `euph.servers."euphoria.leet.nu".rooms.*`.
- Tweaked F1 popup
- Tweaked chat message editor when nick list is foused
- Reduced connection timeout from 30 seconds to 10 seconds
### Fixed
- Room deletion popup accepting any room name
- Duplicated key presses on Windows
## v0.7.1 - 2023-08-31
### Changed
- Updated dependencies
## v0.7.0 - 2023-05-14
### Added
- Auto-generated config documentation
- in [CONFIG.md](CONFIG.md)
- via `help-config` CLI command
- `keys.*` config options
- `measure_widths` config option
### Changed
- Overhauled widget system and extracted generic widgets to [toss](https://github.com/Garmelon/toss)
- Overhauled config system to support auto-generating documentation
- Overhauled key binding system to make key bindings configurable
- Redesigned F1 popup. It can now be toggled with F1 like the F12 log
- The F12 log can now be closed with escape
- Some more small UI fixes and adjustments to the new key binding system
- Reduced tearing when redrawing screen
- Split up project into sub-crates
- Simplified flake dependencies
## v0.6.1 - 2023-04-10
### Changed
- Improved JSON export performance
- Always show rooms from config file in room list
### Fixed
- Rooms reconnecting instead of showing error popups
## v0.6.0 - 2023-04-04
### Added
- Emoji support
- `flake.nix`, making cove available as a nix flake
- `json-stream` room export format
- 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
@ -32,11 +241,17 @@ Procedure when bumping the version number:
- Key bindings to view and open links in a message
### Changed
- Some key bindings in the rooms list
### Fixed
- Rooms being stuck in "Connecting" state
## v0.3.0 - 2022-08-22
### Added
- Account login and logout
- Authentication dialog for password-protected rooms
- Error popups in rooms when something goes wrong
@ -44,10 +259,12 @@ Procedure when bumping the version number:
- Key binding to download more logs
### Changed
- Reduced amount of unnecessary redraws
- Description of `export` CLI command
### Fixed
- Crash when connecting to nonexistent rooms
- Crash when connecting to rooms that require authentication
- Pasting multi-line strings into the editor
@ -55,15 +272,18 @@ Procedure when bumping the version number:
## v0.2.1 - 2022-08-11
### Added
- Support for modifiers on special keys via the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
### Fixed
- Joining new rooms no longer crashes cove
- Scrolling when exiting message editor
## v0.2.0 - 2022-08-10
### Added
- New messages are now marked as unseen
- Sub-trees can now be folded
- Support for pasting text into editors
@ -76,10 +296,12 @@ Procedure when bumping the version number:
- Support for exporting multiple/all rooms at once
### Changed
- Reorganized export command
- Slowed down room history download speed
### Fixed
- Chat rendering when deleting and re-joining a room
- Spacing in some popups

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

1659
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

135
README.md
View file

@ -1,11 +1,16 @@
# cove
Cove is a TUI client for [euphoria.io](https://euphoria.io/), a threaded
Cove is a TUI client for [euphoria.leet.nu](https://euphoria.leet.nu/), a threaded
real-time chat platform.
![A very meta screenshot](screenshot.png)
It runs on Linux, Windows and macOS.
It runs on Linux, Windows, and macOS.
## Installing cove
Download a binary of your choice from the
[latest release on GitHub](https://github.com/Garmelon/cove/releases/latest).
## Using cove
@ -18,131 +23,11 @@ things in) won't automatically shrink. If it takes up too much space, try
running `cove gc` and waiting for it to finish. This isn't done automatically
because it can take quite a while.
## Manual installation
## Configuring 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.
### Installing rustup
Cove is written in Rust, so the first step is to install rustup. Either install
it from your package manager of choice (if you have one) or use the
[installer](https://rustup.rs/).
Test your installation by running `rustup --version` and `cargo --version`. If
rustup is installed correctly, both of these should show a version number.
Cove is designed on the current version of the stable toolchain. If cove doesn't
compile, you can try switching to the stable toolchain and updating it using the
following commands:
```bash
$ rustup default stable
$ rustup update
```
### Installing cove
To install or update to the latest release of cove, run the following command:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --branch latest
```
If you like to live dangerously and want to install or update to the latest,
bleeding-edge, possibly-broken commit from the repo's main branch, run the
following command.
**Warning:** This could corrupt your vault. Make sure to make a backup before
running the command.
```bash
$ cargo install --force --git https://github.com/Garmelon/cove
```
To install a specific version of cove, run the following command and substitute
in the full version you want to install:
```bash
$ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0
```
## Config file
Cove's config file uses the [TOML](https://toml.io/) format.
A complete list of config options is available in the [CONFIG.md](CONFIG.md)
file or via `cove help-config`.
When launched, cove prints the location it is loading its config file from. To
configure cove, create a config file at that location. This location can be
changed via the `--config` command line option.
The following is a complete list of available options. If a command line option
with the same purpose exists, it takes precedence over the option specified in
the config file.
### `data_dir`
**Type:** String (representing path)
**Default:** Platform dependent
The directory that cove stores its data in when not running in ephemeral mode.
Relative paths are interpreted relative to the user's home directory.
See also the `--data-dir` command line option.
### `ephemeral`
**Type:** Boolean
**Default:** `false`
Whether to start in ephemeral mode.
In ephemeral mode, cove doesn't store any data. It completely ignores any
options related to the data dir.
See also the `--ephemeral` command line option.
### `offline`
**Type:** Boolean
**Default:** `false`
Whether to start in offline mode.
In offline mode, cove won't automatically join rooms marked via the `autojoin`
option on startup. You can still join those rooms manually by pressing `a` in
the rooms list.
See also the `--offline` command line option.
### `euph.rooms.<room>.autojoin`
**Type:** Boolean
**Default:** `false`
Whether to automatically join this room on startup.
### `euph.rooms.<room>.username`
**Type:** String
**Default:** Not st
If set, cove will set this username upon joining if there is no username
associated with the current session.
### `euph.rooms.<room>.force_username`
**Type:** Boolean
**Default:** `false`
If `euph.rooms.<room>.username` is set, this will force cove to set the username
even if there is already a different username associated with the current
session.
### `euph.rooms.<room>.password`
**Type:** String
**Default:** Not st
If set, cove will try once to use this password to authenticate, should the room
be password-protected.

0
cove-config/CONFIG.md Normal file
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

View file

@ -1,7 +1,9 @@
mod room;
mod small_message;
mod util;
pub use highlight::*;
pub use room::*;
pub use small_message::*;
pub use util::*;
mod highlight;
mod room;
mod small_message;
mod util;

211
cove/src/euph/highlight.rs Normal file
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;
@ -11,6 +8,10 @@ pub trait Msg {
fn parent(&self) -> Option<Self::Id>;
fn seen(&self) -> bool;
fn nick_emoji(&self) -> Option<String> {
None
}
fn last_possible_id() -> Self::Id;
}
@ -27,12 +28,12 @@ impl<I> Path<I> {
self.0.iter().take(self.0.len() - 1)
}
pub fn push(&mut self, segment: I) {
self.0.push(segment)
pub fn first(&self) -> &I {
self.0.first().expect("path is empty")
}
pub fn first(&self) -> &I {
self.0.first().expect("path is not empty")
pub fn into_first(self) -> I {
self.0.into_iter().next().expect("path is empty")
}
}
@ -130,24 +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 msg(&self, id: &M::Id) -> Option<M>;
async fn tree(&self, tree_id: &M::Id) -> Tree<M>;
async fn first_tree_id(&self) -> Option<M::Id>;
async fn last_tree_id(&self) -> Option<M::Id>;
async fn prev_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>;
async fn next_tree_id(&self, tree_id: &M::Id) -> Option<M::Id>;
async fn oldest_msg_id(&self) -> Option<M::Id>;
async fn newest_msg_id(&self) -> Option<M::Id>;
async fn older_msg_id(&self, id: &M::Id) -> Option<M::Id>;
async fn newer_msg_id(&self, id: &M::Id) -> Option<M::Id>;
async fn oldest_unseen_msg_id(&self) -> Option<M::Id>;
async fn newest_unseen_msg_id(&self) -> Option<M::Id>;
async fn older_unseen_msg_id(&self, id: &M::Id) -> Option<M::Id>;
async fn newer_unseen_msg_id(&self, id: &M::Id) -> Option<M::Id>;
async fn unseen_msgs_count(&self) -> usize;
async fn set_seen(&self, id: &M::Id, seen: bool);
async fn set_older_seen(&self, id: &M::Id, seen: bool);
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(())
}
}

View file

@ -1,5 +1,6 @@
mod account;
mod auth;
mod inspect;
mod links;
mod nick;
mod nick_list;

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

View file

@ -1,28 +1,32 @@
use rusqlite::Connection;
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
eprintln!("Preparing vault");
// Cache ids of tree roots.
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_trees (
domain TEXT NOT NULL,
room TEXT NOT NULL,
id INT NOT NULL,
PRIMARY KEY (room, id)
PRIMARY KEY (domain, room, id)
) STRICT;
INSERT INTO euph_trees (room, id)
SELECT room, id
INSERT INTO euph_trees (domain, room, id)
SELECT domain, room, id
FROM euph_msgs
WHERE parent IS NULL
UNION
SELECT room, parent
SELECT domain, room, parent
FROM euph_msgs
WHERE parent IS NOT NULL
AND NOT EXISTS(
SELECT *
FROM euph_msgs AS parents
WHERE parents.room = euph_msgs.room
WHERE parents.domain = euph_msgs.domain
AND parents.room = euph_msgs.room
AND parents.id = euph_msgs.parent
);
@ -30,15 +34,16 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_trees
WHERE room = old.room;
WHERE domain = old.domain
AND room = old.room;
END;
CREATE TEMPORARY TRIGGER et_insert_msg_without_parent
AFTER INSERT ON main.euph_msgs
WHEN new.parent IS NULL
BEGIN
INSERT OR IGNORE INTO euph_trees (room, id)
VALUES (new.room, new.id);
INSERT OR IGNORE INTO euph_trees (domain, room, id)
VALUES (new.domain, new.room, new.id);
END;
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
@ -46,16 +51,18 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
WHEN new.parent IS NOT NULL
BEGIN
DELETE FROM euph_trees
WHERE room = new.room
WHERE domain = new.domain
AND room = new.room
AND id = new.id;
INSERT OR IGNORE INTO euph_trees (room, id)
INSERT OR IGNORE INTO euph_trees (domain, room, id)
SELECT *
FROM (VALUES (new.room, new.parent))
FROM (VALUES (new.domain, new.room, new.parent))
WHERE NOT EXISTS(
SELECT *
FROM euph_msgs
WHERE room = new.room
WHERE domain = new.domain
AND room = new.room
AND id = new.parent
AND parent IS NOT NULL
);
@ -67,35 +74,37 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
conn.execute_batch(
"
CREATE TEMPORARY TABLE euph_unseen_counts (
domain TEXT NOT NULL,
room TEXT NOT NULL,
amount INTEGER NOT NULL,
PRIMARY KEY (room)
PRIMARY KEY (domain, room)
) STRICT;
-- There must be an entry for every existing room.
INSERT INTO euph_unseen_counts (room, amount)
SELECT room, 0
INSERT INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, 0
FROM euph_rooms;
INSERT OR REPLACE INTO euph_unseen_counts (room, amount)
SELECT room, COUNT(*)
INSERT OR REPLACE INTO euph_unseen_counts (domain, room, amount)
SELECT domain, room, COUNT(*)
FROM euph_msgs
WHERE NOT seen
GROUP BY room;
GROUP BY domain, room;
CREATE TEMPORARY TRIGGER euc_insert_room
AFTER INSERT ON main.euph_rooms
BEGIN
INSERT INTO euph_unseen_counts (room, amount)
VALUES (new.room, 0);
INSERT INTO euph_unseen_counts (domain, room, amount)
VALUES (new.domain, new.room, 0);
END;
CREATE TEMPORARY TRIGGER euc_delete_room
AFTER DELETE ON main.euph_rooms
BEGIN
DELETE FROM euph_unseen_counts
WHERE room = old.room;
WHERE domain = old.domain
AND room = old.room;
END;
CREATE TEMPORARY TRIGGER euc_insert_msg
@ -104,7 +113,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
BEGIN
UPDATE euph_unseen_counts
SET amount = amount + 1
WHERE room = new.room;
WHERE domain = new.domain
AND room = new.room;
END;
CREATE TEMPORARY TRIGGER euc_update_msg
@ -113,7 +123,8 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
BEGIN
UPDATE euph_unseen_counts
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
WHERE room = new.room;
WHERE domain = new.domain
AND room = new.room;
END;
",
)?;

2
cove/src/version.rs Normal file
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,51 +0,0 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::macros::ok_or_return;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EuphRoom {
// TODO Mark favourite rooms via printable ascii characters
#[serde(default)]
pub autojoin: bool,
pub username: Option<String>,
#[serde(default)]
pub force_username: bool,
pub password: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct Euph {
pub rooms: HashMap<String, EuphRoom>,
}
#[derive(Debug, Default, Deserialize)]
pub struct Config {
pub data_dir: Option<PathBuf>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub offline: bool,
// TODO Invoke external notification command?
pub euph: Euph,
}
impl Config {
pub fn load(path: &Path) -> Self {
let content = ok_or_return!(fs::read_to_string(path), Self::default());
match toml::from_str(&content) {
Ok(config) => config,
Err(err) => {
println!("Error loading config file: {err}");
Self::default()
}
}
}
pub fn euph_room(&self, name: &str) -> EuphRoom {
self.euph.rooms.get(name).cloned().unwrap_or_default()
}
}

View file

@ -1,549 +0,0 @@
use std::convert::Infallible;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::bail;
use cookie::{Cookie, CookieJar};
use euphoxide::api::packet::ParsedPacket;
use euphoxide::api::{
Auth, AuthOption, Data, Log, Login, Logout, Nick, Send, Snowflake, Time, UserId,
};
use euphoxide::conn::{ConnRx, ConnTx, Joining, Status};
use log::{error, info, warn};
use parking_lot::Mutex;
use tokio::sync::{mpsc, oneshot};
use tokio::{select, task};
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::handshake::client::Response;
use tokio_tungstenite::tungstenite::http::{header, HeaderValue};
use crate::macros::ok_or_return;
use crate::vault::{EuphVault, Vault};
const TIMEOUT: Duration = Duration::from_secs(30);
const RECONNECT_INTERVAL: Duration = Duration::from_secs(5);
const LOG_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("room stopped")]
Stopped,
}
pub enum EuphRoomEvent {
Connected,
Disconnected,
Packet(Box<ParsedPacket>),
Stopped,
}
#[derive(Debug)]
enum Event {
// Events
Connected(ConnTx),
Disconnected,
Packet(Box<ParsedPacket>),
// Commands
Status(oneshot::Sender<Option<Status>>),
RequestLogs,
Auth(String),
Nick(String),
Send(Option<Snowflake>, String, oneshot::Sender<Snowflake>),
Login { email: String, password: String },
Logout,
}
#[derive(Debug)]
struct State {
name: String,
username: Option<String>,
force_username: bool,
password: Option<String>,
vault: EuphVault,
conn_tx: Option<ConnTx>,
/// `None` before any `snapshot-event`, then either `Some(None)` or
/// `Some(Some(id))`.
last_msg_id: Option<Option<Snowflake>>,
requesting_logs: Arc<Mutex<bool>>,
}
impl State {
async fn run(
mut self,
canary: oneshot::Receiver<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
mut event_rx: mpsc::UnboundedReceiver<Event>,
euph_room_event_tx: mpsc::UnboundedSender<EuphRoomEvent>,
ephemeral: bool,
) {
let vault = self.vault.clone();
let name = self.name.clone();
let result = if ephemeral {
select! {
_ = canary => Ok(()),
_ = Self::reconnect(&vault, &name, &event_tx) => Ok(()),
e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e,
}
} else {
select! {
_ = canary => Ok(()),
_ = Self::reconnect(&vault, &name, &event_tx) => Ok(()),
e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e,
_ = Self::regularly_request_logs(&event_tx) => Ok(()),
}
};
if let Err(e) = result {
error!("e&{name}: {}", e);
}
// Ensure that whoever is using this room knows that it's gone.
// Otherwise, the users of the Room may be left in an inconsistent or
// outdated state, and the UI may not update correctly.
let _ = euph_room_event_tx.send(EuphRoomEvent::Stopped);
}
async fn reconnect(
vault: &EuphVault,
name: &str,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
loop {
info!("e&{}: connecting", name);
let connected = if let Some((conn_tx, mut conn_rx)) = Self::connect(vault, name).await?
{
info!("e&{}: connected", name);
event_tx.send(Event::Connected(conn_tx))?;
while let Some(packet) = conn_rx.recv().await {
event_tx.send(Event::Packet(Box::new(packet)))?;
}
info!("e&{}: disconnected", name);
event_tx.send(Event::Disconnected)?;
true
} else {
info!("e&{}: could not connect", name);
event_tx.send(Event::Disconnected)?;
false
};
// Only delay reconnecting if the previous attempt failed. This way,
// we'll reconnect immediately if we login or logout.
if !connected {
tokio::time::sleep(RECONNECT_INTERVAL).await;
}
}
}
async fn get_cookies(vault: &Vault) -> String {
let cookie_jar = vault.euph_cookies().await;
let cookies = cookie_jar
.iter()
.map(|c| format!("{}", c.stripped()))
.collect::<Vec<_>>();
cookies.join("; ")
}
fn update_cookies(vault: &Vault, response: &Response) {
let mut cookie_jar = CookieJar::new();
for (name, value) in response.headers() {
if name == header::SET_COOKIE {
let value_str = ok_or_return!(value.to_str());
let cookie = ok_or_return!(Cookie::from_str(value_str));
cookie_jar.add(cookie);
}
}
vault.set_euph_cookies(cookie_jar);
}
async fn connect(vault: &EuphVault, name: &str) -> anyhow::Result<Option<(ConnTx, ConnRx)>> {
let uri = format!("wss://euphoria.io/room/{name}/ws?h=1");
let mut request = uri.into_client_request().expect("valid request");
let cookies = Self::get_cookies(vault.vault()).await;
let cookies = HeaderValue::from_str(&cookies).expect("valid cookies");
request.headers_mut().append(header::COOKIE, cookies);
// TODO Set user agent?
match tokio_tungstenite::connect_async(request).await {
Ok((ws, response)) => {
Self::update_cookies(vault.vault(), &response);
Ok(Some(euphoxide::wrap(ws, TIMEOUT)))
}
Err(tungstenite::Error::Http(resp)) if resp.status().is_client_error() => {
bail!("room {name} doesn't exist");
}
Err(tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_)) => {
bail!("format error for room {name}");
}
Err(_) => Ok(None),
}
}
async fn regularly_request_logs(event_tx: &mpsc::UnboundedSender<Event>) {
// 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;
let _ = event_tx.send(Event::RequestLogs);
}
}
async fn handle_events(
&mut self,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
euph_room_event_tx: &mpsc::UnboundedSender<EuphRoomEvent>,
) -> anyhow::Result<()> {
while let Some(event) = event_rx.recv().await {
match event {
Event::Connected(conn_tx) => {
self.conn_tx = Some(conn_tx);
let _ = euph_room_event_tx.send(EuphRoomEvent::Connected);
}
Event::Disconnected => {
self.conn_tx = None;
self.last_msg_id = None;
let _ = euph_room_event_tx.send(EuphRoomEvent::Disconnected);
}
Event::Packet(packet) => {
self.on_packet(&*packet).await?;
let _ = euph_room_event_tx.send(EuphRoomEvent::Packet(packet));
}
Event::Status(reply_tx) => self.on_status(reply_tx).await,
Event::RequestLogs => self.on_request_logs(),
Event::Auth(password) => self.on_auth(password),
Event::Nick(name) => self.on_nick(name),
Event::Send(parent, content, id_tx) => self.on_send(parent, content, id_tx),
Event::Login { email, password } => self.on_login(email, password),
Event::Logout => self.on_logout(),
}
}
Ok(())
}
async fn own_user_id(&self) -> Option<UserId> {
Some(match self.conn_tx.as_ref()?.status().await.ok()? {
Status::Joining(Joining { hello, .. }) => hello?.session.id,
Status::Joined(joined) => joined.session.id,
})
}
async fn on_packet(&mut self, packet: &ParsedPacket) -> anyhow::Result<()> {
let data = ok_or_return!(&packet.content, Ok(()));
match data {
Data::BounceEvent(_) => {
if let Some(password) = &self.password {
// Try to authenticate with the configured password, but no
// promises if it doesn't work. In particular, we only ever
// try this password once.
self.on_auth(password.clone());
}
}
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) => {
// TODO Add entry in nick list (probably in euphoxide instead of here)
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) => {
// TODO Show info popup and automatically join PM room
info!(
"e&{}: {:?} initiated a pm from &{}",
self.name, d.from_nick, d.from_room
);
}
Data::SendEvent(d) => {
let own_user_id = self.own_user_id().await;
if let Some(last_msg_id) = &mut self.last_msg_id {
let id = d.0.id;
self.vault
.add_message(d.0.clone(), *last_msg_id, own_user_id);
*last_msg_id = Some(id);
} else {
bail!("send event before snapshot event");
}
}
Data::SnapshotEvent(d) => {
info!("e&{}: successfully joined", self.name);
self.vault.join(Time::now());
self.last_msg_id = Some(d.log.last().map(|m| m.id));
let own_user_id = self.own_user_id().await;
self.vault.add_messages(d.log.clone(), None, own_user_id);
if let Some(username) = &self.username {
if self.force_username || d.nick.is_none() {
self.on_nick(username.clone());
}
}
}
Data::LogReply(d) => {
let own_user_id = self.own_user_id().await;
self.vault
.add_messages(d.log.clone(), d.before, own_user_id);
}
Data::SendReply(d) => {
let own_user_id = self.own_user_id().await;
if let Some(last_msg_id) = &mut self.last_msg_id {
let id = d.0.id;
self.vault
.add_message(d.0.clone(), *last_msg_id, own_user_id);
*last_msg_id = Some(id);
} else {
bail!("send reply before snapshot event");
}
}
_ => {}
}
Ok(())
}
async fn on_status(&self, reply_tx: oneshot::Sender<Option<Status>>) {
let status = if let Some(conn_tx) = &self.conn_tx {
conn_tx.status().await.ok()
} else {
None
};
let _ = reply_tx.send(status);
}
fn on_request_logs(&self) {
if let Some(conn_tx) = &self.conn_tx {
// Check whether logs are already being requested
let mut guard = self.requesting_logs.lock();
if *guard {
return;
} else {
*guard = true;
}
drop(guard);
// No logs are being requested and we've reserved our spot, so let's
// request some logs!
let vault = self.vault.clone();
let conn_tx = conn_tx.clone();
let requesting_logs = self.requesting_logs.clone();
task::spawn(async move {
let result = Self::request_logs(vault, conn_tx).await;
*requesting_logs.lock() = false;
result
});
}
}
async fn request_logs(vault: EuphVault, conn_tx: ConnTx) -> anyhow::Result<()> {
let before = match vault.last_span().await {
Some((None, _)) => return Ok(()), // Already at top of room history
Some((Some(before), _)) => Some(before),
None => None,
};
let _ = conn_tx.send(Log { n: 1000, before }).await?;
// The code handling incoming events and replies also handles
// `LogReply`s, so we don't need to do anything special here.
Ok(())
}
fn on_auth(&self, password: String) {
if let Some(conn_tx) = &self.conn_tx {
let conn_tx = conn_tx.clone();
task::spawn(async move {
let _ = conn_tx
.send(Auth {
r#type: AuthOption::Passcode,
passcode: Some(password),
})
.await;
});
}
}
fn on_nick(&self, name: String) {
if let Some(conn_tx) = &self.conn_tx {
let conn_tx = conn_tx.clone();
task::spawn(async move {
let _ = conn_tx.send(Nick { name }).await;
});
}
}
fn on_send(
&self,
parent: Option<Snowflake>,
content: String,
id_tx: oneshot::Sender<Snowflake>,
) {
if let Some(conn_tx) = &self.conn_tx {
let conn_tx = conn_tx.clone();
task::spawn(async move {
if let Ok(reply) = conn_tx.send(Send { content, parent }).await {
let _ = id_tx.send(reply.0.id);
}
});
}
}
fn on_login(&self, email: String, password: String) {
if let Some(conn_tx) = &self.conn_tx {
let _ = conn_tx.send(Login {
namespace: "email".to_string(),
id: email,
password,
});
}
}
fn on_logout(&self) {
if let Some(conn_tx) = &self.conn_tx {
let _ = conn_tx.send(Logout);
}
}
}
#[derive(Debug)]
pub struct Room {
#[allow(dead_code)]
canary: oneshot::Sender<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
}
impl Room {
pub fn new(
vault: EuphVault,
username: Option<String>,
force_username: bool,
password: Option<String>,
) -> (Self, mpsc::UnboundedReceiver<EuphRoomEvent>) {
let (canary_tx, canary_rx) = oneshot::channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (euph_room_event_tx, euph_room_event_rx) = mpsc::unbounded_channel();
let ephemeral = vault.vault().ephemeral();
let state = State {
name: vault.room().to_string(),
username,
force_username,
password,
vault,
conn_tx: None,
last_msg_id: None,
requesting_logs: Arc::new(Mutex::new(false)),
};
task::spawn(state.run(
canary_rx,
event_tx.clone(),
event_rx,
euph_room_event_tx,
ephemeral,
));
let new_room = Self {
canary: canary_tx,
event_tx,
};
(new_room, euph_room_event_rx)
}
pub fn stopped(&self) -> bool {
self.event_tx.is_closed()
}
pub async fn status(&self) -> Result<Option<Status>, Error> {
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::Status(tx))
.map_err(|_| Error::Stopped)?;
rx.await.map_err(|_| Error::Stopped)
}
pub fn auth(&self, password: String) -> Result<(), Error> {
self.event_tx
.send(Event::Auth(password))
.map_err(|_| Error::Stopped)
}
pub fn log(&self) -> Result<(), Error> {
self.event_tx
.send(Event::RequestLogs)
.map_err(|_| Error::Stopped)
}
pub fn nick(&self, name: String) -> Result<(), Error> {
self.event_tx
.send(Event::Nick(name))
.map_err(|_| Error::Stopped)
}
pub fn send(
&self,
parent: Option<Snowflake>,
content: String,
) -> Result<oneshot::Receiver<Snowflake>, Error> {
let (id_tx, id_rx) = oneshot::channel();
self.event_tx
.send(Event::Send(parent, content, id_tx))
.map(|_| id_rx)
.map_err(|_| Error::Stopped)
}
pub fn login(&self, email: String, password: String) -> Result<(), Error> {
self.event_tx
.send(Event::Login { email, password })
.map_err(|_| Error::Stopped)
}
pub fn logout(&self) -> Result<(), Error> {
self.event_tx
.send(Event::Logout)
.map_err(|_| Error::Stopped)
}
}

View file

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

View file

@ -1,43 +0,0 @@
use crossterm::style::{Color, ContentStyle, Stylize};
/// Convert HSL to RGB following [this approach from wikipedia][1].
///
/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`.
///
/// [1]: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
assert!((0.0..=360.0).contains(&h), "h must be in range [0, 360]");
assert!((0.0..=1.0).contains(&s), "s must be in range [0, 1]");
assert!((0.0..=1.0).contains(&l), "l must be in range [0, 1]");
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h / 60.0;
let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = match () {
_ if h_prime < 1.0 => (c, x, 0.0),
_ if h_prime < 2.0 => (x, c, 0.0),
_ if h_prime < 3.0 => (0.0, c, x),
_ if h_prime < 4.0 => (0.0, x, c),
_ if h_prime < 5.0 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let (r, g, b) = (r1 + m, g1 + m, b1 + m);
// The rgb values in the range [0,1] are each split into 256 segments of the
// same length, which are then assigned to the 256 possible values of an u8.
((r * 256.0) as u8, (g * 256.0) as u8, (b * 256.0) as u8)
}
pub fn nick_color(nick: &str) -> (u8, u8, u8) {
let hue = euphoxide::nick_hue(nick) as f32;
hsl_to_rgb(hue, 1.0, 0.72)
}
pub fn nick_style(nick: &str) -> ContentStyle {
let (r, g, b) = nick_color(nick);
ContentStyle::default().bold().with(Color::Rgb { r, g, b })
}

View file

@ -1,121 +0,0 @@
//! Export logs from the vault to plain text files.
mod json;
mod text;
use std::fs::File;
use std::io::{BufWriter, Write};
use crate::vault::Vault;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum Format {
/// Human-readable tree-structured messages.
Text,
/// List of message objects in the same format as the euphoria API uses.
Json,
}
impl Format {
fn name(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
}
}
fn extension(&self) -> &'static str {
match self {
Self::Text => "txt",
Self::Json => "json",
}
}
}
#[derive(Debug, clap::Parser)]
pub struct Args {
rooms: Vec<String>,
/// Export all rooms.
#[clap(long, short)]
all: bool,
/// Format of the output file.
#[clap(long, short, value_enum, default_value_t = Format::Text)]
format: Format,
/// Location of the output file
///
/// May include the following placeholders:
/// `%r` - room name
/// `%e` - format extension
/// A literal `%` can be written as `%%`.
///
/// If the value ends with a `/`, it is assumed to point to a directory and
/// `%r.%e` will be appended.
///
/// Must be a valid utf-8 encoded string.
#[clap(long, short, default_value_t = Into::into("%r.%e"))]
#[clap(verbatim_doc_comment)]
out: String,
}
pub async fn export(vault: &Vault, mut args: Args) -> anyhow::Result<()> {
if args.out.ends_with('/') {
args.out.push_str("%r.%e");
}
let rooms = if args.all {
let mut rooms = vault.euph_rooms().await;
rooms.sort_unstable();
rooms
} else {
let mut rooms = args.rooms.clone();
rooms.dedup();
rooms
};
if rooms.is_empty() {
println!("No rooms to export");
}
for room in rooms {
let out = format_out(&args.out, &room, args.format);
println!("Exporting &{room} as {} to {out}", args.format.name());
let mut file = BufWriter::new(File::create(out)?);
match args.format {
Format::Text => text::export_to_file(vault, room, &mut file).await?,
Format::Json => json::export_to_file(vault, room, &mut file).await?,
}
file.flush()?;
}
Ok(())
}
fn format_out(out: &str, room: &str, format: Format) -> String {
let mut result = String::new();
let mut special = false;
for char in out.chars() {
if special {
match char {
'r' => result.push_str(room),
'e' => result.push_str(format.extension()),
'%' => result.push('%'),
_ => {
result.push('%');
result.push(char);
}
}
special = false;
} else if char == '%' {
special = true;
} else {
result.push(char);
}
}
result
}

View file

@ -1,47 +0,0 @@
use std::fs::File;
use std::io::{BufWriter, Write};
use crate::vault::Vault;
const CHUNK_SIZE: usize = 10000;
pub async fn export_to_file(
vault: &Vault,
room: String,
file: &mut BufWriter<File>,
) -> anyhow::Result<()> {
let vault = vault.euph(room);
write!(file, "[")?;
let mut total = 0;
let mut offset = 0;
loop {
let messages = vault.chunk_at_offset(CHUNK_SIZE, offset).await;
offset += messages.len();
if messages.is_empty() {
break;
}
for message in messages {
if total == 0 {
writeln!(file)?;
} else {
writeln!(file, ",")?;
}
serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D
total += 1;
}
if total % 100000 == 0 {
println!(" {total} messages");
}
}
write!(file, "\n]")?;
println!(" {total} messages in total");
Ok(())
}

View file

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

View file

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

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,178 +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 config;
mod euph;
mod export;
mod logger;
mod macros;
mod store;
mod ui;
mod vault;
use std::path::PathBuf;
use clap::Parser;
use cookie::CookieJar;
use directories::{BaseDirs, ProjectDirs};
use log::info;
use toss::terminal::Terminal;
use ui::Ui;
use vault::Vault;
use crate::config::Config;
use crate::logger::Logger;
#[derive(Debug, clap::Subcommand)]
enum Command {
/// Run the client interactively (default).
Run,
/// Export room logs as plain text files.
Export(export::Args),
/// Compact and clean up vault.
Gc,
/// Clear euphoria session cookies.
ClearCookies,
}
impl Default for Command {
fn default() -> Self {
Self::Run
}
}
#[derive(Debug, clap::Parser)]
#[clap(version)]
struct Args {
/// Path to the config file.
///
/// Relative paths are interpreted relative to the current directory.
#[clap(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.
#[clap(long, short)]
data_dir: Option<PathBuf>,
/// If set, cove won't store data permanently.
#[clap(long, short, action)]
ephemeral: bool,
/// If set, cove will ignore the autojoin config option.
#[clap(long, short, action)]
offline: bool,
/// Measure the width of characters as displayed by the terminal emulator
/// instead of guessing the width.
#[clap(long, short, action)]
measure_widths: bool,
#[clap(subcommand)]
command: Option<Command>,
}
fn set_data_dir(config: &mut Config, args_data_dir: Option<PathBuf>) {
if let Some(data_dir) = args_data_dir {
// The data dir specified via args_data_dir is relative to the current
// directory and needs no resolving.
config.data_dir = Some(data_dir);
} else if let Some(data_dir) = &config.data_dir {
// Resolve the data dir specified in the config file relative to the
// user's home directory, if possible.
if let Some(base_dirs) = BaseDirs::new() {
config.data_dir = Some(base_dirs.home_dir().join(data_dir));
}
}
}
fn set_ephemeral(config: &mut Config, args_ephemeral: bool) {
if args_ephemeral {
config.ephemeral = true;
}
}
fn set_offline(config: &mut Config, args_offline: bool) {
if args_offline {
config.offline = true;
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories");
let config_path = args
.config
.unwrap_or_else(|| dirs.config_dir().join("config.toml"));
println!("Config file: {}", config_path.to_string_lossy());
let mut config = Config::load(&config_path);
set_data_dir(&mut config, args.data_dir);
set_ephemeral(&mut config, args.ephemeral);
set_offline(&mut config, args.offline);
let config = Box::leak(Box::new(config));
let vault = if config.ephemeral {
vault::launch_in_memory()?
} else {
let data_dir = config
.data_dir
.clone()
.unwrap_or_else(|| dirs.data_dir().to_path_buf());
println!("Data dir: {}", data_dir.to_string_lossy());
vault::launch(&data_dir.join("vault.db"))?
};
match args.command.unwrap_or_default() {
Command::Run => run(config, &vault, args.measure_widths).await?,
Command::Export(args) => export::export(&vault, args).await?,
Command::Gc => {
println!("Cleaning up and compacting vault");
println!("This may take a while...");
vault.gc().await;
}
Command::ClearCookies => {
println!("Clearing cookies");
vault.set_euph_cookies(CookieJar::new());
}
}
vault.close().await;
println!("Goodbye!");
Ok(())
}
async fn run(config: &'static Config, 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(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
drop(terminal); // So the vault can print again
Ok(())
}

319
src/ui.rs
View file

@ -1,319 +0,0 @@
mod chat;
mod euph;
mod input;
mod rooms;
mod util;
mod widgets;
use std::convert::Infallible;
use std::io;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use parking_lot::FairMutex;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task;
use toss::terminal::Terminal;
use crate::config::Config;
use crate::euph::EuphRoomEvent;
use crate::logger::{LogMsg, Logger};
use crate::macros::{ok_or_return, some_or_return};
use crate::vault::Vault;
pub use self::chat::ChatMsg;
use self::chat::ChatState;
use self::input::{key, InputEvent, KeyBindingsList};
use self::rooms::Rooms;
use self::widgets::layer::Layer;
use self::widgets::list::ListState;
use self::widgets::BoxedWidget;
/// Time to spend batch processing events before redrawing the screen.
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
pub enum UiEvent {
GraphemeWidthsChanged,
LogChanged,
Term(crossterm::event::Event),
EuphRoom { name: String, event: EuphRoomEvent },
}
enum EventHandleResult {
Redraw,
Continue,
Stop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Main,
Log,
}
pub struct Ui {
event_tx: UnboundedSender<UiEvent>,
mode: Mode,
rooms: Rooms,
log_chat: ChatState<LogMsg, Logger>,
key_bindings_list: Option<ListState<Infallible>>,
}
impl Ui {
const POLL_DURATION: Duration = Duration::from_millis(100);
pub async fn run(
config: &'static Config,
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(config, vault, event_tx.clone()),
log_chat: ChatState::new(logger),
key_bindings_list: None,
};
tokio::select! {
e = ui.run_main(terminal, event_rx, crossterm_lock) => e?,
_ = Self::update_on_log_event(logger_rx, &event_tx) => (),
e = crossterm_event_task => e??,
}
Ok(())
}
fn poll_crossterm_events(
tx: UnboundedSender<UiEvent>,
lock: Weak<FairMutex<()>>,
) -> crossterm::Result<()> {
loop {
let lock = some_or_return!(lock.upgrade(), Ok(()));
let _guard = lock.lock();
if crossterm::event::poll(Self::POLL_DURATION)? {
let event = crossterm::event::read()?;
ok_or_return!(tx.send(UiEvent::Term(event)), Ok(()));
}
}
}
async fn update_on_log_event(
mut logger_rx: UnboundedReceiver<()>,
event_tx: &UnboundedSender<UiEvent>,
) {
loop {
some_or_return!(logger_rx.recv().await);
ok_or_return!(event_tx.send(UiEvent::LogChanged));
}
}
async fn run_main(
&mut self,
terminal: &mut Terminal,
mut event_rx: UnboundedReceiver<UiEvent>,
crossterm_lock: Arc<FairMutex<()>>,
) -> io::Result<()> {
// Initial render so we don't show a blank screen until the first event
terminal.autoresize()?;
terminal.frame().reset();
self.widget().await.render(terminal.frame()).await;
terminal.present()?;
loop {
// 1. Measure grapheme widths if required
if terminal.measuring_required() {
let _guard = crossterm_lock.lock();
terminal.measure_widths()?;
ok_or_return!(self.event_tx.send(UiEvent::GraphemeWidthsChanged), Ok(()));
}
// 2. Handle events (in batches)
let mut event = match event_rx.recv().await {
Some(event) => event,
None => return Ok(()),
};
let mut redraw = false;
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
loop {
match self.handle_event(terminal, &crossterm_lock, event).await {
EventHandleResult::Redraw => redraw = true,
EventHandleResult::Continue => {}
EventHandleResult::Stop => return Ok(()),
}
if Instant::now() >= end_time {
break;
}
event = match event_rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return Ok(()),
};
}
// 3. Render and present final state
if redraw {
terminal.autoresize()?;
terminal.frame().reset();
self.widget().await.render(terminal.frame()).await;
terminal.present()?;
}
}
}
async fn widget(&mut self) -> BoxedWidget {
let widget = match self.mode {
Mode::Main => self.rooms.widget().await,
Mode::Log => self.log_chat.widget(String::new()).into(),
};
if let Some(key_bindings_list) = &self.key_bindings_list {
let mut bindings = KeyBindingsList::new(key_bindings_list);
self.list_key_bindings(&mut bindings).await;
Layer::new(vec![widget, bindings.widget()]).into()
} else {
widget
}
}
fn show_key_bindings(&mut self) {
if self.key_bindings_list.is_none() {
self.key_bindings_list = Some(ListState::new())
}
}
async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("ctrl+c", "quit cove");
bindings.binding("F1, ?", "show this menu");
bindings.binding("F12", "toggle log");
bindings.empty();
match self.mode {
Mode::Main => self.rooms.list_key_bindings(bindings).await,
Mode::Log => self.log_chat.list_key_bindings(bindings, false).await,
}
}
async fn handle_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: UiEvent,
) -> EventHandleResult {
match event {
UiEvent::GraphemeWidthsChanged => EventHandleResult::Redraw,
UiEvent::LogChanged if self.mode == Mode::Log => EventHandleResult::Redraw,
UiEvent::LogChanged => EventHandleResult::Continue,
UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw,
UiEvent::Term(event) => {
self.handle_term_event(terminal, crossterm_lock, event)
.await
}
UiEvent::EuphRoom { name, event } => {
let handled = self.handle_euph_room_event(name, event).await;
if self.mode == Mode::Main && handled {
EventHandleResult::Redraw
} else {
EventHandleResult::Continue
}
}
}
}
async fn handle_term_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: crossterm::event::Event,
) -> EventHandleResult {
let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue);
if let key!(Ctrl + 'c') = event {
// Exit unconditionally on ctrl+c. Previously, shift+q would also
// unconditionally exit, but that interfered with typing text in
// inline editors.
return EventHandleResult::Stop;
}
// Key bindings list overrides any other bindings if visible
if let Some(key_bindings_list) = &mut self.key_bindings_list {
match event {
key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None,
key!('k') | key!(Up) => key_bindings_list.scroll_up(1),
key!('j') | key!(Down) => key_bindings_list.scroll_down(1),
_ => return EventHandleResult::Continue,
}
return EventHandleResult::Redraw;
}
match event {
key!(F 1) => {
self.key_bindings_list = Some(ListState::new());
return EventHandleResult::Redraw;
}
key!(F 12) => {
self.mode = match self.mode {
Mode::Main => Mode::Log,
Mode::Log => Mode::Main,
};
return EventHandleResult::Redraw;
}
_ => {}
}
let mut handled = match self.mode {
Mode::Main => {
self.rooms
.handle_input_event(terminal, crossterm_lock, &event)
.await
}
Mode::Log => self
.log_chat
.handle_input_event(terminal, crossterm_lock, &event, false)
.await
.handled(),
};
// Pressing '?' should only open the key bindings list if it doesn't
// interfere with any part of the main UI, such as entering text in a
// text editor.
if !handled {
if let key!('?') = event {
self.show_key_bindings();
handled = true;
}
}
if handled {
EventHandleResult::Redraw
} else {
EventHandleResult::Continue
}
}
async fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool {
let handled = self.rooms.handle_euph_room_event(name, event);
handled && self.mode == Mode::Main
}
}

View file

@ -1,157 +0,0 @@
// TODO Implement thread view
// TODO Implement flat (chronological?) view
// TODO Implement message search?
mod blocks;
mod tree;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::FairMutex;
use time::OffsetDateTime;
use toss::frame::{Frame, Size};
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use self::tree::{TreeView, TreeViewState};
use super::input::{InputEvent, KeyBindingsList};
use super::widgets::Widget;
///////////
// Trait //
///////////
pub trait ChatMsg {
fn time(&self) -> OffsetDateTime;
fn styled(&self) -> (Styled, Styled);
fn edit(nick: &str, content: &str) -> (Styled, Styled);
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
}
///////////
// State //
///////////
pub enum Mode {
Tree,
// Thread,
// Flat,
}
pub struct ChatState<M: Msg, S: MsgStore<M>> {
store: S,
mode: Mode,
tree: TreeViewState<M, S>,
// thread: ThreadView,
// flat: FlatView,
}
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
pub fn new(store: S) -> Self {
Self {
mode: Mode::Tree,
tree: TreeViewState::new(store.clone()),
store,
}
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
pub fn store(&self) -> &S {
&self.store
}
pub fn widget(&self, nick: String) -> Chat<M, S> {
match self.mode {
Mode::Tree => Chat::Tree(self.tree.widget(nick)),
}
}
}
pub enum Reaction<M: Msg> {
NotHandled,
Handled,
Composed {
parent: Option<M::Id>,
content: String,
},
}
impl<M: Msg> Reaction<M> {
pub fn handled(&self) -> bool {
!matches!(self, Self::NotHandled)
}
}
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
match self.mode {
Mode::Tree => self.tree.list_key_bindings(bindings, can_compose).await,
}
}
pub async fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
can_compose: bool,
) -> Reaction<M> {
match self.mode {
Mode::Tree => {
self.tree
.handle_input_event(terminal, crossterm_lock, event, can_compose)
.await
}
}
}
pub async fn cursor(&self) -> Option<M::Id> {
match self.mode {
Mode::Tree => self.tree.cursor().await,
}
}
/// A [`Reaction::Composed`] message was sent, either successfully or
/// unsuccessfully.
///
/// If successful, include the message's id as an argument. If unsuccessful,
/// instead pass a `None`.
pub async fn sent(&mut self, id: Option<M::Id>) {
match self.mode {
Mode::Tree => self.tree.sent(id).await,
}
}
}
////////////
// Widget //
////////////
pub enum Chat<M: Msg, S: MsgStore<M>> {
Tree(TreeView<M, S>),
}
#[async_trait]
impl<M, S> Widget for Chat<M, S>
where
M: Msg + ChatMsg,
M::Id: Send + Sync,
S: MsgStore<M> + Send + Sync,
{
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,440 +0,0 @@
// TODO Focusing on sub-trees
mod cursor;
mod layout;
mod tree_blocks;
mod widgets;
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::FairMutex;
use tokio::sync::Mutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::util;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::Widget;
use self::cursor::Cursor;
use super::{ChatMsg, Reaction};
///////////
// State //
///////////
enum Correction {
MakeCursorVisible,
MoveCursorToVisibleArea,
CenterCursor,
}
struct InnerTreeViewState<M: Msg, S: MsgStore<M>> {
store: S,
last_cursor: Cursor<M::Id>,
last_cursor_line: i32,
last_visible_msgs: Vec<M::Id>,
cursor: Cursor<M::Id>,
editor: EditorState,
/// Scroll the view on the next render. Positive values scroll up and
/// negative values scroll down.
scroll: i32,
correction: Option<Correction>,
folded: HashSet<M::Id>,
}
impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
fn new(store: S) -> Self {
Self {
store,
last_cursor: Cursor::Bottom,
last_cursor_line: 0,
last_visible_msgs: vec![],
cursor: Cursor::Bottom,
editor: EditorState::new(),
scroll: 0,
correction: None,
folded: HashSet::new(),
}
}
pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("j/k, ↓/↑", "move cursor up/down");
bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling");
bindings.binding("p/P", "move cursor to parent/root");
bindings.binding("h/l, ←/→", "move cursor chronologically");
bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message");
bindings.binding("g, home", "move cursor to top");
bindings.binding("G, end", "move cursor to bottom");
bindings.binding("ctrl+y/e", "scroll up/down a line");
bindings.binding("ctrl+u/d", "scroll up/down half a screen");
bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen");
bindings.binding("z", "center cursor on screen");
// TODO Bindings inspired by vim's ()/[]/{} bindings?
}
async fn handle_movement_input_event(&mut self, frame: &mut Frame, event: &InputEvent) -> bool {
let chat_height = frame.size().height - 3;
match event {
key!('k') | key!(Up) => self.move_cursor_up().await,
key!('j') | key!(Down) => self.move_cursor_down().await,
key!('K') | key!(Ctrl + Up) => self.move_cursor_up_sibling().await,
key!('J') | key!(Ctrl + Down) => self.move_cursor_down_sibling().await,
key!('p') => self.move_cursor_to_parent().await,
key!('P') => self.move_cursor_to_root().await,
key!('h') | key!(Left) => self.move_cursor_older().await,
key!('l') | key!(Right) => self.move_cursor_newer().await,
key!('H') | key!(Ctrl + Left) => self.move_cursor_older_unseen().await,
key!('L') | key!(Ctrl + Right) => self.move_cursor_newer_unseen().await,
key!('g') | key!(Home) => self.move_cursor_to_top().await,
key!('G') | key!(End) => self.move_cursor_to_bottom().await,
key!(Ctrl + 'y') => self.scroll_up(1),
key!(Ctrl + 'e') => self.scroll_down(1),
key!(Ctrl + 'u') => self.scroll_up((chat_height / 2).into()),
key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()),
key!(Ctrl + 'b') | key!(PageUp) => self.scroll_up(chat_height.saturating_sub(1).into()),
key!(Ctrl + 'f') | key!(PageDown) => {
self.scroll_down(chat_height.saturating_sub(1).into())
}
key!('z') => self.center_cursor(),
_ => return false,
}
true
}
pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("space", "fold current message's subtree");
bindings.binding("s", "toggle current message's seen status");
bindings.binding("S", "mark all visible messages as seen");
bindings.binding("ctrl+s", "mark all older messages as seen");
}
async fn handle_action_input_event(&mut self, event: &InputEvent, id: Option<&M::Id>) -> bool {
match event {
key!(' ') => {
if let Some(id) = id {
if !self.folded.remove(id) {
self.folded.insert(id.clone());
}
return true;
}
}
key!('s') => {
if let Some(id) = id {
if let Some(msg) = self.store.tree(id).await.msg(id) {
self.store.set_seen(id, !msg.seen()).await;
}
return true;
}
}
key!('S') => {
for id in &self.last_visible_msgs {
self.store.set_seen(id, true).await;
}
return true;
}
key!(Ctrl + 's') => {
if let Some(id) = id {
self.store.set_older_seen(id, true).await;
} else {
self.store
.set_older_seen(&M::last_possible_id(), true)
.await;
}
return true;
}
_ => {}
}
false
}
pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.empty();
bindings.binding("r", "reply to message");
bindings.binding_ctd("(inline if possible, otherwise directly)");
bindings.binding("R", "reply to message (opposite of R)");
bindings.binding("t", "start a new thread");
}
async fn handle_edit_initiating_input_event(
&mut self,
event: &InputEvent,
id: Option<M::Id>,
) -> bool {
match event {
key!('r') => {
if let Some(parent) = self.parent_for_normal_reply().await {
self.cursor = Cursor::editor(id, parent);
self.correction = Some(Correction::MakeCursorVisible);
}
}
key!('R') => {
if let Some(parent) = self.parent_for_alternate_reply().await {
self.cursor = Cursor::editor(id, parent);
self.correction = Some(Correction::MakeCursorVisible);
}
}
key!('t') | key!('T') => {
self.cursor = Cursor::editor(id, None);
self.correction = Some(Correction::MakeCursorVisible);
}
_ => return false,
}
true
}
pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
self.list_movement_key_bindings(bindings);
self.list_action_key_bindings(bindings);
if can_compose {
self.list_edit_initiating_key_bindings(bindings);
}
}
async fn handle_normal_input_event(
&mut self,
frame: &mut Frame,
event: &InputEvent,
can_compose: bool,
id: Option<M::Id>,
) -> bool {
#[allow(clippy::if_same_then_else)]
if self.handle_movement_input_event(frame, event).await {
true
} else if self.handle_action_input_event(event, id.as_ref()).await {
true
} else if can_compose {
self.handle_edit_initiating_input_event(event, id).await
} else {
false
}
}
fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "close editor");
bindings.binding("enter", "send message");
util::list_editor_key_bindings(bindings, |_| true, true);
}
fn handle_editor_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
coming_from: Option<M::Id>,
parent: Option<M::Id>,
) -> Reaction<M> {
// TODO Tab-completion
match event {
key!(Esc) => {
self.cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom);
self.correction = Some(Correction::MakeCursorVisible);
return Reaction::Handled;
}
key!(Enter) => {
let content = self.editor.text();
if !content.trim().is_empty() {
self.cursor = Cursor::Pseudo {
coming_from,
parent: parent.clone(),
};
return Reaction::Composed { parent, content };
}
}
_ => {
let handled = util::handle_editor_input_event(
&self.editor,
terminal,
crossterm_lock,
event,
|_| true,
true,
);
if !handled {
return Reaction::NotHandled;
}
}
}
self.correction = Some(Correction::MakeCursorVisible);
Reaction::Handled
}
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
bindings.heading("Chat");
match &self.cursor {
Cursor::Bottom | Cursor::Msg(_) => {
self.list_normal_key_bindings(bindings, can_compose);
}
Cursor::Editor { .. } => self.list_editor_key_bindings(bindings),
Cursor::Pseudo { .. } => {
self.list_normal_key_bindings(bindings, false);
}
}
}
async fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
can_compose: bool,
) -> Reaction<M> {
match &self.cursor {
Cursor::Bottom => {
if self
.handle_normal_input_event(terminal.frame(), event, can_compose, None)
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
Cursor::Msg(id) => {
let id = id.clone();
if self
.handle_normal_input_event(terminal.frame(), event, can_compose, Some(id))
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
Cursor::Editor {
coming_from,
parent,
} => self.handle_editor_input_event(
terminal,
crossterm_lock,
event,
coming_from.clone(),
parent.clone(),
),
Cursor::Pseudo { .. } => {
if self
.handle_movement_input_event(terminal.frame(), event)
.await
{
Reaction::Handled
} else {
Reaction::NotHandled
}
}
}
}
fn cursor(&self) -> Option<M::Id> {
match &self.cursor {
Cursor::Msg(id) => Some(id.clone()),
Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None,
}
}
fn sent(&mut self, id: Option<M::Id>) {
if let Cursor::Pseudo { coming_from, .. } = &self.cursor {
if let Some(id) = id {
self.last_cursor = Cursor::Msg(id.clone());
self.cursor = Cursor::Msg(id);
self.editor.clear();
} else {
self.cursor = match coming_from {
Some(id) => Cursor::Msg(id.clone()),
None => Cursor::Bottom,
};
};
}
}
}
pub struct TreeViewState<M: Msg, S: MsgStore<M>>(Arc<Mutex<InnerTreeViewState<M, S>>>);
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
pub fn new(store: S) -> Self {
Self(Arc::new(Mutex::new(InnerTreeViewState::new(store))))
}
pub fn widget(&self, nick: String) -> TreeView<M, S> {
TreeView {
inner: self.0.clone(),
nick,
}
}
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
self.0.lock().await.list_key_bindings(bindings, can_compose);
}
pub async fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
can_compose: bool,
) -> Reaction<M> {
self.0
.lock()
.await
.handle_input_event(terminal, crossterm_lock, event, can_compose)
.await
}
pub async fn cursor(&self) -> Option<M::Id> {
self.0.lock().await.cursor()
}
pub async fn sent(&mut self, id: Option<M::Id>) {
self.0.lock().await.sent(id)
}
}
////////////
// Widget //
////////////
pub struct TreeView<M: Msg, S: MsgStore<M>> {
inner: Arc<Mutex<InnerTreeViewState<M, S>>>,
nick: String,
}
#[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,472 +0,0 @@
//! Moving the cursor around.
use std::collections::HashSet;
use crate::store::{Msg, MsgStore, Tree};
use super::{Correction, InnerTreeViewState};
#[derive(Debug, Clone, Copy)]
pub enum Cursor<I> {
Bottom,
Msg(I),
Editor {
coming_from: Option<I>,
parent: Option<I>,
},
Pseudo {
coming_from: Option<I>,
parent: Option<I>,
},
}
impl<I> Cursor<I> {
pub fn editor(coming_from: Option<I>, parent: Option<I>) -> Self {
Self::Editor {
coming_from,
parent,
}
}
}
impl<I: Eq> Cursor<I> {
pub fn refers_to(&self, id: &I) -> bool {
if let Self::Msg(own_id) = self {
own_id == id
} else {
false
}
}
pub fn refers_to_last_child_of(&self, id: &I) -> bool {
if let Self::Editor {
parent: Some(parent),
..
}
| Self::Pseudo {
parent: Some(parent),
..
} = self
{
parent == id
} else {
false
}
}
}
impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(parent) = tree.parent(id) {
*id = parent;
true
} else {
false
}
}
fn find_first_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool {
if folded.contains(id) {
return false;
}
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
*id = child.clone();
true
} else {
false
}
}
fn find_last_child(folded: &HashSet<M::Id>, tree: &Tree<M>, id: &mut M::Id) -> bool {
if folded.contains(id) {
return false;
}
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
*id = child.clone();
true
} else {
false
}
}
/// Move to the previous sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_prev_sibling(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
if let Some(prev_sibling) = tree.prev_sibling(id) {
*id = prev_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the previous tree.
if let Some(prev_tree_id) = store.prev_tree_id(tree.root()).await {
*tree = store.tree(&prev_tree_id).await;
*id = prev_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the next sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_next_sibling(store: &S, tree: &mut Tree<M>, id: &mut M::Id) -> bool {
if let Some(next_sibling) = tree.next_sibling(id) {
*id = next_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the next tree.
if let Some(next_tree_id) = store.next_tree_id(tree.root()).await {
*tree = store.tree(&next_tree_id).await;
*id = next_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the previous message, or don't move if this is not possible.
async fn find_prev_msg(
store: &S,
folded: &HashSet<M::Id>,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
// Move to previous sibling, then to its last child
// If not possible, move to parent
if Self::find_prev_sibling(store, tree, id).await {
while Self::find_last_child(folded, tree, id) {}
true
} else {
Self::find_parent(tree, id)
}
}
/// Move to the next message, or don't move if this is not possible.
async fn find_next_msg(
store: &S,
folded: &HashSet<M::Id>,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if Self::find_first_child(folded, tree, id) {
return true;
}
if Self::find_next_sibling(store, tree, id).await {
return true;
}
// Temporary id to avoid modifying the original one if no parent-sibling
// can be found.
let mut tmp_id = id.clone();
while Self::find_parent(tree, &mut tmp_id) {
if Self::find_next_sibling(store, tree, &mut tmp_id).await {
*id = tmp_id;
return true;
}
}
false
}
pub async fn move_cursor_up(&mut self) {
match &mut self.cursor {
Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => {
if let Some(last_tree_id) = self.store.last_tree_id().await {
let tree = self.store.tree(&last_tree_id).await;
let mut id = last_tree_id;
while Self::find_last_child(&self.folded, &tree, &mut id) {}
self.cursor = Cursor::Msg(id);
}
}
Cursor::Msg(msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
Self::find_prev_msg(&self.store, &self.folded, &mut tree, msg).await;
}
Cursor::Editor { .. } => {}
Cursor::Pseudo {
parent: Some(parent),
..
} => {
let tree = self.store.tree(parent).await;
let mut id = parent.clone();
while Self::find_last_child(&self.folded, &tree, &mut id) {}
self.cursor = Cursor::Msg(id);
}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_down(&mut self) {
match &mut self.cursor {
Cursor::Msg(msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
if !Self::find_next_msg(&self.store, &self.folded, &mut tree, msg).await {
self.cursor = Cursor::Bottom;
}
}
Cursor::Pseudo { parent: None, .. } => {
self.cursor = Cursor::Bottom;
}
Cursor::Pseudo {
parent: Some(parent),
..
} => {
let mut tree = self.store.tree(parent).await;
let mut id = parent.clone();
while Self::find_last_child(&self.folded, &tree, &mut id) {}
// Now we're at the previous message
if Self::find_next_msg(&self.store, &self.folded, &mut tree, &mut id).await {
self.cursor = Cursor::Msg(id);
} else {
self.cursor = Cursor::Bottom;
}
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_up_sibling(&mut self) {
match &mut self.cursor {
Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => {
if let Some(last_tree_id) = self.store.last_tree_id().await {
self.cursor = Cursor::Msg(last_tree_id);
}
}
Cursor::Msg(msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
Self::find_prev_sibling(&self.store, &mut tree, msg).await;
}
Cursor::Editor { .. } => {}
Cursor::Pseudo {
parent: Some(parent),
..
} => {
let path = self.store.path(parent).await;
let tree = self.store.tree(path.first()).await;
if let Some(children) = tree.children(parent) {
if let Some(last_child) = children.last() {
self.cursor = Cursor::Msg(last_child.clone());
}
}
}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_down_sibling(&mut self) {
match &mut self.cursor {
Cursor::Msg(msg) => {
let path = self.store.path(msg).await;
let mut tree = self.store.tree(path.first()).await;
if !Self::find_next_sibling(&self.store, &mut tree, msg).await
&& tree.parent(msg).is_none()
{
self.cursor = Cursor::Bottom;
}
}
Cursor::Pseudo { parent: None, .. } => {
self.cursor = Cursor::Bottom;
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_to_parent(&mut self) {
match &mut self.cursor {
Cursor::Pseudo {
parent: Some(parent),
..
} => self.cursor = Cursor::Msg(parent.clone()),
Cursor::Msg(id) => {
// Could also be done via retrieving the path, but it doesn't
// really matter here
let tree = self.store.tree(id).await;
Self::find_parent(&tree, id);
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_to_root(&mut self) {
match &mut self.cursor {
Cursor::Pseudo {
parent: Some(parent),
..
} => {
let path = self.store.path(parent).await;
self.cursor = Cursor::Msg(path.first().clone());
}
Cursor::Msg(msg) => {
let path = self.store.path(msg).await;
*msg = path.first().clone();
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_older(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.older_msg_id(id).await {
*id = prev_id;
}
}
Cursor::Bottom | Cursor::Pseudo { .. } => {
if let Some(id) = self.store.newest_msg_id().await {
self.cursor = Cursor::Msg(id);
}
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_newer(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.newer_msg_id(id).await {
*id = prev_id;
} else {
self.cursor = Cursor::Bottom;
}
}
Cursor::Pseudo { .. } => {
self.cursor = Cursor::Bottom;
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_older_unseen(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.older_unseen_msg_id(id).await {
*id = prev_id;
}
}
Cursor::Bottom | Cursor::Pseudo { .. } => {
if let Some(id) = self.store.newest_unseen_msg_id().await {
self.cursor = Cursor::Msg(id);
}
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_newer_unseen(&mut self) {
match &mut self.cursor {
Cursor::Msg(id) => {
if let Some(prev_id) = self.store.newer_unseen_msg_id(id).await {
*id = prev_id;
} else {
self.cursor = Cursor::Bottom;
}
}
Cursor::Pseudo { .. } => {
self.cursor = Cursor::Bottom;
}
_ => {}
}
self.correction = Some(Correction::MakeCursorVisible);
}
pub async fn move_cursor_to_top(&mut self) {
if let Some(first_tree_id) = self.store.first_tree_id().await {
self.cursor = Cursor::Msg(first_tree_id);
self.correction = Some(Correction::MakeCursorVisible);
}
}
pub async fn move_cursor_to_bottom(&mut self) {
self.cursor = Cursor::Bottom;
// Not really necessary; only here for consistency with other methods
self.correction = Some(Correction::MakeCursorVisible);
}
pub fn scroll_up(&mut self, amount: i32) {
self.scroll += amount;
self.correction = Some(Correction::MoveCursorToVisibleArea);
}
pub fn scroll_down(&mut self, amount: i32) {
self.scroll -= amount;
self.correction = Some(Correction::MoveCursorToVisibleArea);
}
pub fn center_cursor(&mut self) {
self.correction = Some(Correction::CenterCursor);
}
pub async fn parent_for_normal_reply(&self) -> Option<Option<M::Id>> {
match &self.cursor {
Cursor::Bottom => Some(None),
Cursor::Msg(id) => {
let path = self.store.path(id).await;
let tree = self.store.tree(path.first()).await;
Some(Some(if tree.next_sibling(id).is_some() {
// A reply to a message that has further siblings should be a
// direct reply. An indirect reply might end up a lot further
// down in the current conversation.
id.clone()
} else if let Some(parent) = tree.parent(id) {
// A reply to a message without younger siblings should be
// an indirect reply so as not to create unnecessarily deep
// threads. In the case that our message has children, this
// might get a bit confusing. I'm not sure yet how well this
// "smart" reply actually works in practice.
parent
} else {
// When replying to a top-level message, it makes sense to avoid
// creating unnecessary new threads.
id.clone()
}))
}
_ => None,
}
}
pub async fn parent_for_alternate_reply(&self) -> Option<Option<M::Id>> {
match &self.cursor {
Cursor::Bottom => Some(None),
Cursor::Msg(id) => {
let path = self.store.path(id).await;
let tree = self.store.tree(path.first()).await;
Some(Some(if tree.next_sibling(id).is_none() {
// The opposite of replying normally
id.clone()
} else if let Some(parent) = tree.parent(id) {
// The opposite of replying normally
parent
} else {
// The same as replying normally, still to avoid creating
// unnecessary new threads
id.clone()
}))
}
_ => None,
}
}
}

View file

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

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,160 +0,0 @@
mod indent;
mod seen;
mod time;
use crossterm::style::{ContentStyle, Stylize};
use toss::frame::Frame;
use toss::styled::Styled;
use super::super::ChatMsg;
use crate::store::Msg;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::join::{HJoin, Segment};
use crate::ui::widgets::layer::Layer;
use crate::ui::widgets::padding::Padding;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
use self::indent::Indent;
pub const PLACEHOLDER: &str = "[...]";
pub fn style_placeholder() -> ContentStyle {
ContentStyle::default().dark_grey()
}
fn style_time(highlighted: bool) -> ContentStyle {
if highlighted {
ContentStyle::default().black().on_white()
} else {
ContentStyle::default().grey()
}
}
fn style_indent(highlighted: bool) -> ContentStyle {
if highlighted {
ContentStyle::default().black().on_white()
} else {
ContentStyle::default().dark_grey()
}
}
fn style_info() -> ContentStyle {
ContentStyle::default().italic().dark_grey()
}
fn style_editor_highlight() -> ContentStyle {
ContentStyle::default().black().on_cyan()
}
fn style_pseudo_highlight() -> ContentStyle {
ContentStyle::default().black().on_yellow()
}
pub fn msg<M: Msg + ChatMsg>(
highlighted: bool,
indent: usize,
msg: &M,
folded_info: Option<usize>,
) -> BoxedWidget {
let (nick, mut content) = msg.styled();
if let Some(amount) = folded_info {
content = content
.then_plain("\n")
.then(format!("[{amount} more]"), style_info());
}
HJoin::new(vec![
Segment::new(seen::widget(msg.seen())),
Segment::new(
Padding::new(time::widget(Some(msg.time()), style_time(highlighted)))
.stretch(true)
.right(1),
),
Segment::new(Indent::new(indent, style_indent(highlighted))),
Segment::new(Layer::new(vec![
Indent::new(1, style_indent(false)).into(),
Padding::new(Text::new(nick)).right(1).into(),
])),
// TODO Minimum content width
// TODO Minimizing and maximizing messages
Segment::new(Text::new(content).wrap(true)).priority(1),
])
.into()
}
pub fn msg_placeholder(
highlighted: bool,
indent: usize,
folded_info: Option<usize>,
) -> BoxedWidget {
let mut content = Styled::new(PLACEHOLDER, style_placeholder());
if let Some(amount) = folded_info {
content = content
.then_plain("\n")
.then(format!("[{amount} more]"), style_info());
}
HJoin::new(vec![
Segment::new(seen::widget(true)),
Segment::new(
Padding::new(time::widget(None, style_time(highlighted)))
.stretch(true)
.right(1),
),
Segment::new(Indent::new(indent, style_indent(highlighted))),
Segment::new(Text::new(content)),
])
.into()
}
pub fn editor<M: ChatMsg>(
frame: &mut Frame,
indent: usize,
nick: &str,
editor: &EditorState,
) -> (BoxedWidget, usize) {
let (nick, content) = M::edit(nick, &editor.text());
let editor = editor.widget().highlight(|_| content);
let cursor_row = editor.cursor_row(frame);
let widget = HJoin::new(vec![
Segment::new(seen::widget(true)),
Segment::new(
Padding::new(time::widget(None, style_editor_highlight()))
.stretch(true)
.right(1),
),
Segment::new(Indent::new(indent, style_editor_highlight())),
Segment::new(Layer::new(vec![
Indent::new(1, style_indent(false)).into(),
Padding::new(Text::new(nick)).right(1).into(),
])),
Segment::new(editor).priority(1).expanding(true),
])
.into();
(widget, cursor_row)
}
pub fn pseudo<M: ChatMsg>(indent: usize, nick: &str, editor: &EditorState) -> BoxedWidget {
let (nick, content) = M::edit(nick, &editor.text());
HJoin::new(vec![
Segment::new(seen::widget(true)),
Segment::new(
Padding::new(time::widget(None, style_pseudo_highlight()))
.stretch(true)
.right(1),
),
Segment::new(Indent::new(indent, style_pseudo_highlight())),
Segment::new(Layer::new(vec![
Indent::new(1, style_indent(false)).into(),
Padding::new(Text::new(nick)).right(1).into(),
])),
Segment::new(Text::new(content).wrap(true)).priority(1),
])
.into()
}

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,24 +0,0 @@
use crossterm::style::{ContentStyle, Stylize};
use crate::ui::widgets::background::Background;
use crate::ui::widgets::empty::Empty;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
const UNSEEN: &str = "*";
const WIDTH: u16 = 1;
fn seen_style() -> ContentStyle {
ContentStyle::default().black().on_green()
}
pub fn widget(seen: bool) -> BoxedWidget {
if seen {
Empty::new().width(WIDTH).into()
} else {
let style = seen_style();
Background::new(Text::new((UNSEEN, style)))
.style(style)
.into()
}
}

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,219 +0,0 @@
use std::sync::Arc;
use crossterm::style::{ContentStyle, Stylize};
use euphoxide::api::PersonalAccountView;
use euphoxide::conn::Status;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
use crate::euph::Room;
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::util;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::empty::Empty;
use crate::ui::widgets::join::{HJoin, Segment, VJoin};
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::resize::Resize;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
use super::room::RoomStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Email,
Password,
}
pub struct LoggedOut {
focus: Focus,
email: EditorState,
password: EditorState,
}
impl LoggedOut {
fn new() -> Self {
Self {
focus: Focus::Email,
email: EditorState::new(),
password: EditorState::new(),
}
}
fn widget(&self) -> BoxedWidget {
let bold = ContentStyle::default().bold();
VJoin::new(vec![
Segment::new(Text::new(("Not logged in", bold.yellow()))),
Segment::new(Empty::new().height(1)),
Segment::new(HJoin::new(vec![
Segment::new(Text::new(("Email address:", bold))),
Segment::new(Empty::new().width(1)),
Segment::new(self.email.widget().focus(self.focus == Focus::Email)),
])),
Segment::new(HJoin::new(vec![
Segment::new(Text::new(("Password:", bold))),
Segment::new(Empty::new().width(5 + 1)),
Segment::new(
self.password
.widget()
.focus(self.focus == Focus::Password)
.hidden(),
),
])),
])
.into()
}
}
pub struct LoggedIn(PersonalAccountView);
impl LoggedIn {
fn widget(&self) -> BoxedWidget {
let bold = ContentStyle::default().bold();
VJoin::new(vec![
Segment::new(Text::new(("Logged in", bold.green()))),
Segment::new(Empty::new().height(1)),
Segment::new(HJoin::new(vec![
Segment::new(Text::new(("Email address:", bold))),
Segment::new(Empty::new().width(1)),
Segment::new(Text::new((&self.0.email,))),
])),
])
.into()
}
}
pub enum AccountUiState {
LoggedOut(LoggedOut),
LoggedIn(LoggedIn),
}
pub enum EventResult {
NotHandled,
Handled,
ResetState,
}
impl AccountUiState {
pub fn new() -> Self {
Self::LoggedOut(LoggedOut::new())
}
/// Returns `false` if the account UI should not be displayed any longer.
pub fn stabilize(&mut self, status: &RoomStatus) -> bool {
if let RoomStatus::Connected(Status::Joined(status)) = status {
match (&self, &status.account) {
(Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())),
(Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()),
_ => {}
}
true
} else {
false
}
}
pub fn widget(&self) -> BoxedWidget {
let inner = match self {
Self::LoggedOut(logged_out) => logged_out.widget(),
Self::LoggedIn(logged_in) => logged_in.widget(),
};
Popup::new(Resize::new(inner).min_width(40))
.title("Account")
.build()
}
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "close account ui");
match self {
Self::LoggedOut(logged_out) => {
match logged_out.focus {
Focus::Email => bindings.binding("enter", "focus on password"),
Focus::Password => bindings.binding("enter", "log in"),
}
bindings.binding("tab", "switch focus");
util::list_editor_key_bindings(bindings, |c| c != '\n', false);
}
Self::LoggedIn(_) => bindings.binding("L", "log out"),
}
}
pub fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
room: &Option<Room>,
) -> EventResult {
if let key!(Esc) = event {
return EventResult::ResetState;
}
match self {
Self::LoggedOut(logged_out) => {
if let key!(Tab) = event {
logged_out.focus = match logged_out.focus {
Focus::Email => Focus::Password,
Focus::Password => Focus::Email,
};
return EventResult::Handled;
}
match logged_out.focus {
Focus::Email => {
if let key!(Enter) = event {
logged_out.focus = Focus::Password;
return EventResult::Handled;
}
if util::handle_editor_input_event(
&logged_out.email,
terminal,
crossterm_lock,
event,
|c| c != '\n',
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
Focus::Password => {
if let key!(Enter) = event {
if let Some(room) = room {
let _ =
room.login(logged_out.email.text(), logged_out.password.text());
}
return EventResult::Handled;
}
if util::handle_editor_input_event(
&logged_out.password,
terminal,
crossterm_lock,
event,
|c| c != '\n',
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}
Self::LoggedIn(_) => {
if let key!('L') = event {
if let Some(room) = room {
let _ = room.logout();
}
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}
}

View file

@ -1,65 +0,0 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
use crate::euph::Room;
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::util;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::BoxedWidget;
pub fn new() -> EditorState {
EditorState::new()
}
pub fn widget(editor: &EditorState) -> BoxedWidget {
Popup::new(editor.widget().hidden())
.title("Enter password")
.build()
}
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
bindings.binding("esc", "abort");
bindings.binding("enter", "authenticate");
util::list_editor_key_bindings(bindings, |_| true, false);
}
pub enum EventResult {
NotHandled,
Handled,
ResetState,
}
pub fn handle_input_event(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
room: &Option<Room>,
editor: &EditorState,
) -> EventResult {
match event {
key!(Esc) => EventResult::ResetState,
key!(Enter) => {
if let Some(room) = &room {
let _ = room.auth(editor.text());
}
EventResult::ResetState
}
_ => {
if util::handle_editor_input_event(
editor,
terminal,
crossterm_lock,
event,
|_| true,
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}

View file

@ -1,132 +0,0 @@
use std::io;
use crossterm::style::{ContentStyle, Stylize};
use linkify::{LinkFinder, LinkKind};
use toss::styled::Styled;
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::list::ListState;
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
pub struct LinksState {
links: Vec<String>,
list: ListState<usize>,
}
pub enum EventResult {
NotHandled,
Handled,
Close,
ErrorOpeningLink { link: String, error: io::Error },
}
const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
impl LinksState {
pub fn new(content: &str) -> Self {
let links = LinkFinder::new()
.links(content)
.filter(|l| *l.kind() == LinkKind::Url)
.map(|l| l.as_str().to_string())
.collect();
Self {
links,
list: ListState::new(),
}
}
pub fn widget(&self) -> BoxedWidget {
let style_selected = ContentStyle::default().black().on_white();
let mut list = self.list.widget().focus(true);
if self.links.is_empty() {
list.add_unsel(Text::new((
"No links found",
ContentStyle::default().grey().italic(),
)))
}
for (id, link) in self.links.iter().enumerate() {
let (line_normal, line_selected) = if let Some(number_key) = NUMBER_KEYS.get(id) {
(
Styled::new(
format!("[{number_key}]"),
ContentStyle::default().dark_grey().bold(),
)
.then_plain(" ")
.then_plain(link),
Styled::new(format!("[{number_key}]"), style_selected.bold())
.then(" ", style_selected)
.then(link, style_selected),
)
} else {
(
Styled::new_plain(format!(" {link}")),
Styled::new(format!(" {link}"), style_selected),
)
};
list.add_sel(id, Text::new(line_normal), Text::new(line_selected));
}
Popup::new(list).title("Links").build()
}
fn open_link_by_id(&self, id: usize) -> EventResult {
if let Some(link) = self.links.get(id) {
if let Err(error) = open::that(link) {
return EventResult::ErrorOpeningLink {
link: link.to_string(),
error,
};
}
}
EventResult::Handled
}
fn open_link(&self) -> EventResult {
if let Some(id) = self.list.cursor() {
self.open_link_by_id(id)
} else {
EventResult::Handled
}
}
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "close links popup");
bindings.binding("j/k, ↓/↑", "move cursor up/down");
bindings.binding("g, home", "move cursor to top");
bindings.binding("G, end", "move cursor to bottom");
bindings.binding("ctrl+y/e", "scroll up/down");
bindings.empty();
bindings.binding("enter", "open selected link");
bindings.binding("1,2,...", "open link by position");
}
pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult {
match event {
key!(Esc) => return EventResult::Close,
key!('k') | key!(Up) => self.list.move_cursor_up(),
key!('j') | key!(Down) => self.list.move_cursor_down(),
key!('g') | key!(Home) => self.list.move_cursor_to_top(),
key!('G') | key!(End) => self.list.move_cursor_to_bottom(),
key!(Ctrl + 'y') => self.list.scroll_up(1),
key!(Ctrl + 'e') => self.list.scroll_down(1),
key!(Enter) => return self.open_link(),
key!('1') => return self.open_link_by_id(0),
key!('2') => return self.open_link_by_id(1),
key!('3') => return self.open_link_by_id(2),
key!('4') => return self.open_link_by_id(3),
key!('5') => return self.open_link_by_id(4),
key!('6') => return self.open_link_by_id(5),
key!('7') => return self.open_link_by_id(6),
key!('8') => return self.open_link_by_id(7),
key!('9') => return self.open_link_by_id(8),
key!('0') => return self.open_link_by_id(9),
_ => return EventResult::NotHandled,
}
EventResult::Handled
}
}

View file

@ -1,76 +0,0 @@
use std::sync::Arc;
use euphoxide::conn::Joined;
use parking_lot::FairMutex;
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::euph::{self, Room};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::util;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::padding::Padding;
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::BoxedWidget;
pub fn new(joined: Joined) -> EditorState {
EditorState::with_initial_text(joined.session.name)
}
pub fn widget(editor: &EditorState) -> BoxedWidget {
let editor = editor
.widget()
.highlight(|s| Styled::new(s, euph::nick_style(s)));
Popup::new(Padding::new(editor).left(1))
.title("Choose nick")
.inner_padding(false)
.build()
}
fn nick_char(c: char) -> bool {
c != '\n'
}
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
bindings.binding("esc", "abort");
bindings.binding("enter", "set nick");
util::list_editor_key_bindings(bindings, nick_char, false);
}
pub enum EventResult {
NotHandled,
Handled,
ResetState,
}
pub fn handle_input_event(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
room: &Option<Room>,
editor: &EditorState,
) -> EventResult {
match event {
key!(Esc) => EventResult::ResetState,
key!(Enter) => {
if let Some(room) = &room {
let _ = room.nick(editor.text());
}
EventResult::ResetState
}
_ => {
if util::handle_editor_input_event(
editor,
terminal,
crossterm_lock,
event,
nick_char,
false,
) {
EventResult::Handled
} else {
EventResult::NotHandled
}
}
}
}

View file

@ -1,119 +0,0 @@
use std::iter;
use crossterm::style::{Color, ContentStyle, Stylize};
use euphoxide::api::{SessionType, SessionView};
use euphoxide::conn::Joined;
use toss::styled::Styled;
use crate::euph;
use crate::ui::widgets::background::Background;
use crate::ui::widgets::empty::Empty;
use crate::ui::widgets::list::{List, ListState};
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
pub fn widget(state: &ListState<String>, joined: &Joined) -> BoxedWidget {
let mut list = state.widget();
render_rows(&mut list, joined);
list.into()
}
fn render_rows(list: &mut List<String>, joined: &Joined) {
let mut people = vec![];
let mut bots = vec![];
let mut lurkers = vec![];
let mut nurkers = vec![];
let mut sessions = iter::once(&joined.session)
.chain(joined.listing.values())
.collect::<Vec<_>>();
sessions.sort_unstable_by_key(|s| &s.name);
for sess in sessions {
match sess.id.session_type() {
Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess),
Some(SessionType::Bot) => bots.push(sess),
_ if sess.name.is_empty() => lurkers.push(sess),
_ => people.push(sess),
}
}
people.sort_unstable_by_key(|s| (&s.name, &s.session_id));
bots.sort_unstable_by_key(|s| (&s.name, &s.session_id));
lurkers.sort_unstable_by_key(|s| &s.session_id);
nurkers.sort_unstable_by_key(|s| &s.session_id);
render_section(list, "People", &people, &joined.session);
render_section(list, "Bots", &bots, &joined.session);
render_section(list, "Lurkers", &lurkers, &joined.session);
render_section(list, "Nurkers", &nurkers, &joined.session);
}
fn render_section(
list: &mut List<String>,
name: &str,
sessions: &[&SessionView],
own_session: &SessionView,
) {
if sessions.is_empty() {
return;
}
let heading_style = ContentStyle::new().bold();
if !list.is_empty() {
list.add_unsel(Empty::new());
}
let row = Styled::new_plain(" ")
.then(name, heading_style)
.then_plain(format!(" ({})", sessions.len()));
list.add_unsel(Text::new(row));
for session in sessions {
render_row(list, session, own_session);
}
}
fn render_row(list: &mut List<String>, session: &SessionView, own_session: &SessionView) {
let id = session.session_id.clone();
let (name, style, style_inv) = if session.name.is_empty() {
let name = "lurk";
let style = ContentStyle::default().grey();
let style_inv = ContentStyle::default().black().on_grey();
(name, style, style_inv)
} else {
let name = &session.name as &str;
let (r, g, b) = euph::nick_color(name);
let color = Color::Rgb { r, g, b };
let style = ContentStyle::default().bold().with(color);
let style_inv = ContentStyle::default().bold().black().on(color);
(name, style, style_inv)
};
let perms = if session.is_staff {
"!"
} else if session.is_manager {
"*"
} else if session.id.session_type() == Some(SessionType::Account) {
"~"
} else {
""
};
let owner = if session.session_id == own_session.session_id {
">"
} else {
" "
};
let normal = Styled::new_plain(owner).then(name, style).then_plain(perms);
let selected = Styled::new_plain(owner)
.then(name, style_inv)
.then_plain(perms);
list.add_sel(
id,
Text::new(normal),
Background::new(Text::new(selected)).style(style_inv),
);
}

View file

@ -1,37 +0,0 @@
use crossterm::style::{ContentStyle, Stylize};
use toss::styled::Styled;
use crate::ui::widgets::float::Float;
use crate::ui::widgets::popup::Popup;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
pub enum RoomPopup {
Error { description: String, reason: String },
}
impl RoomPopup {
fn server_error_widget(description: &str, reason: &str) -> BoxedWidget {
let border_style = ContentStyle::default().red().bold();
let text = Styled::new_plain(description)
.then_plain("\n\n")
.then("Reason:", ContentStyle::default().bold())
.then_plain(" ")
.then_plain(reason);
Popup::new(Text::new(text))
.title(("Error", border_style))
.border(border_style)
.build()
}
pub fn widget(&self) -> BoxedWidget {
let widget = match self {
Self::Error {
description,
reason,
} => Self::server_error_widget(description, reason),
};
Float::new(widget).horizontal(0.5).vertical(0.5).into()
}
}

View file

@ -1,576 +0,0 @@
use std::collections::VecDeque;
use std::sync::Arc;
use crossterm::style::{ContentStyle, Stylize};
use euphoxide::api::{Data, PacketType, Snowflake};
use euphoxide::conn::{Joined, Joining, Status};
use parking_lot::FairMutex;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::{mpsc, oneshot};
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::config;
use crate::euph::{self, EuphRoomEvent};
use crate::macros::{ok_or_return, some_or_return};
use crate::store::MsgStore;
use crate::ui::chat::{ChatState, Reaction};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::border::Border;
use crate::ui::widgets::editor::EditorState;
use crate::ui::widgets::join::{HJoin, Segment, VJoin};
use crate::ui::widgets::layer::Layer;
use crate::ui::widgets::list::ListState;
use crate::ui::widgets::padding::Padding;
use crate::ui::widgets::text::Text;
use crate::ui::widgets::BoxedWidget;
use crate::ui::UiEvent;
use crate::vault::EuphVault;
use super::account::{self, AccountUiState};
use super::links::{self, LinksState};
use super::popup::RoomPopup;
use super::{auth, nick, nick_list};
enum State {
Normal,
Auth(EditorState),
Nick(EditorState),
Account(AccountUiState),
Links(LinksState),
// TODO Inspect messages and users
}
#[allow(clippy::large_enum_variant)]
pub enum RoomStatus {
NoRoom,
Stopped,
Connecting,
Connected(Status),
}
impl RoomStatus {
pub fn connecting_or_connected(&self) -> bool {
match self {
Self::NoRoom | Self::Stopped => false,
Self::Connecting | Self::Connected(_) => true,
}
}
}
pub struct EuphRoom {
config: config::EuphRoom,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
vault: EuphVault,
room: Option<euph::Room>,
state: State,
popups: VecDeque<RoomPopup>,
chat: ChatState<euph::SmallMessage, EuphVault>,
last_msg_sent: Option<oneshot::Receiver<Snowflake>>,
nick_list: ListState<String>,
}
impl EuphRoom {
pub fn new(
config: config::EuphRoom,
vault: EuphVault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) -> Self {
Self {
config,
ui_event_tx,
vault: vault.clone(),
room: None,
state: State::Normal,
popups: VecDeque::new(),
chat: ChatState::new(vault),
last_msg_sent: None,
nick_list: ListState::new(),
}
}
async fn shovel_room_events(
name: String,
mut euph_room_event_rx: mpsc::UnboundedReceiver<EuphRoomEvent>,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) {
loop {
let event = some_or_return!(euph_room_event_rx.recv().await);
let event = UiEvent::EuphRoom {
name: name.clone(),
event,
};
ok_or_return!(ui_event_tx.send(event));
}
}
pub fn connect(&mut self) {
if self.room.is_none() {
let store = self.chat.store().clone();
let name = store.room().to_string();
let (room, euph_room_event_rx) = euph::Room::new(
store,
self.config.username.clone(),
self.config.force_username,
self.config.password.clone(),
);
self.room = Some(room);
tokio::task::spawn(Self::shovel_room_events(
name,
euph_room_event_rx,
self.ui_event_tx.clone(),
));
}
}
pub fn disconnect(&mut self) {
self.room = None;
}
pub async fn status(&self) -> RoomStatus {
match &self.room {
Some(room) => match room.status().await {
Ok(Some(status)) => RoomStatus::Connected(status),
Ok(None) => RoomStatus::Connecting,
Err(_) => RoomStatus::Stopped,
},
None => RoomStatus::NoRoom,
}
}
pub fn stopped(&self) -> bool {
self.room.as_ref().map(|r| r.stopped()).unwrap_or(true)
}
pub fn retain(&mut self) {
if let Some(room) = &self.room {
if room.stopped() {
self.room = None;
}
}
}
pub async fn unseen_msgs_count(&self) -> usize {
self.vault.unseen_msgs_count().await
}
async fn stabilize_pseudo_msg(&mut self) {
if let Some(id_rx) = &mut self.last_msg_sent {
match id_rx.try_recv() {
Ok(id) => {
self.chat.sent(Some(id)).await;
self.last_msg_sent = None;
}
Err(TryRecvError::Empty) => {} // Wait a bit longer
Err(TryRecvError::Closed) => {
self.chat.sent(None).await;
self.last_msg_sent = None;
}
}
}
}
fn stabilize_state(&mut self, status: &RoomStatus) {
match &mut self.state {
State::Auth(_)
if !matches!(
status,
RoomStatus::Connected(Status::Joining(Joining {
bounce: Some(_),
..
}))
) =>
{
self.state = State::Normal
}
State::Nick(_) if !matches!(status, RoomStatus::Connected(Status::Joined(_))) => {
self.state = State::Normal
}
State::Account(account) => {
if !account.stabilize(status) {
self.state = State::Normal
}
}
_ => {}
}
}
async fn stabilize(&mut self, status: &RoomStatus) {
self.stabilize_pseudo_msg().await;
self.stabilize_state(status);
}
pub async fn widget(&mut self) -> BoxedWidget {
let status = self.status().await;
self.stabilize(&status).await;
let chat = if let RoomStatus::Connected(Status::Joined(joined)) = &status {
self.widget_with_nick_list(&status, joined).await
} else {
self.widget_without_nick_list(&status).await
};
let mut layers = vec![chat];
match &self.state {
State::Normal => {}
State::Auth(editor) => layers.push(auth::widget(editor)),
State::Nick(editor) => layers.push(nick::widget(editor)),
State::Account(account) => layers.push(account.widget()),
State::Links(links) => layers.push(links.widget()),
}
for popup in &self.popups {
layers.push(popup.widget());
}
Layer::new(layers).into()
}
async fn widget_without_nick_list(&self, status: &RoomStatus) -> BoxedWidget {
VJoin::new(vec![
Segment::new(Border::new(
Padding::new(self.status_widget(status).await).horizontal(1),
)),
// TODO Use last known nick?
Segment::new(self.chat.widget(String::new())).expanding(true),
])
.into()
}
async fn widget_with_nick_list(&self, status: &RoomStatus, joined: &Joined) -> BoxedWidget {
HJoin::new(vec![
Segment::new(VJoin::new(vec![
Segment::new(Border::new(
Padding::new(self.status_widget(status).await).horizontal(1),
)),
Segment::new(self.chat.widget(joined.session.name.clone())).expanding(true),
]))
.expanding(true),
Segment::new(Border::new(
Padding::new(nick_list::widget(&self.nick_list, joined)).right(1),
)),
])
.into()
}
async fn status_widget(&self, status: &RoomStatus) -> BoxedWidget {
// TODO Include unread message count
let room = self.chat.store().room();
let room_style = ContentStyle::default().bold().blue();
let mut info = Styled::new(format!("&{room}"), room_style);
info = match status {
RoomStatus::NoRoom | RoomStatus::Stopped => info.then_plain(", archive"),
RoomStatus::Connecting => info.then_plain(", connecting..."),
RoomStatus::Connected(Status::Joining(j)) if j.bounce.is_some() => {
info.then_plain(", auth required")
}
RoomStatus::Connected(Status::Joining(_)) => info.then_plain(", joining..."),
RoomStatus::Connected(Status::Joined(j)) => {
let nick = &j.session.name;
if nick.is_empty() {
info.then_plain(", present without nick")
} else {
let nick_style = euph::nick_style(nick);
info.then_plain(", present as ").then(nick, nick_style)
}
}
};
let unseen = self.unseen_msgs_count().await;
if unseen > 0 {
info = info
.then_plain(" (")
.then(format!("{unseen}"), ContentStyle::default().bold().green())
.then_plain(")");
}
Text::new(info).into()
}
pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.binding("esc", "leave room");
let can_compose = if let Some(room) = &self.room {
match room.status().await.ok().flatten() {
Some(Status::Joining(Joining {
bounce: Some(_), ..
})) => {
bindings.binding("a", "authenticate");
false
}
Some(Status::Joined(_)) => {
bindings.binding("n", "change nick");
bindings.binding("m", "download more messages");
bindings.binding("A", "show account ui");
true
}
_ => false,
}
} else {
false
};
bindings.binding("I", "show message links");
bindings.empty();
self.chat.list_key_bindings(bindings, can_compose).await;
}
async fn handle_normal_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
) -> bool {
if let Some(room) = &self.room {
let status = room.status().await;
let can_compose = matches!(status, Ok(Some(Status::Joined(_))));
// We need to handle chat input first, otherwise the other
// key bindings will shadow characters in the editor.
match self
.chat
.handle_input_event(terminal, crossterm_lock, event, can_compose)
.await
{
Reaction::NotHandled => {}
Reaction::Handled => return true,
Reaction::Composed { parent, content } => {
match room.send(parent, content) {
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
Err(_) => self.chat.sent(None).await,
}
return true;
}
}
if let key!('I') = event {
if let Some(id) = self.chat.cursor().await {
if let Some(msg) = self.vault.msg(&id).await {
self.state = State::Links(LinksState::new(&msg.content));
}
}
return true;
}
match status.ok().flatten() {
Some(Status::Joining(Joining {
bounce: Some(_), ..
})) if matches!(event, key!('a')) => {
self.state = State::Auth(auth::new());
true
}
Some(Status::Joined(joined)) => match event {
key!('n') | key!('N') => {
self.state = State::Nick(nick::new(joined));
true
}
key!('m') => {
if let Some(room) = &self.room {
let _ = room.log();
}
true
}
key!('A') => {
self.state = State::Account(AccountUiState::new());
true
}
_ => false,
},
_ => false,
}
} else {
if self
.chat
.handle_input_event(terminal, crossterm_lock, event, false)
.await
.handled()
{
return true;
}
if let key!('I') = event {
if let Some(id) = self.chat.cursor().await {
if let Some(msg) = self.vault.msg(&id).await {
self.state = State::Links(LinksState::new(&msg.content));
}
}
return true;
}
false
}
}
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
bindings.heading("Room");
if !self.popups.is_empty() {
bindings.binding("esc", "close popup");
return;
}
match &self.state {
State::Normal => self.list_normal_key_bindings(bindings).await,
State::Auth(_) => auth::list_key_bindings(bindings),
State::Nick(_) => nick::list_key_bindings(bindings),
State::Account(account) => account.list_key_bindings(bindings),
State::Links(links) => links.list_key_bindings(bindings),
}
}
pub async fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
) -> bool {
if !self.popups.is_empty() {
if matches!(event, key!(Esc)) {
self.popups.pop_back();
return true;
}
return false;
}
match &mut self.state {
State::Normal => {
self.handle_normal_input_event(terminal, crossterm_lock, event)
.await
}
State::Auth(editor) => {
match auth::handle_input_event(terminal, crossterm_lock, event, &self.room, editor)
{
auth::EventResult::NotHandled => false,
auth::EventResult::Handled => true,
auth::EventResult::ResetState => {
self.state = State::Normal;
true
}
}
}
State::Nick(editor) => {
match nick::handle_input_event(terminal, crossterm_lock, event, &self.room, editor)
{
nick::EventResult::NotHandled => false,
nick::EventResult::Handled => true,
nick::EventResult::ResetState => {
self.state = State::Normal;
true
}
}
}
State::Account(account) => {
match account.handle_input_event(terminal, crossterm_lock, event, &self.room) {
account::EventResult::NotHandled => false,
account::EventResult::Handled => true,
account::EventResult::ResetState => {
self.state = State::Normal;
true
}
}
}
State::Links(links) => match links.handle_input_event(event) {
links::EventResult::NotHandled => false,
links::EventResult::Handled => true,
links::EventResult::Close => {
self.state = State::Normal;
true
}
links::EventResult::ErrorOpeningLink { link, error } => {
self.popups.push_front(RoomPopup::Error {
description: format!("Failed to open link: {link}"),
reason: format!("{error}"),
});
true
}
},
}
}
pub fn handle_euph_room_event(&mut self, event: EuphRoomEvent) -> bool {
match event {
EuphRoomEvent::Connected | EuphRoomEvent::Disconnected | EuphRoomEvent::Stopped => true,
EuphRoomEvent::Packet(packet) => match packet.content {
Ok(data) => self.handle_euph_data(data),
Err(reason) => self.handle_euph_error(packet.r#type, reason),
},
}
}
fn handle_euph_data(&mut self, data: Data) -> bool {
// These packets don't result in any noticeable change in the UI.
#[allow(clippy::match_like_matches_macro)]
let handled = match &data {
Data::PingEvent(_) | Data::PingReply(_) => {
// Pings are displayed nowhere in the room UI.
false
}
Data::DisconnectEvent(_) => {
// Followed by the server closing the connection, meaning that
// we'll get an `EuphRoomEvent::Disconnected` soon after this.
false
}
_ => true,
};
// Because the euphoria API is very carefully designed with emphasis on
// consistency, some failures are not normal errors but instead
// error-free replies that encode their own error.
let error = match data {
Data::AuthReply(reply) if !reply.success => Some(("authenticate", reply.reason)),
Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason)),
_ => None,
};
if let Some((action, reason)) = error {
let description = format!("Failed to {action}.");
let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string());
self.popups.push_front(RoomPopup::Error {
description,
reason,
});
}
handled
}
fn handle_euph_error(&mut self, r#type: PacketType, reason: String) -> bool {
let action = match r#type {
PacketType::AuthReply => "authenticate",
PacketType::NickReply => "set nick",
PacketType::PmInitiateReply => "initiate pm",
PacketType::SendReply => "send message",
PacketType::ChangeEmailReply => "change account email",
PacketType::ChangeNameReply => "change account name",
PacketType::ChangePasswordReply => "change account password",
PacketType::LoginReply => "log in",
PacketType::LogoutReply => "log out",
PacketType::RegisterAccountReply => "register account",
PacketType::ResendVerificationEmailReply => "resend verification email",
PacketType::ResetPasswordReply => "reset account password",
PacketType::BanReply => "ban",
PacketType::EditMessageReply => "edit message",
PacketType::GrantAccessReply => "grant room access",
PacketType::GrantManagerReply => "grant manager permissions",
PacketType::RevokeAccessReply => "revoke room access",
PacketType::RevokeManagerReply => "revoke manager permissions",
PacketType::UnbanReply => "unban",
_ => return false,
};
let description = format!("Failed to {action}.");
self.popups.push_front(RoomPopup::Error {
description,
reason,
});
true
}
}

View file

@ -1,148 +0,0 @@
use std::convert::Infallible;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use crossterm::style::{ContentStyle, Stylize};
use toss::styled::Styled;
use super::widgets::background::Background;
use super::widgets::border::Border;
use super::widgets::empty::Empty;
use super::widgets::float::Float;
use super::widgets::join::{HJoin, Segment};
use super::widgets::layer::Layer;
use super::widgets::list::{List, ListState};
use super::widgets::padding::Padding;
use super::widgets::resize::Resize;
use super::widgets::text::Text;
use super::widgets::BoxedWidget;
#[derive(Debug, Clone)]
pub enum InputEvent {
Key(KeyEvent),
Paste(String),
}
impl InputEvent {
pub fn from_event(event: Event) -> Option<Self> {
match event {
crossterm::event::Event::Key(key) => Some(Self::Key(key.into())),
crossterm::event::Event::Paste(text) => Some(Self::Paste(text)),
_ => None,
}
}
}
/// A key event data type that is a bit easier to pattern match on than
/// [`crossterm::event::KeyEvent`].
#[derive(Debug, Clone, Copy)]
pub struct KeyEvent {
pub code: KeyCode,
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
impl From<crossterm::event::KeyEvent> for KeyEvent {
fn from(event: crossterm::event::KeyEvent) -> Self {
Self {
code: event.code,
shift: event.modifiers.contains(KeyModifiers::SHIFT),
ctrl: event.modifiers.contains(KeyModifiers::CONTROL),
alt: event.modifiers.contains(KeyModifiers::ALT),
}
}
}
#[rustfmt::skip]
macro_rules! key {
// key!(Paste text)
( Paste $text:ident ) => { crate::ui::input::InputEvent::Paste($text) };
// key!('a')
( $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
( Ctrl + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
( Alt + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
// key!(Char c)
( Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
( Ctrl + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
( Alt + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
// key!(F n)
( F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: false, }) };
( Shift + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: true, ctrl: false, alt: false, }) };
( Ctrl + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: true, alt: false, }) };
( Alt + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: true, }) };
// key!(other)
( $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: false, }) };
( Shift + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: true, ctrl: false, alt: false, }) };
( Ctrl + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: true, alt: false, }) };
( Alt + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: true, }) };
}
pub(crate) use key;
/// Helper wrapper around a list widget for a more consistent key binding style.
pub struct KeyBindingsList(List<Infallible>);
impl KeyBindingsList {
/// Width of the left column of key bindings.
const BINDING_WIDTH: u16 = 20;
pub fn new(state: &ListState<Infallible>) -> Self {
Self(state.widget())
}
fn binding_style() -> ContentStyle {
ContentStyle::default().cyan()
}
pub fn widget(self) -> BoxedWidget {
let binding_style = Self::binding_style();
Float::new(Layer::new(vec![
Border::new(Background::new(Padding::new(self.0).horizontal(1))).into(),
Float::new(
Padding::new(Text::new(
Styled::new("jk/↓↑", binding_style)
.then_plain(" to scroll, ")
.then("esc", binding_style)
.then_plain(" to close"),
))
.horizontal(1),
)
.horizontal(0.5)
.into(),
]))
.horizontal(0.5)
.vertical(0.5)
.into()
}
pub fn empty(&mut self) {
self.0.add_unsel(Empty::new());
}
pub fn heading(&mut self, name: &str) {
self.0
.add_unsel(Text::new((name, ContentStyle::default().bold())));
}
pub fn binding(&mut self, binding: &str, description: &str) {
let widget = HJoin::new(vec![
Segment::new(
Resize::new(Padding::new(Text::new((binding, Self::binding_style()))).right(1))
.min_width(Self::BINDING_WIDTH),
),
Segment::new(Text::new(description)),
]);
self.0.add_unsel(widget);
}
pub fn binding_ctd(&mut self, description: &str) {
let widget = HJoin::new(vec![
Segment::new(Resize::new(Empty::new()).min_width(Self::BINDING_WIDTH)),
Segment::new(Text::new(description)),
]);
self.0.add_unsel(widget);
}
}

View file

@ -1,463 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::iter;
use std::sync::Arc;
use crossterm::style::{ContentStyle, Stylize};
use euphoxide::api::SessionType;
use euphoxide::conn::{Joined, Status};
use parking_lot::FairMutex;
use tokio::sync::mpsc;
use toss::styled::Styled;
use toss::terminal::Terminal;
use crate::config::Config;
use crate::euph::EuphRoomEvent;
use crate::vault::Vault;
use super::euph::room::{EuphRoom, RoomStatus};
use super::input::{key, InputEvent, KeyBindingsList};
use super::widgets::editor::EditorState;
use super::widgets::join::{HJoin, Segment, VJoin};
use super::widgets::layer::Layer;
use super::widgets::list::{List, ListState};
use super::widgets::padding::Padding;
use super::widgets::popup::Popup;
use super::widgets::text::Text;
use super::widgets::BoxedWidget;
use super::{util, UiEvent};
enum State {
ShowList,
ShowRoom(String),
Connect(EditorState),
}
enum Order {
Alphabet,
Importance,
}
pub struct Rooms {
config: &'static Config,
vault: Vault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
state: State,
list: ListState<String>,
order: Order,
euph_rooms: HashMap<String, EuphRoom>,
}
impl Rooms {
pub fn new(
config: &'static Config,
vault: Vault,
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
) -> Self {
let mut result = Self {
config,
vault,
ui_event_tx,
state: State::ShowList,
list: ListState::new(),
order: Order::Alphabet,
euph_rooms: HashMap::new(),
};
if !config.offline {
for (name, config) in &config.euph.rooms {
if config.autojoin {
result.get_or_insert_room(name.clone()).connect();
}
}
}
result
}
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
self.euph_rooms.entry(name.clone()).or_insert_with(|| {
EuphRoom::new(
self.config.euph_room(&name),
self.vault.euph(name),
self.ui_event_tx.clone(),
)
})
}
/// Remove rooms that are not running any more and can't be found in the db.
/// Insert rooms that are in the db but not yet in in the hash map.
///
/// These kinds of rooms are either
/// - failed connection attempts, or
/// - rooms that were deleted from the db.
async fn stabilize_rooms(&mut self) {
let mut rooms_set = self
.vault
.euph_rooms()
.await
.into_iter()
.collect::<HashSet<_>>();
// Prevent room that is currently being shown from being removed. This
// could otherwise happen when connecting to a room that doesn't exist.
if let State::ShowRoom(name) = &self.state {
rooms_set.insert(name.clone());
}
self.euph_rooms
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
for room in rooms_set {
self.get_or_insert_room(room).retain();
}
}
pub async fn widget(&mut self) -> BoxedWidget {
match &self.state {
State::ShowRoom(_) => {}
_ => self.stabilize_rooms().await,
}
match &self.state {
State::ShowList => self.rooms_widget().await,
State::ShowRoom(name) => {
self.euph_rooms
.get_mut(name)
.expect("room exists after stabilization")
.widget()
.await
}
State::Connect(editor) => Layer::new(vec![
self.rooms_widget().await,
Self::new_room_widget(editor),
])
.into(),
}
}
fn new_room_widget(editor: &EditorState) -> BoxedWidget {
let room_style = ContentStyle::default().bold().blue();
let editor = editor.widget().highlight(|s| Styled::new(s, room_style));
Popup::new(
Padding::new(HJoin::new(vec![
Segment::new(Text::new(("&", room_style))),
Segment::new(editor).priority(0),
]))
.left(1),
)
.title("Connect to")
.inner_padding(false)
.build()
}
fn format_pbln(joined: &Joined) -> String {
let mut p = 0_usize;
let mut b = 0_usize;
let mut l = 0_usize;
let mut n = 0_usize;
for sess in iter::once(&joined.session).chain(joined.listing.values()) {
match sess.id.session_type() {
Some(SessionType::Bot) if sess.name.is_empty() => n += 1,
Some(SessionType::Bot) => b += 1,
_ if sess.name.is_empty() => l += 1,
_ => p += 1,
}
}
// There must always be either one p, b, l or n since we're including
// ourselves.
let mut result = vec![];
if p > 0 {
result.push(format!("{p}p"));
}
if b > 0 {
result.push(format!("{b}b"));
}
if l > 0 {
result.push(format!("{l}l"));
}
if n > 0 {
result.push(format!("{n}n"));
}
result.join(" ")
}
fn format_status(status: RoomStatus) -> Option<String> {
match status {
RoomStatus::NoRoom | RoomStatus::Stopped => None,
RoomStatus::Connecting => Some("connecting".to_string()),
RoomStatus::Connected(Status::Joining(j)) if j.bounce.is_some() => {
Some("auth required".to_string())
}
RoomStatus::Connected(Status::Joining(_)) => Some("joining".to_string()),
RoomStatus::Connected(Status::Joined(joined)) => Some(Self::format_pbln(&joined)),
}
}
fn format_unseen_msgs(unseen: usize) -> Option<String> {
if unseen == 0 {
None
} else {
Some(format!("{unseen}"))
}
}
fn format_room_info(status: RoomStatus, unseen: usize) -> Styled {
let unseen_style = ContentStyle::default().bold().green();
let status = Self::format_status(status);
let unseen = Self::format_unseen_msgs(unseen);
match (status, unseen) {
(None, None) => Styled::default(),
(None, Some(u)) => Styled::new_plain(" (")
.then(&u, unseen_style)
.then_plain(")"),
(Some(s), None) => Styled::new_plain(" (").then_plain(&s).then_plain(")"),
(Some(s), Some(u)) => Styled::new_plain(" (")
.then_plain(&s)
.then_plain(", ")
.then(&u, unseen_style)
.then_plain(")"),
}
}
fn sort_rooms(&self, rooms: &mut [(&String, RoomStatus, usize)]) {
match self.order {
Order::Alphabet => rooms.sort_unstable_by_key(|(n, _, _)| *n),
Order::Importance => {
rooms.sort_unstable_by_key(|(n, s, u)| (!s.connecting_or_connected(), *u == 0, *n))
}
}
}
async fn render_rows(&self, list: &mut List<String>) {
if self.euph_rooms.is_empty() {
list.add_unsel(Text::new((
"Press F1 for key bindings",
ContentStyle::default().grey().italic(),
)))
}
let mut rooms = vec![];
for (name, room) in &self.euph_rooms {
let status = room.status().await;
let unseen = room.unseen_msgs_count().await;
rooms.push((name, status, unseen));
}
self.sort_rooms(&mut rooms);
for (name, status, unseen) in rooms {
let room_style = ContentStyle::default().bold().blue();
let room_sel_style = ContentStyle::default().bold().black().on_white();
let mut normal = Styled::new(format!("&{name}"), room_style);
let mut selected = Styled::new(format!("&{name}"), room_sel_style);
let info = Self::format_room_info(status, unseen);
normal = normal.and_then(info.clone());
selected = selected.and_then(info);
list.add_sel(name.clone(), Text::new(normal), Text::new(selected));
}
}
async fn rooms_widget(&self) -> BoxedWidget {
let heading_style = ContentStyle::default().bold();
let amount = self.euph_rooms.len();
let heading =
Text::new(Styled::new("Rooms", heading_style).then_plain(format!(" ({amount})")));
let mut list = self.list.widget().focus(true);
self.render_rows(&mut list).await;
VJoin::new(vec![Segment::new(heading), Segment::new(list).priority(0)]).into()
}
fn room_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
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 all rooms");
bindings.binding("d", "disconnect from selected room");
bindings.binding("D", "disconnect from all rooms");
bindings.binding("a", "connect to all autojoin room");
bindings.binding("A", "disconnect from all non-autojoin rooms");
bindings.binding("n", "connect to new room");
bindings.binding("X", "delete room");
bindings.empty();
bindings.binding("s", "change sort order");
}
State::ShowRoom(name) => {
// Key bindings for leaving the room are a part of the room's
// list_key_bindings function since they may be shadowed by the
// nick selector or message editor.
if let Some(room) = self.euph_rooms.get(name) {
room.list_key_bindings(bindings).await;
} else {
// There should always be a room here already but I don't
// really want to panic in case it is not. If I show a
// message like this, it'll hopefully be reported if
// somebody ever encounters it.
bindings.binding_ctd("oops, this text should never be visible")
}
}
State::Connect(_) => {
bindings.heading("Rooms");
bindings.binding("esc", "abort");
bindings.binding("enter", "connect to room");
util::list_editor_key_bindings(bindings, Self::room_char, false);
}
}
}
pub async fn handle_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
) -> bool {
self.stabilize_rooms().await;
match &self.state {
State::ShowList => match event {
key!('k') | key!(Up) => self.list.move_cursor_up(),
key!('j') | key!(Down) => self.list.move_cursor_down(),
key!('g') | key!(Home) => self.list.move_cursor_to_top(),
key!('G') | key!(End) => self.list.move_cursor_to_bottom(),
key!(Ctrl + 'y') => self.list.scroll_up(1),
key!(Ctrl + 'e') => self.list.scroll_down(1),
key!(Enter) => {
if let Some(name) = self.list.cursor() {
self.state = State::ShowRoom(name);
}
}
key!('c') => {
if let Some(name) = self.list.cursor() {
if let Some(room) = self.euph_rooms.get_mut(&name) {
room.connect();
}
}
}
key!('C') => {
for room in self.euph_rooms.values_mut() {
room.connect();
}
}
key!('d') => {
if let Some(name) = self.list.cursor() {
if let Some(room) = self.euph_rooms.get_mut(&name) {
room.disconnect();
}
}
}
key!('D') => {
for room in self.euph_rooms.values_mut() {
room.disconnect();
}
}
key!('a') => {
for (name, options) in &self.config.euph.rooms {
if options.autojoin {
self.get_or_insert_room(name.clone()).connect();
}
}
}
key!('A') => {
for (name, room) in &mut self.euph_rooms {
let autojoin = self
.config
.euph
.rooms
.get(name)
.map(|r| r.autojoin)
.unwrap_or(false);
if !autojoin {
room.disconnect();
}
}
}
key!('n') => self.state = State::Connect(EditorState::new()),
key!('X') => {
// 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();
}
}
key!('s') => {
self.order = match self.order {
Order::Alphabet => Order::Importance,
Order::Importance => Order::Alphabet,
};
}
_ => return false,
},
State::ShowRoom(name) => {
if let Some(room) = self.euph_rooms.get_mut(name) {
if room
.handle_input_event(terminal, crossterm_lock, event)
.await
{
return true;
}
if let key!(Esc) = event {
self.state = State::ShowList;
return true;
}
}
return false;
}
State::Connect(ed) => match event {
key!(Esc) => self.state = State::ShowList,
key!(Enter) => {
let name = ed.text();
if !name.is_empty() {
self.get_or_insert_room(name.clone()).connect();
self.state = State::ShowRoom(name);
}
}
_ => {
return util::handle_editor_input_event(
ed,
terminal,
crossterm_lock,
event,
Self::room_char,
false,
)
}
},
}
true
}
pub fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool {
let room_visible = if let State::ShowRoom(n) = &self.state {
*n == name
} else {
true
};
let room = self.get_or_insert_room(name);
let handled = room.handle_euph_room_event(event);
handled && room_visible
}
}

View file

@ -1,114 +0,0 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
use super::input::{key, InputEvent, KeyBindingsList};
use super::widgets::editor::EditorState;
pub fn prompt(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
initial_text: &str,
) -> Option<String> {
let content = {
let _guard = crossterm_lock.lock();
terminal.suspend().expect("could not suspend");
let content = edit::edit(initial_text);
terminal.unsuspend().expect("could not unsuspend");
content
};
// TODO Don't swipe this error under the rug
let content = content.ok()?;
if content.trim().is_empty() {
None
} else {
Some(content)
}
}
// TODO List key binding util functions
pub fn list_editor_key_bindings(
bindings: &mut KeyBindingsList,
char_filter: impl Fn(char) -> bool,
can_edit_externally: bool,
) {
if char_filter('\n') {
bindings.binding("enter+<any modifier>", "insert newline");
}
// Editing
bindings.binding("ctrl+h, backspace", "delete before cursor");
bindings.binding("ctrl+d, delete", "delete after cursor");
bindings.binding("ctrl+l", "clear editor contents");
if can_edit_externally {
bindings.binding("ctrl+x", "edit in external editor");
}
bindings.empty();
// Cursor movement
bindings.binding("ctrl+b, ←", "move cursor left");
bindings.binding("ctrl+f, →", "move cursor right");
bindings.binding("alt+b, ctrl+←", "move cursor left a word");
bindings.binding("alt+f, ctrl+→", "move cursor right a word");
bindings.binding("ctrl+a, home", "move cursor to start of line");
bindings.binding("ctrl+e, end", "move cursor to end of line");
bindings.binding("↑/↓", "move cursor up/down");
}
pub fn handle_editor_input_event(
editor: &EditorState,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
char_filter: impl Fn(char) -> bool,
can_edit_externally: bool,
) -> bool {
match event {
// Enter with *any* modifier pressed - if ctrl and shift don't
// work, maybe alt does
key!(Enter) => return false,
InputEvent::Key(crate::ui::input::KeyEvent {
code: crossterm::event::KeyCode::Enter,
..
}) if char_filter('\n') => editor.insert_char(terminal.frame(), '\n'),
// Editing
key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.frame(), *ch),
key!(Paste str) => {
// It seems that when pasting, '\n' are converted into '\r' for some
// reason. I don't really know why, or at what point this happens.
// Vim converts any '\r' pasted via the terminal into '\n', so I
// decided to mirror that behaviour.
let str = str.replace('\r', "\n");
if str.chars().all(char_filter) {
editor.insert_str(terminal.frame(), &str);
} else {
return false;
}
}
key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.frame()),
key!(Ctrl + 'd') | key!(Delete) => editor.delete(),
key!(Ctrl + 'l') => editor.clear(),
key!(Ctrl + 'x') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock),
// TODO Key bindings to delete words
// Cursor movement
key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()),
key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.frame()),
key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.frame()),
key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.frame()),
key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.frame()),
key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.frame()),
key!(Up) => editor.move_cursor_up(terminal.frame()),
key!(Down) => editor.move_cursor_down(terminal.frame()),
_ => return false,
}
true
}

View file

@ -1,37 +0,0 @@
// Since the widget module is effectively a library and will probably be moved
// to toss later, warnings about unused functions are mostly inaccurate.
// TODO Restrict this a bit more?
#![allow(dead_code)]
pub mod background;
pub mod border;
pub mod cursor;
pub mod editor;
pub mod empty;
pub mod float;
pub mod join;
pub mod layer;
pub mod list;
pub mod padding;
pub mod popup;
pub mod resize;
pub mod rules;
pub mod text;
use async_trait::async_trait;
use toss::frame::{Frame, Size};
#[async_trait]
pub trait Widget {
fn size(&self, frame: &mut Frame, max_width: Option<u16>, max_height: Option<u16>) -> Size;
async fn render(self: Box<Self>, frame: &mut Frame);
}
pub type BoxedWidget = Box<dyn Widget + Send>;
impl<W: 'static + Widget + Send> From<W> for BoxedWidget {
fn from(widget: W) -> Self {
Box::new(widget)
}
}

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

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