diff --git a/Cargo.lock b/Cargo.lock index f2ba9b7..b6edc34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -423,6 +441,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -863,6 +890,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -953,6 +986,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.14" @@ -1075,6 +1114,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1138,6 +1183,7 @@ name = "furumi_tui" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "core-foundation 0.10.1", "crokey", "crossterm", @@ -1257,6 +1303,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2085,6 +2141,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2199,6 +2264,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-audio-toolbox" version = "0.3.2" @@ -2260,6 +2337,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2279,6 +2369,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2327,6 +2428,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "palette" version = "0.7.6" @@ -2435,6 +2546,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset 0.5.7", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -2639,6 +2761,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3026,7 +3157,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3683,7 +3814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf", "phf_codegen", ] @@ -3709,7 +3840,7 @@ dependencies = [ "fancy-regex", "filedescriptor", "finl_unicode", - "fixedbitset", + "fixedbitset 0.4.2", "hex", "lazy_static", "libc", @@ -4057,6 +4188,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4335,6 +4477,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.100" @@ -5001,12 +5213,47 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 01d78f4..57a0997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" +arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } crokey = "1.4.0" crossterm = { version = "0.29.0", features = ["event-stream"] } directories = "6.0.0" diff --git a/src/app/login.rs b/src/app/login.rs index 43014d8..81bc5c7 100644 --- a/src/app/login.rs +++ b/src/app/login.rs @@ -59,6 +59,31 @@ fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { } fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { + // Ctrl-shortcuts first: plain letters belong to the paste field. + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + // Copy the full SSO URL — terminals can't copy a wrapped link + // in one piece, the clipboard can. + KeyCode::Char('l') => { + form.error = None; + match copy_to_clipboard(&form.sso_url) { + Ok(()) => form.error = Some("link copied to clipboard".to_string()), + Err(err) => { + tracing::warn!(%err, "clipboard copy failed"); + form.error = Some(format!("copy failed: {err}")); + } + } + } + KeyCode::Char('o') => { + if let Err(err) = open::that_detached(&form.sso_url) { + tracing::warn!(%err, "failed to reopen browser"); + form.error = Some("couldn't open a browser".to_string()); + } + } + _ => {} + } + return; + } match key.code { KeyCode::Esc => { if let Some(listener) = runtime.sso.take() { @@ -77,6 +102,10 @@ fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { } } +fn copy_to_clipboard(text: &str) -> Result<(), arboard::Error> { + arboard::Clipboard::new()?.set_text(text.to_string()) +} + fn is_typing(key: KeyEvent) -> bool { key.modifiers .difference(KeyModifiers::SHIFT) diff --git a/src/ui/login.rs b/src/ui/login.rs index 78b7356..d30e629 100644 --- a/src/ui/login.rs +++ b/src/ui/login.rs @@ -66,13 +66,11 @@ fn draw_form(frame: &mut Frame, form: &LoginForm) { } fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { - // The dialog grows with the URL so it is always shown in full and can - // be copied/typed manually. - let width = 78.min(frame.area().width.saturating_sub(2)).max(40); - let inner_width = usize::from(width - 2); - let url_lines = (form.sso_url.len().div_ceil(inner_width.max(1)) as u16).clamp(1, 6); - let height = (13 + url_lines).min(frame.area().height); - let area = centered(frame.area(), width, height); + // The URL stays on ONE line (wrapping breaks copy-paste); the dialog is + // as wide as the terminal allows and ctrl-l copies the full link. + let width = (form.sso_url.len() as u16 + 4) + .clamp(48, frame.area().width.saturating_sub(2).max(40)); + let area = centered(frame.area(), width, 14.min(frame.area().height)); let block = Block::bordered() .title(" Continue with SSO ") @@ -84,7 +82,7 @@ fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { let [steps, url_label, url, paste, message, hint] = Layout::vertical([ Constraint::Length(3), Constraint::Length(1), - Constraint::Length(url_lines), + Constraint::Length(1), Constraint::Length(3), Constraint::Length(2), Constraint::Length(1), @@ -111,25 +109,26 @@ fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { frame.render_widget( Paragraph::new(Line::styled( - "If the browser didn't open, visit:", + "If the browser didn't open — ctrl-l copies this link, ctrl-o retries:", theme::dim(), )), url_label, ); - // Unbordered and character-wrapped: the URL is fully visible and can be - // selected in the terminal without picking up border glyphs. + // One line, never wrapped: a wrapped URL copies with a line break and + // stops working. If it doesn't fit, ctrl-l still copies it whole. frame.render_widget( - Paragraph::new(form.sso_url.clone()) - .style(theme::accent()) - .wrap(Wrap { trim: false }), + Paragraph::new(Line::styled(form.sso_url.clone(), theme::accent())), url, ); draw_field(frame, paste, "Link or code", &form.sso_paste, false, true); draw_message(frame, message, form); frame.render_widget( - Paragraph::new(Line::styled("enter submit · esc back · ctrl-c quit", theme::dim())) - .alignment(Alignment::Center), + Paragraph::new(Line::styled( + "enter submit · ctrl-l copy link · esc back · ctrl-c quit", + theme::dim(), + )) + .alignment(Alignment::Center), hint, ); }