feat(gui): paste button, Enter-to-connect, version, and clipboard prefill

Quick viewer-flow polish:

- a "📋 Paste" button pinned to the right of the code field — the read-side
  mirror of the host's Copy button;
- Enter in the code field connects (same decode gate as the button);
- the View screen prefills the field from the clipboard on open when it holds
  a decodable ticket and the field is empty, so the freshly-shared code is
  usually already there (live decode still shows the id to verify);
- the menu shows the binary version under the heading.

Tightens the common "host clicks Copy → viewer clicks Paste → Connect" loop;
the prefill only ever drops in a *valid* ticket, so it can't reintroduce the
stale/garbage paste it guards against.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 03:37:53 -04:00
parent 48f5510699
commit d23848decc
+55 -9
View File
@@ -43,6 +43,15 @@ fn set_clipboard(text: &str) -> bool {
.is_ok()
}
/// Best-effort clipboard read, backing the viewer Paste button and the
/// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave
/// the field untouched.
fn get_clipboard() -> Option<String> {
arboard::Clipboard::new()
.and_then(|mut cb| cb.get_text())
.ok()
}
/// Decode the host endpoint id from a ticket string, using the exact same
/// parse the viewer does (`EndpointTicket::from_str`), so the GUI agrees with
/// the child about what's a valid code. `None` means it isn't a pixelpass
@@ -233,6 +242,11 @@ impl PixelPassApp {
ui.add_space(24.0);
ui.heading("PixelPass");
ui.label("P2P screen sharing");
ui.label(
egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
.small()
.weak(),
);
ui.add_space(32.0);
if ui
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
@@ -249,6 +263,7 @@ impl PixelPassApp {
.clicked()
{
self.screen = Screen::Viewer;
self.prefill_viewer_ticket();
}
});
}
@@ -580,11 +595,26 @@ impl PixelPassApp {
ui.label("Paste the share code you received:");
ui.add_space(4.0);
ui.add(
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
.desired_width(f32::INFINITY)
.hint_text("endpoint…"),
);
// Field fills the row, with a Paste button pinned to its right — the
// read-side mirror of the host's Copy button (one click grabs the code
// the host just put on the clipboard).
let ticket_resp = ui
.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("📋 Paste").clicked()
&& let Some(text) = get_clipboard()
{
self.viewer.ticket_input = text.trim().to_string();
}
ui.add(
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
.desired_width(f32::INFINITY)
.hint_text("endpoint…"),
)
})
.inner;
// Enter in the field connects (gated on a decodable code below).
let enter_pressed =
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
// Decode the pasted code live: confirms it's a real ticket and shows
// which host it points at, so a stale clipboard paste is caught here
@@ -621,14 +651,14 @@ impl PixelPassApp {
ui.add_space(16.0);
// Only dial a code that actually decodes — no point spawning a child to
// spend 15s timing out against garbage.
if ui
// spend 15s timing out against garbage. Enter takes the same path.
let connect_clicked = ui
.add_enabled(
decoded_id.is_some(),
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
)
.clicked()
{
.clicked();
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
self.start_viewer(ui.ctx().clone());
}
}
@@ -656,6 +686,22 @@ impl PixelPassApp {
}
}
/// On entering the View screen, drop a clipboard ticket straight into the
/// field — the host's Copy button (and our auto-copy) usually leaves the
/// freshly-shared code right there. Guarded so it only fires when the field
/// is empty and the clipboard holds a *decodable* ticket, so stray
/// clipboard text never lands in the box; the live decode still shows the
/// id for the user to verify.
fn prefill_viewer_ticket(&mut self) {
if self.viewer.ticket_input.trim().is_empty()
&& self.viewer.proc.is_none()
&& let Some(text) = get_clipboard()
&& ticket_endpoint_id(&text).is_some()
{
self.viewer.ticket_input = text.trim().to_string();
}
}
fn start_viewer(&mut self, ctx: egui::Context) {
self.viewer.error = None;
self.viewer.url = None;