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:
+21
-5
@@ -11,7 +11,7 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
|||||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||||
use crate::presence::PresenceMode;
|
use crate::presence::PresenceMode;
|
||||||
use crate::theme::{AppTheme, Palette};
|
use crate::theme::{AppTheme, Palette};
|
||||||
use crate::widget::context_input::context_input;
|
use crate::widget::context_input::{context_input, locked_value};
|
||||||
|
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
|
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
|
||||||
@@ -313,6 +313,8 @@ pub enum AppMessage {
|
|||||||
CopyToClipboard,
|
CopyToClipboard,
|
||||||
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
|
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
|
||||||
CopyText(String),
|
CopyText(String),
|
||||||
|
/// No-op message for controlled read-only selectable fields.
|
||||||
|
Noop,
|
||||||
TogglePtt(bool),
|
TogglePtt(bool),
|
||||||
StartHotkeyCapture(HotkeyAction),
|
StartHotkeyCapture(HotkeyAction),
|
||||||
ClearHotkey(HotkeyAction),
|
ClearHotkey(HotkeyAction),
|
||||||
@@ -1522,6 +1524,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::CopyText(s) => {
|
AppMessage::CopyText(s) => {
|
||||||
return iced::clipboard::write(s);
|
return iced::clipboard::write(s);
|
||||||
}
|
}
|
||||||
|
AppMessage::Noop => {}
|
||||||
AppMessage::TogglePtt(enabled) => {
|
AppMessage::TogglePtt(enabled) => {
|
||||||
state.ptt_enabled = enabled;
|
state.ptt_enabled = enabled;
|
||||||
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
||||||
@@ -3537,11 +3540,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
// The ID line shows a short form (iced text isn't selectable) plus a Copy
|
// The ID line exposes the full value in a locked selectable field while
|
||||||
// button that puts the FULL node id on the clipboard, so it's shareable.
|
// keeping the one-click Copy button for fast whole-ID copy.
|
||||||
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
|
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
|
||||||
Some(full) => row![
|
Some(full) => row![
|
||||||
text(format!("ID: {id_display}")).size(13).color(color_text),
|
text("ID:").size(13).color(color_text),
|
||||||
|
locked_value(&full, AppMessage::Noop)
|
||||||
|
.width(iced::Length::Fixed(260.0))
|
||||||
|
.size(13)
|
||||||
|
.padding(4),
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
icon(IconKind::Copy, 13.0, color_text),
|
icon(IconKind::Copy, 13.0, color_text),
|
||||||
@@ -3550,7 +3557,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(5)
|
.spacing(5)
|
||||||
.align_y(iced::alignment::Vertical::Center)
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
)
|
)
|
||||||
.on_press(AppMessage::CopyText(full))
|
.on_press(AppMessage::CopyText(full.clone()))
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
.padding(6),
|
.padding(6),
|
||||||
]
|
]
|
||||||
@@ -4103,6 +4110,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text(format!("My ID: {}", short_id(&state.self_id)))
|
text(format!("My ID: {}", short_id(&state.self_id)))
|
||||||
.size(14)
|
.size(14)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
|
row![
|
||||||
|
text("Ticket:").size(12).color(color_subtext),
|
||||||
|
locked_value(&state.ticket, AppMessage::Noop)
|
||||||
|
.width(iced::Length::Fixed(260.0))
|
||||||
|
.size(12)
|
||||||
|
.padding(4),
|
||||||
|
]
|
||||||
|
.spacing(6)
|
||||||
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
icon(IconKind::Copy, 14.0, color_text),
|
icon(IconKind::Copy, 14.0, color_text),
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ where
|
|||||||
ContextInput::new(placeholder, value)
|
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<
|
pub struct ContextInput<
|
||||||
'a,
|
'a,
|
||||||
Message,
|
Message,
|
||||||
@@ -117,6 +131,7 @@ pub struct ContextInput<
|
|||||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||||
value: String,
|
value: String,
|
||||||
is_secure: bool,
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
@@ -134,6 +149,7 @@ where
|
|||||||
input: text_input::TextInput::new(placeholder, value),
|
input: text_input::TextInput::new(placeholder, value),
|
||||||
value: value.to_owned(),
|
value: value.to_owned(),
|
||||||
is_secure: false,
|
is_secure: false,
|
||||||
|
locked: false,
|
||||||
on_input: None,
|
on_input: None,
|
||||||
on_paste: None,
|
on_paste: None,
|
||||||
style: None,
|
style: None,
|
||||||
@@ -151,6 +167,11 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn locked(mut self, yes: bool) -> Self {
|
||||||
|
self.locked = yes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_input(
|
pub fn on_input(
|
||||||
mut self,
|
mut self,
|
||||||
on_input: impl Fn(String) -> Message + 'a,
|
on_input: impl Fn(String) -> Message + 'a,
|
||||||
@@ -448,6 +469,7 @@ where
|
|||||||
input_state,
|
input_state,
|
||||||
value: &self.value,
|
value: &self.value,
|
||||||
is_secure: self.is_secure,
|
is_secure: self.is_secure,
|
||||||
|
locked: self.locked,
|
||||||
on_input: self.on_input.clone(),
|
on_input: self.on_input.clone(),
|
||||||
on_paste: self.on_paste.clone(),
|
on_paste: self.on_paste.clone(),
|
||||||
style: self.style.clone(),
|
style: self.style.clone(),
|
||||||
@@ -479,6 +501,7 @@ where
|
|||||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||||
value: &'a str,
|
value: &'a str,
|
||||||
is_secure: bool,
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
style: Option<InputStyleFn<'a, Theme>>,
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
@@ -686,13 +709,13 @@ where
|
|||||||
let has_selection = menu.selection.0 != menu.selection.1;
|
let has_selection = menu.selection.0 != menu.selection.1;
|
||||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||||
|
|
||||||
match action {
|
menu_action_enabled(
|
||||||
MenuAction::Cut | MenuAction::Copy => {
|
action,
|
||||||
has_selection && !self.is_secure
|
has_selection,
|
||||||
}
|
has_value,
|
||||||
MenuAction::Paste => true,
|
self.is_secure,
|
||||||
MenuAction::SelectAll => has_value,
|
self.locked,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hit_action(
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user