feat(command-line): copy/clear history + selectable log (#232)
The command-line history dropdown was display-only, so users had to screenshot and OCR the log to report output. Make it copyable: - Render the whole backlog in one read-only text_editor so a single mouse drag selects across lines and Ctrl+C copies the span. - Add Copy (whole log to clipboard) and Clear buttons to the dropdown. - Gate the Ctrl+C/X/V accelerators on event Status::Ignored so a focused text widget's copy/cut/paste wins instead of firing COPYCLIP/CUTCLIP; the drawing's clipboard shortcuts still work when it has focus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ada9952f06
commit
1e9de924be
6 changed files with 181 additions and 30 deletions
1
assets/icons/ui/copy.svg
Normal file
1
assets/icons/ui/copy.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15 H4 a1 1 0 0 1 -1 -1 V4 a1 1 0 0 1 1 -1 H14 a1 1 0 0 1 1 1 V5"/></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
|
|
@ -194,6 +194,10 @@ pub(super) struct OpenCADStudio {
|
||||||
ribbon: Ribbon,
|
ribbon: Ribbon,
|
||||||
app_menu: AppMenu,
|
app_menu: AppMenu,
|
||||||
command_line: CommandLine,
|
command_line: CommandLine,
|
||||||
|
/// Read-only editor buffer backing the command-line history dropdown, so
|
||||||
|
/// the log can be drag-selected across lines and copied (issue #232).
|
||||||
|
/// Rebuilt from the history each time the dropdown is opened.
|
||||||
|
history_content: iced::widget::text_editor::Content,
|
||||||
status_bar: StatusBar,
|
status_bar: StatusBar,
|
||||||
cursor_pos: Point,
|
cursor_pos: Point,
|
||||||
vp_size: (f32, f32),
|
vp_size: (f32, f32),
|
||||||
|
|
@ -1176,6 +1180,16 @@ pub enum Message {
|
||||||
CommandHistoryNext,
|
CommandHistoryNext,
|
||||||
/// Toggle the dropdown listing the full command-line history.
|
/// Toggle the dropdown listing the full command-line history.
|
||||||
CommandHistoryToggle,
|
CommandHistoryToggle,
|
||||||
|
/// Copy the full command-line history (every line) to the system
|
||||||
|
/// clipboard as plain text — issue #232, so output can be pasted for
|
||||||
|
/// debugging instead of screenshotted.
|
||||||
|
CommandHistoryCopy,
|
||||||
|
/// Clear every line from the command-line history.
|
||||||
|
CommandHistoryClear,
|
||||||
|
/// Text-editor action from the read-only history dropdown. Only
|
||||||
|
/// non-editing actions (cursor moves, selection, scroll) are applied so
|
||||||
|
/// the log stays read-only while remaining drag-selectable and copyable.
|
||||||
|
CommandHistoryEdit(iced::widget::text_editor::Action),
|
||||||
/// User clicked an autocomplete suggestion — fill the input with
|
/// User clicked an autocomplete suggestion — fill the input with
|
||||||
/// the chosen command name and dispatch it.
|
/// the chosen command name and dispatch it.
|
||||||
CommandSuggestionPick(String),
|
CommandSuggestionPick(String),
|
||||||
|
|
@ -1906,6 +1920,7 @@ impl OpenCADStudio {
|
||||||
ribbon: Ribbon::new(),
|
ribbon: Ribbon::new(),
|
||||||
app_menu,
|
app_menu,
|
||||||
command_line: CommandLine::new(),
|
command_line: CommandLine::new(),
|
||||||
|
history_content: iced::widget::text_editor::Content::new(),
|
||||||
status_bar: StatusBar::new(),
|
status_bar: StatusBar::new(),
|
||||||
cursor_pos: Point::ORIGIN,
|
cursor_pos: Point::ORIGIN,
|
||||||
vp_size: (1280.0, 720.0),
|
vp_size: (1280.0, 720.0),
|
||||||
|
|
|
||||||
|
|
@ -774,6 +774,41 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
Message::CommandHistoryToggle => {
|
Message::CommandHistoryToggle => {
|
||||||
self.command_line.toggle_history();
|
self.command_line.toggle_history();
|
||||||
|
// On open, snapshot the current log into the read-only editor
|
||||||
|
// buffer so it can be drag-selected across lines and copied.
|
||||||
|
if self.command_line.history_open {
|
||||||
|
use iced::widget::text_editor::{Action, Motion};
|
||||||
|
self.history_content = iced::widget::text_editor::Content::with_text(
|
||||||
|
&self.command_line.history_plain_text(),
|
||||||
|
);
|
||||||
|
// Scroll to the newest line (bottom), the most useful when
|
||||||
|
// opening the log to grab a recent error.
|
||||||
|
self.history_content.perform(Action::Move(Motion::DocumentEnd));
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::CommandHistoryCopy => {
|
||||||
|
let text = self.command_line.history_plain_text();
|
||||||
|
if text.is_empty() {
|
||||||
|
Task::none()
|
||||||
|
} else {
|
||||||
|
iced::clipboard::write(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::CommandHistoryClear => {
|
||||||
|
self.command_line.clear_history();
|
||||||
|
self.history_content = iced::widget::text_editor::Content::new();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::CommandHistoryEdit(action) => {
|
||||||
|
// Read-only: drop edits, keep selection / cursor / scroll so
|
||||||
|
// the user can still highlight and Ctrl+C the log.
|
||||||
|
if !action.is_edit() {
|
||||||
|
self.history_content.perform(action);
|
||||||
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1105,7 +1105,11 @@ impl OpenCADStudio {
|
||||||
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|
||||||
|| self.text_inline.is_some();
|
|| self.text_inline.is_some();
|
||||||
let command_line_overlay =
|
let command_line_overlay =
|
||||||
iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing))
|
iced::widget::container(self.command_line.view(
|
||||||
|
allow_autocomplete,
|
||||||
|
dyn_capturing,
|
||||||
|
&self.history_content,
|
||||||
|
))
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.align_x(iced::alignment::Horizontal::Center)
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
|
|
@ -1583,9 +1587,21 @@ impl OpenCADStudio {
|
||||||
"z" if !shift => Some(Message::Undo),
|
"z" if !shift => Some(Message::Undo),
|
||||||
"z" if shift => Some(Message::Redo),
|
"z" if shift => Some(Message::Redo),
|
||||||
"y" => Some(Message::Redo),
|
"y" => Some(Message::Redo),
|
||||||
"c" => Some(Message::Command("COPYCLIP".to_string())),
|
// Clipboard accelerators defer to a focused
|
||||||
"x" => Some(Message::Command("CUTCLIP".to_string())),
|
// text widget: when the command-line history
|
||||||
"v" => Some(Message::PasteShortcut),
|
// editor (or any text field) captures Ctrl+C/
|
||||||
|
// X/V it copies/cuts/pastes its own text and
|
||||||
|
// marks the event Captured, so we must NOT also
|
||||||
|
// fire the drawing's COPYCLIP/CUTCLIP/paste.
|
||||||
|
// Only when nothing captured (the drawing has
|
||||||
|
// focus, status Ignored) do these run. (#232)
|
||||||
|
"c" if status == Status::Ignored => {
|
||||||
|
Some(Message::Command("COPYCLIP".to_string()))
|
||||||
|
}
|
||||||
|
"x" if status == Status::Ignored => {
|
||||||
|
Some(Message::Command("CUTCLIP".to_string()))
|
||||||
|
}
|
||||||
|
"v" if status == Status::Ignored => Some(Message::PasteShortcut),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
// Printable glyphs are already handled by the
|
// Printable glyphs are already handled by the
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
use iced::time::Instant;
|
use iced::time::Instant;
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use iced::widget::{button, column, container, opaque, row, scrollable, text, text_input};
|
use iced::widget::{
|
||||||
|
button, column, container, opaque, row, text, text_editor, text_input, Space,
|
||||||
|
};
|
||||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
pub const CMD_INPUT_ID: &str = "cmd_input";
|
pub const CMD_INPUT_ID: &str = "cmd_input";
|
||||||
|
|
@ -224,6 +226,25 @@ impl CommandLine {
|
||||||
self.history_open = false;
|
self.history_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole history flattened to plain text, one entry per line, for the
|
||||||
|
/// clipboard-copy button (issue #232). Entries carry no per-line prefix
|
||||||
|
/// beyond what `push_*` already baked into `text` (e.g. "Command: "), so
|
||||||
|
/// the pasted block reads the same as the on-screen log.
|
||||||
|
pub fn history_plain_text(&self) -> String {
|
||||||
|
self.history
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every history line. The step-prompt mirror is reset too so a
|
||||||
|
/// later step still repins correctly.
|
||||||
|
pub fn clear_history(&mut self) {
|
||||||
|
self.history.clear();
|
||||||
|
self.step_prompt = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the autocomplete highlight up one entry. Wraps to the last
|
/// Move the autocomplete highlight up one entry. Wraps to the last
|
||||||
/// match. Returns `true` when there was a list to navigate.
|
/// match. Returns `true` when there was a list to navigate.
|
||||||
pub fn autocomplete_prev(&mut self) -> bool {
|
pub fn autocomplete_prev(&mut self) -> bool {
|
||||||
|
|
@ -268,7 +289,12 @@ impl CommandLine {
|
||||||
ranked_matches(self.input.trim())
|
ranked_matches(self.input.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> {
|
pub fn view<'a>(
|
||||||
|
&'a self,
|
||||||
|
show_autocomplete: bool,
|
||||||
|
dyn_capturing: bool,
|
||||||
|
history_content: &'a text_editor::Content,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
// Only the most recent entries pushed within the last few
|
// Only the most recent entries pushed within the last few
|
||||||
// seconds show on the overlay. The dropdown button keeps the
|
// seconds show on the overlay. The dropdown button keeps the
|
||||||
// full backlog reachable when the user actually wants it.
|
// full backlog reachable when the user actually wants it.
|
||||||
|
|
@ -420,30 +446,63 @@ impl CommandLine {
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.align_y(iced::Center);
|
.align_y(iced::Center);
|
||||||
|
|
||||||
// Full backlog dropdown — appears ABOVE the input pill when
|
// Full backlog dropdown — appears ABOVE the input pill when open. The
|
||||||
// open. The history `Vec` already contains every line pushed
|
// whole log is rendered in ONE read-only `text_editor` (backed by
|
||||||
// since startup; render them all (newest at the bottom) in a
|
// `history_content`) rather than per-line labels, so a single mouse
|
||||||
// scrollable. `opaque` wraps the panel so mouse-wheel events
|
// drag selects across lines and Ctrl+C copies the lot — issue #232.
|
||||||
// inside the dropdown don't bubble through to the viewport
|
// Edits are dropped in the update handler, keeping it read-only. The
|
||||||
// shader behind it (otherwise scrolling the history zoomed
|
// editor scrolls internally past `max_height`; `opaque` stops its
|
||||||
// the drawing). `anchor_bottom` keeps the newest line in view
|
// mouse-wheel events bubbling to the viewport shader behind it (else
|
||||||
// when the dropdown first opens.
|
// scrolling the history zoomed the drawing).
|
||||||
let dropdown: Element<'_, Message> = if self.history_open {
|
let dropdown: Element<'a, Message> = if self.history_open {
|
||||||
let mut col = column![].spacing(0).width(Length::Fill);
|
let log = text_editor(history_content)
|
||||||
for entry in &self.history {
|
.on_action(Message::CommandHistoryEdit)
|
||||||
let color = match entry.kind {
|
.size(11)
|
||||||
EntryKind::Command => CMD_COLOR,
|
.padding([2, 8])
|
||||||
EntryKind::Output => OUT_COLOR,
|
.max_height(180.0)
|
||||||
EntryKind::Error => ERR_COLOR,
|
.style(|_: &Theme, _status| text_editor::Style {
|
||||||
EntryKind::Info => INFO_COLOR,
|
background: Background::Color(PANEL_BG),
|
||||||
};
|
border: Border::default(),
|
||||||
col = col.push(
|
placeholder: OUT_COLOR,
|
||||||
container(text(&entry.text).size(11).color(color)).padding([1, 8]),
|
value: CMD_COLOR,
|
||||||
);
|
selection: Color {
|
||||||
}
|
r: 0.20,
|
||||||
let panel = container(
|
g: 0.44,
|
||||||
scrollable(col).anchor_bottom().width(Length::Fill),
|
b: 0.72,
|
||||||
|
a: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Header strip: a Copy-all and a Clear button pinned above the log.
|
||||||
|
let copy_btn = button(
|
||||||
|
row![
|
||||||
|
crate::ui::icons::tinted(crate::ui::icons::COPY, 11.0, PROMPT_COLOR),
|
||||||
|
text("Copy").size(11).color(CMD_COLOR),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Center),
|
||||||
)
|
)
|
||||||
|
.on_press(Message::CommandHistoryCopy)
|
||||||
|
.style(header_btn_style)
|
||||||
|
.padding([2, 6]);
|
||||||
|
let clear_btn = button(
|
||||||
|
row![
|
||||||
|
crate::ui::icons::tinted(crate::ui::icons::TRASH, 11.0, ERR_COLOR),
|
||||||
|
text("Clear").size(11).color(CMD_COLOR),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(iced::Center),
|
||||||
|
)
|
||||||
|
.on_press(Message::CommandHistoryClear)
|
||||||
|
.style(header_btn_style)
|
||||||
|
.padding([2, 6]);
|
||||||
|
let header = container(
|
||||||
|
row![Space::new().width(Length::Fill), copy_btn, clear_btn]
|
||||||
|
.spacing(6)
|
||||||
|
.align_y(iced::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([2, 6]);
|
||||||
|
let panel = container(column![header, log])
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(PANEL_BG)),
|
background: Some(Background::Color(PANEL_BG)),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|
@ -454,7 +513,6 @@ impl CommandLine {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.max_height(200.0)
|
|
||||||
.padding([4, 0]);
|
.padding([4, 0]);
|
||||||
opaque(panel).into()
|
opaque(panel).into()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -527,6 +585,31 @@ pub fn ranked_matches(needle: &str) -> Vec<&'static str> {
|
||||||
matches
|
matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flat button style for the history dropdown's Copy / Clear strip: a subtle
|
||||||
|
/// filled pill that brightens on hover.
|
||||||
|
fn header_btn_style(_: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let bg = if matches!(status, button::Status::Hovered) {
|
||||||
|
Color {
|
||||||
|
r: 0.24,
|
||||||
|
g: 0.24,
|
||||||
|
b: 0.24,
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
INPUT_ROW_BG
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: Color::WHITE,
|
||||||
|
border: Border {
|
||||||
|
color: BORDER_COLOR,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 3.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PANEL_BG: Color = Color {
|
const PANEL_BG: Color = Color {
|
||||||
r: 0.15,
|
r: 0.15,
|
||||||
g: 0.15,
|
g: 0.15,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ pub const CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg");
|
||||||
pub const CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg");
|
pub const CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg");
|
||||||
pub const PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg");
|
pub const PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg");
|
||||||
pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg");
|
pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg");
|
||||||
|
pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg");
|
||||||
pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg");
|
pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg");
|
||||||
pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg");
|
pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg");
|
||||||
pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
|
pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue