W21 Phase 1: locked selectable display fields for node ID + ticket

Add a read-only-but-selectable "locked" mode to the A9 ContextInput so
share-critical values (full node ID, full room ticket) can be drag-selected
and copied with the mouse/keyboard, in addition to the existing one-click
Copy buttons (which are kept).

- context_input.rs: add `locked` flag + builder + `locked_value(value, noop)`
  constructor. A controlled text_input with a no-op on_input stays focusable
  and selection-capable while never mutating (iced treats on_input==None as
  Disabled, verified against iced_widget-0.14.2 source).
- Extract overlay gating into a pure `menu_action_enabled` seam: when locked,
  Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and
  Select All is enabled when there's a value. +1 unit test.
- app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in
  width-capped locked fields beside their existing Copy buttons.

Phase 2 (cross-message selectable chat transcript) intentionally deferred:
it requires a transcript-level custom widget that owns selection/layout/hit-
testing while preserving A13 links and attachment rows — out of scope for a
bounded edit. Design path recorded in the Codex task report.

Tests-green only (460 lib, clippy clean, release build green); wants a quick
field check of mouse drag-select + right-click Copy + Ctrl+A/C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 15:53:33 -04:00
co-authored by Claude Opus 4.8
parent a4bb6ce0be
commit a6d9a8cbd4
2 changed files with 77 additions and 12 deletions
+56 -7
View File
@@ -105,6 +105,20 @@ where
ContextInput::new(placeholder, value)
}
pub fn locked_value<'a, Message, Theme, Renderer>(
value: &str,
noop: Message,
) -> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
ContextInput::new("", value)
.on_input(move |_| noop.clone())
.locked(true)
}
pub struct ContextInput<
'a,
Message,
@@ -117,6 +131,7 @@ pub struct ContextInput<
input: text_input::TextInput<'a, Message, Theme, Renderer>,
value: String,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
@@ -134,6 +149,7 @@ where
input: text_input::TextInput::new(placeholder, value),
value: value.to_owned(),
is_secure: false,
locked: false,
on_input: None,
on_paste: None,
style: None,
@@ -151,6 +167,11 @@ where
self
}
pub fn locked(mut self, yes: bool) -> Self {
self.locked = yes;
self
}
pub fn on_input(
mut self,
on_input: impl Fn(String) -> Message + 'a,
@@ -448,6 +469,7 @@ where
input_state,
value: &self.value,
is_secure: self.is_secure,
locked: self.locked,
on_input: self.on_input.clone(),
on_paste: self.on_paste.clone(),
style: self.style.clone(),
@@ -479,6 +501,7 @@ where
input_state: &'a mut text_input::State<Renderer::Paragraph>,
value: &'a str,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
@@ -686,13 +709,13 @@ where
let has_selection = menu.selection.0 != menu.selection.1;
let has_value = !text_input::Value::new(self.value).is_empty();
match action {
MenuAction::Cut | MenuAction::Copy => {
has_selection && !self.is_secure
}
MenuAction::Paste => true,
MenuAction::SelectAll => has_value,
}
menu_action_enabled(
action,
has_selection,
has_value,
self.is_secure,
self.locked,
)
}
fn hit_action(
@@ -811,6 +834,21 @@ fn disabled_color(color: Color) -> Color {
}
}
fn menu_action_enabled(
action: MenuAction,
has_selection: bool,
has_value: bool,
is_secure: bool,
locked: bool,
) -> bool {
match action {
MenuAction::Cut => has_selection && !is_secure && !locked,
MenuAction::Copy => has_selection && !is_secure,
MenuAction::Paste => !locked,
MenuAction::SelectAll => has_value,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -891,4 +929,15 @@ mod tests {
}
);
}
#[test]
fn locked_menu_allows_copy_and_select_all_only() {
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
}
}