max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+434 insertions,
-3 deletions
| @@ -58,7 +58,7 @@ | |||
| 58 | 58 | `alloy mesh` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone: | |
| 59 | 59 | ||
| 60 | 60 | - **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, and the control plane named in the title when it is self-hosted. **Still to come:** MagicDNS lookup, share/unshare, and the enrollment flow. | |
| 61 | - | - **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** the folder list with path, share mode and paused state, the device list with connection state and this machine first, pause and resume on either, and enrollment. **Still to come:** add and remove, for both folders and devices, which need text entry rather than a keypress. | |
| 61 | + | - **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** the folder list with path, share mode and paused state, the device list with connection state and this machine first, pause and resume on either, add and remove for both, and enrollment. Adding opens a small overlay of text fields (`a`); removing confirms first (`d`), and both confirms say what is *not* lost, because "remove folder" must not read as "delete my documents". **Still to come:** editing an existing folder's share list, and accepting the pending-device invitations Syncthing raises when an unknown device asks to connect. | |
| 62 | 62 | ||
| 63 | 63 | It fronts `syncthing cli` rather than the REST API directly. The API needs an HTTP client and an API key read out of a file the daemon owns; `syncthing cli` is a first-party client for that same API which finds the key itself, so the view keeps the command-front shape every other console screen has, and the log pane teaches a command the user could have typed. | |
| 64 | 64 |
| @@ -40,16 +40,20 @@ | |||
| 40 | 40 | ||
| 41 | 41 | use std::collections::HashMap; | |
| 42 | 42 | ||
| 43 | - | use alloy_tui::{AlloyBlock, AlloyList, AlloyTabs, Cursor, Hint, Severity, Theme, hint, text}; | |
| 43 | + | use alloy_tui::{ | |
| 44 | + | AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, TextField, Theme, hint, | |
| 45 | + | layout, text, | |
| 46 | + | }; | |
| 44 | 47 | use anyhow::Result; | |
| 45 | 48 | use ratatui::Frame; | |
| 46 | 49 | use ratatui::crossterm::event::{KeyCode, KeyEvent}; | |
| 47 | 50 | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 51 | + | use ratatui::style::{Modifier, Style}; | |
| 48 | 52 | use ratatui::text::{Line, Span}; | |
| 49 | 53 | use serde::Deserialize; | |
| 50 | 54 | ||
| 51 | 55 | use crate::cli::{CommandLog, Invocation}; | |
| 52 | - | use crate::shell::{Flow, View, block_title, truncate}; | |
| 56 | + | use crate::shell::{Confirm, Flow, View, block_title, truncate}; | |
| 53 | 57 | ||
| 54 | 58 | /// Ticks between background refreshes. | |
| 55 | 59 | /// | |
| @@ -186,6 +190,86 @@ | |||
| 186 | 190 | ||
| 187 | 191 | /// Pause or resume a device. | |
| 188 | 192 | fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()>; | |
| 193 | + | ||
| 194 | + | /// Start synchronizing a directory. | |
| 195 | + | fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()>; | |
| 196 | + | ||
| 197 | + | /// Share this machine's folders with another device. | |
| 198 | + | fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()>; | |
| 199 | + | ||
| 200 | + | /// Stop synchronizing a directory. Leaves the files where they are. | |
| 201 | + | fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()>; | |
| 202 | + | ||
| 203 | + | /// Forget a device. | |
| 204 | + | fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()>; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// A folder the user is describing but has not added yet. | |
| 208 | + | /// | |
| 209 | + | /// Plain strings rather than the [`TextField`]s they come from, so the | |
| 210 | + | /// backends take data instead of UI state and the validation below is | |
| 211 | + | /// testable without a keyboard. | |
| 212 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 213 | + | pub(crate) struct FolderDraft { | |
| 214 | + | pub id: String, | |
| 215 | + | pub label: String, | |
| 216 | + | pub path: String, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// A device the user is describing but has not added yet. | |
| 220 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 221 | + | pub(crate) struct DeviceDraft { | |
| 222 | + | pub id: String, | |
| 223 | + | pub name: String, | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// Check a folder draft before spending a command on it. | |
| 227 | + | /// | |
| 228 | + | /// Deliberately shallow. Whether the path exists, whether the id collides with | |
| 229 | + | /// an existing folder, whether the filesystem is writable: Syncthing answers | |
| 230 | + | /// all of those and answers them correctly, and duplicating its rules here | |
| 231 | + | /// would mean two validators to keep in agreement. What this catches is the | |
| 232 | + | /// empty submit, which is the one case where the error would otherwise come | |
| 233 | + | /// back as an opaque usage message about a flag the user never saw. | |
| 234 | + | fn validate_folder(draft: &FolderDraft) -> Result<(), String> { | |
| 235 | + | if draft.id.trim().is_empty() { | |
| 236 | + | return Err("a folder needs an id".into()); | |
| 237 | + | } | |
| 238 | + | if draft.path.trim().is_empty() { | |
| 239 | + | return Err("a folder needs a path".into()); | |
| 240 | + | } | |
| 241 | + | Ok(()) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Check a device draft. Same shallowness, one extra rule. | |
| 245 | + | /// | |
| 246 | + | /// The length check earns its place against the *truncated* paste, which is | |
| 247 | + | /// the error a 56-character id invites: half an id produces no useful | |
| 248 | + | /// complaint from anything downstream, and this says how many characters | |
| 249 | + | /// arrived. It is not here to second-guess the id's validity — Syncthing | |
| 250 | + | /// verifies the check digits itself and says `check digit incorrect`, which is | |
| 251 | + | /// a perfectly actionable message, so a wrong-but-full-length id is left to it | |
| 252 | + | /// rather than duplicated here. | |
| 253 | + | /// | |
| 254 | + | /// The empty case matters for a different reason, found while testing against | |
| 255 | + | /// a live daemon: `config devices add --device-id ""` **exits 0 and silently | |
| 256 | + | /// does nothing**. Without this check a user could press enter on a blank | |
| 257 | + | /// field, see no error, and find no device. | |
| 258 | + | fn validate_device(draft: &DeviceDraft) -> Result<(), String> { | |
| 259 | + | let id = draft.id.trim(); | |
| 260 | + | if id.is_empty() { | |
| 261 | + | return Err("a device needs an id".into()); | |
| 262 | + | } | |
| 263 | + | // 56 characters in eight dash-separated groups of seven, which is how | |
| 264 | + | // Syncthing prints one and how a user will paste it. | |
| 265 | + | let bare: String = id.chars().filter(|c| *c != '-').collect(); | |
| 266 | + | if bare.len() != 56 { | |
| 267 | + | return Err(format!( | |
| 268 | + | "a device id is 56 characters in eight groups; this one has {}", | |
| 269 | + | bare.len() | |
| 270 | + | )); | |
| 271 | + | } | |
| 272 | + | Ok(()) | |
| 189 | 273 | } | |
| 190 | 274 | ||
| 191 | 275 | /// Pick a backend: `syncthing` when it answers, the mock otherwise. | |
| @@ -254,6 +338,47 @@ | |||
| 254 | 338 | .run(log) | |
| 255 | 339 | .map(drop) | |
| 256 | 340 | } | |
| 341 | + | ||
| 342 | + | /// The label is passed even when empty, because Syncthing treats an absent | |
| 343 | + | /// `--label` and an empty one the same way and the parser above already | |
| 344 | + | /// falls back to the id for display. | |
| 345 | + | fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()> { | |
| 346 | + | Invocation::new("syncthing") | |
| 347 | + | .args(["cli", "config", "folders", "add"]) | |
| 348 | + | .arg("--id") | |
| 349 | + | .arg(draft.id.trim()) | |
| 350 | + | .arg("--label") | |
| 351 | + | .arg(draft.label.trim()) | |
| 352 | + | .arg("--path") | |
| 353 | + | .arg(draft.path.trim()) | |
| 354 | + | .run(log) | |
| 355 | + | .map(drop) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()> { | |
| 359 | + | Invocation::new("syncthing") | |
| 360 | + | .args(["cli", "config", "devices", "add"]) | |
| 361 | + | .arg("--device-id") | |
| 362 | + | .arg(draft.id.trim()) | |
| 363 | + | .arg("--name") | |
| 364 | + | .arg(draft.name.trim()) | |
| 365 | + | .run(log) | |
| 366 | + | .map(drop) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()> { | |
| 370 | + | Invocation::new("syncthing") | |
| 371 | + | .args(["cli", "config", "folders", &folder.id, "delete"]) | |
| 372 | + | .run(log) | |
| 373 | + | .map(drop) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()> { | |
| 377 | + | Invocation::new("syncthing") | |
| 378 | + | .args(["cli", "config", "devices", &device.id, "delete"]) | |
| 379 | + | .run(log) | |
| 380 | + | .map(drop) | |
| 381 | + | } | |
| 257 | 382 | } | |
| 258 | 383 | ||
| 259 | 384 | /// Fixed sample state, for machines without Syncthing. | |
| @@ -318,6 +443,22 @@ | |||
| 318 | 443 | fn set_device_paused(&self, _d: &Device, _p: bool, _log: &mut CommandLog) -> Result<()> { | |
| 319 | 444 | Ok(()) | |
| 320 | 445 | } | |
| 446 | + | ||
| 447 | + | fn add_folder(&self, _draft: &FolderDraft, _log: &mut CommandLog) -> Result<()> { | |
| 448 | + | Ok(()) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | fn add_device(&self, _draft: &DeviceDraft, _log: &mut CommandLog) -> Result<()> { | |
| 452 | + | Ok(()) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | fn remove_folder(&self, _folder: &Folder, _log: &mut CommandLog) -> Result<()> { | |
| 456 | + | Ok(()) | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | fn remove_device(&self, _device: &Device, _log: &mut CommandLog) -> Result<()> { | |
| 460 | + | Ok(()) | |
| 461 | + | } | |
| 321 | 462 | } | |
| 322 | 463 | ||
| 323 | 464 | // --------------------------------------------------------------------------- | |
| @@ -488,6 +629,85 @@ | |||
| 488 | 629 | } | |
| 489 | 630 | } | |
| 490 | 631 | ||
| 632 | + | /// The add overlay, when one is open. | |
| 633 | + | /// | |
| 634 | + | /// Two shapes rather than one form with optional rows: a folder and a device | |
| 635 | + | /// share no fields, and a single struct carrying both sets would spend every | |
| 636 | + | /// read asking which half is live. | |
| 637 | + | enum Draft { | |
| 638 | + | Folder { | |
| 639 | + | id: TextField, | |
| 640 | + | label: TextField, | |
| 641 | + | path: TextField, | |
| 642 | + | }, | |
| 643 | + | Device { | |
| 644 | + | id: TextField, | |
| 645 | + | name: TextField, | |
| 646 | + | }, | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | impl Draft { | |
| 650 | + | /// Field labels, in slot order, so the renderer and the focus ring agree | |
| 651 | + | /// about what slot 1 is. | |
| 652 | + | const FOLDER_LABELS: [&'static str; 3] = ["id", "label", "path"]; | |
| 653 | + | const DEVICE_LABELS: [&'static str; 2] = ["device id", "name"]; | |
| 654 | + | ||
| 655 | + | fn labels(&self) -> &'static [&'static str] { | |
| 656 | + | match self { | |
| 657 | + | Draft::Folder { .. } => &Self::FOLDER_LABELS, | |
| 658 | + | Draft::Device { .. } => &Self::DEVICE_LABELS, | |
| 659 | + | } | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | fn field_mut(&mut self, slot: usize) -> Option<&mut TextField> { | |
| 663 | + | match self { | |
| 664 | + | Draft::Folder { id, label, path } => match slot { | |
| 665 | + | 0 => Some(id), | |
| 666 | + | 1 => Some(label), | |
| 667 | + | 2 => Some(path), | |
| 668 | + | _ => None, | |
| 669 | + | }, | |
| 670 | + | Draft::Device { id, name } => match slot { | |
| 671 | + | 0 => Some(id), | |
| 672 | + | 1 => Some(name), | |
| 673 | + | _ => None, | |
| 674 | + | }, | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | fn field(&self, slot: usize) -> Option<&TextField> { | |
| 679 | + | match self { | |
| 680 | + | Draft::Folder { id, label, path } => match slot { | |
| 681 | + | 0 => Some(id), | |
| 682 | + | 1 => Some(label), | |
| 683 | + | 2 => Some(path), | |
| 684 | + | _ => None, | |
| 685 | + | }, | |
| 686 | + | Draft::Device { id, name } => match slot { | |
| 687 | + | 0 => Some(id), | |
| 688 | + | 1 => Some(name), | |
| 689 | + | _ => None, | |
| 690 | + | }, | |
| 691 | + | } | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | fn title(&self) -> &'static str { | |
| 695 | + | match self { | |
| 696 | + | Draft::Folder { .. } => "add folder", | |
| 697 | + | Draft::Device { .. } => "add device", | |
| 698 | + | } | |
| 699 | + | } | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | /// What a raised confirm is waiting to do. | |
| 703 | + | /// | |
| 704 | + | /// The shell's [`Confirm`] carries only what to display, so the pending action | |
| 705 | + | /// lives here, exactly as [`View::confirmed`]'s contract intends. | |
| 706 | + | enum Pending { | |
| 707 | + | RemoveFolder(Folder), | |
| 708 | + | RemoveDevice(Device), | |
| 709 | + | } | |
| 710 | + | ||
| 491 | 711 | pub(crate) struct SyncView { | |
| 492 | 712 | backend: Box<dyn Backend>, | |
| 493 | 713 | reach: Reach, | |
| @@ -495,6 +715,12 @@ | |||
| 495 | 715 | /// One cursor per tab, so moving between them does not reset the other. | |
| 496 | 716 | folders: Cursor, | |
| 497 | 717 | devices: Cursor, | |
| 718 | + | /// The add overlay, when one is open. | |
| 719 | + | draft: Option<Draft>, | |
| 720 | + | /// Which field of the overlay has focus. | |
| 721 | + | draft_focus: FocusRing, | |
| 722 | + | /// What the raised confirm will do if answered yes. | |
| 723 | + | pending: Option<Pending>, | |
| 498 | 724 | error: Option<String>, | |
| 499 | 725 | ticks: u64, | |
| 500 | 726 | } | |
| @@ -507,6 +733,9 @@ | |||
| 507 | 733 | tab, | |
| 508 | 734 | folders: Cursor::new(), | |
| 509 | 735 | devices: Cursor::new(), | |
| 736 | + | draft: None, | |
| 737 | + | draft_focus: FocusRing::new(0), | |
| 738 | + | pending: None, | |
| 510 | 739 | error: None, | |
| 511 | 740 | ticks: 0, | |
| 512 | 741 | }; | |
| @@ -605,6 +834,141 @@ | |||
| 605 | 834 | } | |
| 606 | 835 | } | |
| 607 | 836 | ||
| 837 | + | /// Open the add overlay for whichever tab is showing. | |
| 838 | + | fn open_draft(&mut self) { | |
| 839 | + | let draft = match self.tab { | |
| 840 | + | Tab::Folders => Draft::Folder { | |
| 841 | + | id: TextField::new(), | |
| 842 | + | label: TextField::new(), | |
| 843 | + | path: TextField::new(), | |
| 844 | + | }, | |
| 845 | + | Tab::Devices => Draft::Device { | |
| 846 | + | id: TextField::new(), | |
| 847 | + | name: TextField::new(), | |
| 848 | + | }, | |
| 849 | + | }; | |
| 850 | + | self.draft_focus = FocusRing::new(draft.labels().len()); | |
| 851 | + | self.draft = Some(draft); | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | fn close_draft(&mut self) { | |
| 855 | + | self.draft = None; | |
| 856 | + | self.draft_focus = FocusRing::new(0); | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | /// Validate the open draft and hand it to the backend. | |
| 860 | + | /// | |
| 861 | + | /// A rejected draft stays on screen with the reason in the status line, | |
| 862 | + | /// rather than closing and losing what was typed. Retyping a 56-character | |
| 863 | + | /// device id because one group was wrong would be the worst possible | |
| 864 | + | /// answer to a typo. | |
| 865 | + | fn submit_draft(&mut self, log: &mut CommandLog) { | |
| 866 | + | let Some(draft) = &self.draft else { | |
| 867 | + | return; | |
| 868 | + | }; | |
| 869 | + | let result = match draft { | |
| 870 | + | Draft::Folder { id, label, path } => { | |
| 871 | + | let draft = FolderDraft { | |
| 872 | + | id: id.value().to_string(), | |
| 873 | + | label: label.value().to_string(), | |
| 874 | + | path: path.value().to_string(), | |
| 875 | + | }; | |
| 876 | + | if let Err(reason) = validate_folder(&draft) { | |
| 877 | + | self.error = Some(reason); | |
| 878 | + | return; | |
| 879 | + | } | |
| 880 | + | self.backend.add_folder(&draft, log) | |
| 881 | + | } | |
| 882 | + | Draft::Device { id, name } => { | |
| 883 | + | let draft = DeviceDraft { | |
| 884 | + | id: id.value().to_string(), | |
| 885 | + | name: name.value().to_string(), | |
| 886 | + | }; | |
| 887 | + | if let Err(reason) = validate_device(&draft) { | |
| 888 | + | self.error = Some(reason); | |
| 889 | + | return; | |
| 890 | + | } | |
| 891 | + | self.backend.add_device(&draft, log) | |
| 892 | + | } | |
| 893 | + | }; | |
| 894 | + | // Closed only on success, for the same reason a rejected draft stays | |
| 895 | + | // up: a command that failed has typed input still worth keeping. | |
| 896 | + | if result.is_ok() { | |
| 897 | + | self.close_draft(); | |
| 898 | + | } | |
| 899 | + | self.finish(result, log); | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | /// Keys while the add overlay is open. | |
| 903 | + | /// | |
| 904 | + | /// Returns `true` when the overlay consumed the key, so the list bindings | |
| 905 | + | /// underneath never see a `p` that was meant to be part of a path. | |
| 906 | + | fn handle_draft(&mut self, key: KeyEvent, log: &mut CommandLog) -> bool { | |
| 907 | + | if self.draft.is_none() { | |
| 908 | + | return false; | |
| 909 | + | } | |
| 910 | + | match key.code { | |
| 911 | + | KeyCode::Esc => self.close_draft(), | |
| 912 | + | KeyCode::Enter => self.submit_draft(log), | |
| 913 | + | KeyCode::Tab | KeyCode::Down => self.draft_focus.next(), | |
| 914 | + | KeyCode::BackTab | KeyCode::Up => self.draft_focus.prev(), | |
| 915 | + | _ => { | |
| 916 | + | let slot = self.draft_focus.current(); | |
| 917 | + | let Some(field) = self.draft.as_mut().and_then(|d| d.field_mut(slot)) else { | |
| 918 | + | return true; | |
| 919 | + | }; | |
| 920 | + | match key.code { | |
| 921 | + | KeyCode::Char(c) => field.insert(c), | |
| 922 | + | KeyCode::Backspace => field.backspace(), | |
| 923 | + | KeyCode::Delete => field.delete(), | |
| 924 | + | KeyCode::Left => field.left(), | |
| 925 | + | KeyCode::Right => field.right(), | |
| 926 | + | KeyCode::Home => field.home(), | |
| 927 | + | KeyCode::End => field.end(), | |
| 928 | + | _ => {} | |
| 929 | + | } | |
| 930 | + | } | |
| 931 | + | } | |
| 932 | + | true | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | /// Raise the confirm for removing what is selected. | |
| 936 | + | /// | |
| 937 | + | /// Both removals are confirmed, and the messages say what is and is not | |
| 938 | + | /// lost: neither command touches a file, and a user who thinks "remove | |
| 939 | + | /// folder" means "delete my documents" will not press it. Saying so is | |
| 940 | + | /// cheaper than the support question. | |
| 941 | + | fn remove_selected(&mut self) -> Flow { | |
| 942 | + | match self.tab { | |
| 943 | + | Tab::Folders => { | |
| 944 | + | let Some(folder) = self.selected_folder().cloned() else { | |
| 945 | + | return Flow::Continue; | |
| 946 | + | }; | |
| 947 | + | let message = format!( | |
| 948 | + | "Stop synchronizing {}? The files in {} stay where they are.", | |
| 949 | + | folder.label, folder.path | |
| 950 | + | ); | |
| 951 | + | self.pending = Some(Pending::RemoveFolder(folder)); | |
| 952 | + | Flow::Confirm(Confirm::destructive("remove folder", message)) | |
| 953 | + | } | |
| 954 | + | Tab::Devices => { | |
| 955 | + | let Some(device) = self.selected_device().cloned() else { | |
| 956 | + | return Flow::Continue; | |
| 957 | + | }; | |
| 958 | + | if device.is_self { | |
| 959 | + | self.error = Some("this machine cannot be removed".into()); | |
| 960 | + | return Flow::Continue; | |
| 961 | + | } | |
| 962 | + | let message = format!( | |
| 963 | + | "Forget {}? It stops sharing folders with this machine, and keeps its own copy.", | |
| 964 | + | device.name | |
| 965 | + | ); | |
| 966 | + | self.pending = Some(Pending::RemoveDevice(device)); | |
| 967 | + | Flow::Confirm(Confirm::destructive("remove device", message)) | |
| 968 | + | } | |
| 969 | + | } | |
| 970 | + | } | |
| 971 | + | ||
| 608 | 972 | fn folder_row<'a>(theme: &Theme, folder: &'a Folder) -> Line<'a> { | |
| 609 | 973 | Line::from(vec![ | |
| 610 | 974 | text::bold(theme, format!("{:<20}", truncate(&folder.label, 19))), | |
| @@ -629,6 +993,70 @@ | |||
| 629 | 993 | ]) | |
| 630 | 994 | } | |
| 631 | 995 | ||
| 996 | + | /// One labelled field line, with the caret drawn under a character. | |
| 997 | + | /// | |
| 998 | + | /// Same shape as the installer's account fields, minus the masking: none | |
| 999 | + | /// of these is a secret, and a device id in particular is meant to be read | |
| 1000 | + | /// back against the one on the other machine's screen. | |
| 1001 | + | fn draft_line<'a>(theme: &Theme, label: &'a str, field: &TextField, focused: bool) -> Line<'a> { | |
| 1002 | + | let (before, under, after) = field.split(); | |
| 1003 | + | let mut spans = vec![ | |
| 1004 | + | if focused { | |
| 1005 | + | text::bold(theme, format!("{label:>10} ")) | |
| 1006 | + | } else { | |
| 1007 | + | text::muted(theme, format!("{label:>10} ")) | |
| 1008 | + | }, | |
| 1009 | + | text::primary(theme, before.to_string()), | |
| 1010 | + | ]; | |
| 1011 | + | if focused { | |
| 1012 | + | // Reversed rather than a block glyph, so the caret sits on the | |
| 1013 | + | // character it is about to replace instead of beside it, and a | |
| 1014 | + | // space stands in past the end of the value. Same treatment as the | |
| 1015 | + | // installer's fields. | |
| 1016 | + | spans.push(Span::styled( | |
| 1017 | + | under.unwrap_or(' ').to_string(), | |
| 1018 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 1019 | + | )); | |
| 1020 | + | } else if let Some(under) = under { | |
| 1021 | + | spans.push(text::primary(theme, under.to_string())); | |
| 1022 | + | } | |
| 1023 | + | spans.push(text::primary(theme, after.to_string())); | |
| 1024 | + | Line::from(spans) | |
| 1025 | + | } | |
| 1026 | + | ||
| 1027 | + | fn render_draft(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 1028 | + | let Some(draft) = &self.draft else { | |
| 1029 | + | return; | |
| 1030 | + | }; | |
| 1031 | + | let labels = draft.labels(); | |
| 1032 | + | // Two rows of border, one of padding either side of the fields. | |
| 1033 | + | let height = labels.len() as u16 + 4; | |
| 1034 | + | let overlay = layout::centered(area, 60, height); | |
| 1035 | + | frame.render_widget(ratatui::widgets::Clear, overlay); | |
| 1036 | + | ||
| 1037 | + | let block = AlloyBlock::new(theme) | |
| 1038 | + | .focused(true) | |
| 1039 | + | .build() | |
| 1040 | + | .title(block_title(draft.title())); | |
| 1041 | + | let inner = block.inner(overlay); | |
| 1042 | + | frame.render_widget(block, overlay); | |
| 1043 | + | ||
| 1044 | + | let lines: Vec<Line> = labels | |
| 1045 | + | .iter() | |
| 1046 | + | .enumerate() | |
| 1047 | + | .filter_map(|(slot, label)| { | |
| 1048 | + | let field = draft.field(slot)?; | |
| 1049 | + | Some(Self::draft_line( | |
| 1050 | + | theme, | |
| 1051 | + | label, | |
| 1052 | + | field, | |
| 1053 | + | self.draft_focus.is_focused(slot), | |
| 1054 | + | )) | |
| 1055 | + | }) | |
| 1056 | + | .collect(); | |
| 1057 | + | frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner); | |
| 1058 | + | } | |
| 1059 | + | ||
| 632 | 1060 | /// The offer shown when Syncthing is installed and not running. | |
| 633 | 1061 | fn render_offer(frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 634 | 1062 | let lines = vec![ | |
| @@ -652,12 +1080,21 @@ | |||
| 652 | 1080 | } | |
| 653 | 1081 | ||
| 654 | 1082 | fn hints(&self) -> Vec<Hint> { | |
| 1083 | + | if self.draft.is_some() { | |
| 1084 | + | return vec![ | |
| 1085 | + | hint("tab", "field"), |
Lines truncated