diff --git a/src/app/mod.rs b/src/app/mod.rs index aef5d70b9..12717ecb3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -320,6 +320,9 @@ pub(super) struct OpenCADStudio { /// 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, + /// Pointer state while the command-history panel's top edge is dragged. + command_history_resizing: bool, + command_history_drag_last: Option, status_bar: StatusBar, cursor_pos: Point, vp_size: (f32, f32), @@ -1808,6 +1811,12 @@ pub enum Message { CommandHistoryNext, /// Toggle the dropdown listing the full command-line history. CommandHistoryToggle, + /// Grab/move/release the expanded history panel's top resize edge. + CommandHistoryResizeGrab, + CommandHistoryResizeMove(Point), + CommandHistoryResizeRelease, + /// Restore the expanded history panel to its default height. + CommandHistoryHeightReset, /// Start dragging the Layer Manager's Name-column divider. LayerNameColGrab, /// Toggle the persistent literal-space mode (the `>` button): while on, @@ -2837,6 +2846,8 @@ impl OpenCADStudio { properties_drag_last: None, properties_dock_preview: None, history_content: iced::widget::text_editor::Content::new(), + command_history_resizing: false, + command_history_drag_last: None, status_bar: StatusBar::new(), cursor_pos: Point::ORIGIN, vp_size: (1280.0, 720.0), diff --git a/src/app/settings.rs b/src/app/settings.rs index c7ed4f09e..0d34a864b 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -116,6 +116,8 @@ pub struct UserSettings { /// instead of submitting (as if every line started with `>`), until the /// user toggles it back off. pub literal_spaces: bool, + /// Height of the expanded command-history editor in logical pixels. + pub command_history_height: f32, /// Running object-snap set + master toggle as an `$OSMODE`-style bitmask. /// App-level, not per-drawing: modern DWG (R2000+) has no file slot for /// OSMODE (it moved to the registry), so the set follows the user. A @@ -167,6 +169,7 @@ impl Default for UserSettings { disabled_plugins: Vec::new(), plugin_repos: Vec::new(), literal_spaces: false, + command_history_height: crate::ui::command_line::HISTORY_HEIGHT_DEFAULT, // Snapper::default(): END|MID|CEN|NODE|QUAD|INT|NEA (575), master // off (suppress bit 16384). osmode: 575 | OSMODE_SUPPRESS, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 94266b630..b202c694e 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -274,6 +274,7 @@ impl OpenCADStudio { }, plugin_repos: self.plugin_repos.clone(), literal_spaces: self.command_line.literal_spaces, + command_history_height: self.command_line.history_height, osmode: crate::app::settings::osmode_from_snaps( self.snapper.enabled.iter(), self.snapper.snap_enabled, @@ -304,6 +305,14 @@ impl OpenCADStudio { self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); self.command_line.literal_spaces = s.literal_spaces; + self.command_line.history_height = if s.command_history_height.is_finite() { + s.command_history_height.clamp( + crate::ui::command_line::HISTORY_HEIGHT_MIN, + crate::ui::command_line::HISTORY_HEIGHT_MAX, + ) + } else { + crate::ui::command_line::HISTORY_HEIGHT_DEFAULT + }; let (modes, snap_enabled) = crate::app::settings::snaps_from_osmode(s.osmode); self.snapper.enabled = modes.into_iter().collect(); self.snapper.snap_enabled = snap_enabled; @@ -587,7 +596,11 @@ impl OpenCADStudio { /// Back-compat name for the many "a preference changed, persist it" sites. pub(in crate::app) fn persist_settings_if_changed(&mut self) { - self.save_config(); + // Keep resize feedback live without writing settings on every pointer + // move. The release message saves the final height once. + if !self.command_history_resizing { + self.save_config(); + } } /// Record that the one-time default-association prompt has been answered and diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8c647591d..45fb6862a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -111,10 +111,10 @@ impl OpenCADStudio { if self.history_content.text() == latest { return; } - use iced::widget::text_editor::{Action, Motion}; self.history_content = iced::widget::text_editor::Content::with_text(&latest); - self.history_content - .perform(Action::Move(Motion::DocumentEnd)); + // The outer scrollable is anchored to the newest lines. Leaving the + // editor cursor at its initial position also keeps horizontal scroll at + // zero, so the first glyph of each line cannot be clipped. } /// Close the active in-canvas modal (Plan B), mirroring what closing the @@ -1565,10 +1565,56 @@ impl OpenCADStudio { Message::CommandHistoryToggle => { self.command_line.toggle_history(); + if !self.command_line.history_open { + self.command_history_resizing = false; + self.command_history_drag_last = None; + } self.sync_open_command_history(); Task::none() } + Message::CommandHistoryResizeGrab => { + if self.command_line.history_open { + self.command_history_resizing = true; + self.command_history_drag_last = None; + } + Task::none() + } + + Message::CommandHistoryResizeMove(point) => { + if self.command_history_resizing { + if let Some(last) = self.command_history_drag_last { + let dy = point.y - last.y; + let max_height = + crate::ui::command_line::history_max_height(self.win_size.1); + self.command_line.history_height = (self.command_line.history_height - dy) + .clamp( + crate::ui::command_line::HISTORY_HEIGHT_MIN, + max_height, + ); + } + self.command_history_drag_last = Some(point); + } + Task::none() + } + + Message::CommandHistoryResizeRelease => { + let changed = self.command_history_resizing; + self.command_history_resizing = false; + self.command_history_drag_last = None; + if changed { + self.save_config(); + } + Task::none() + } + + Message::CommandHistoryHeightReset => { + self.command_line.history_height = + crate::ui::command_line::HISTORY_HEIGHT_DEFAULT; + self.save_config(); + Task::none() + } + Message::CommandHistoryCopy => { let text = self.command_line.history_plain_text(); if text.is_empty() { @@ -1599,12 +1645,24 @@ impl OpenCADStudio { } 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); + // Read-only: drop edits, keep selection/cursor actions, and + // route wheel input to the outer scrollbar. The text editor + // remains one selectable buffer while the scrollbar stays + // visible and draggable. + if let iced::widget::text_editor::Action::Scroll { lines } = action { + iced::widget::operation::scroll_by( + iced::widget::Id::new(crate::ui::command_line::HISTORY_SCROLL_ID), + iced::widget::scrollable::AbsoluteOffset { + x: 0.0, + y: lines as f32 * 14.0, + }, + ) + } else { + if !action.is_edit() { + self.history_content.perform(action); + } + Task::none() } - Task::none() } Message::CommandSuggestionPick(cmd) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7259e8034..72ed1efa2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1499,6 +1499,7 @@ impl OpenCADStudio { allow_autocomplete, dyn_capturing, &self.history_content, + self.win_size.1, ); let center_stack: Element<'_, Message> = if tab.is_start { column![ @@ -1534,6 +1535,16 @@ impl OpenCADStudio { .into() }; + let center_stack: Element<'_, Message> = if self.command_history_resizing { + mouse_area(center_stack) + .on_move(Message::CommandHistoryResizeMove) + .on_release(Message::CommandHistoryResizeRelease) + .interaction(iced::mouse::Interaction::ResizingVertically) + .into() + } else { + center_stack + }; + let main_ui = container({ // Clean-screen mode drops the ribbon for a full-canvas view; the // status bar stays so the mode can be toggled back off. diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 50a1c657b..8e37dc32f 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -6,16 +6,32 @@ use crate::app::Message; use crate::command::CmdOption; use crate::t; use iced::widget::{ - button, column, container, opaque, row, rule, text, text_editor, text_input, tooltip, Space, + button, column, container, opaque, row, rule, scrollable, stack, text, text_editor, + text_input, tooltip, Space, }; -use iced::{Background, Border, Color, Element, Length, Theme}; +use iced::{Background, Border, Color, Element, Length, Padding, Theme}; pub const CMD_INPUT_ID: &str = "cmd_input"; +pub const HISTORY_SCROLL_ID: &str = "command_history_scroll"; /// How long a history entry stays visible on the overlay before fading /// out. Picking the full archive happens through the dropdown button. const HISTORY_VISIBLE_SECS: f32 = 3.0; +/// Resizable full-history editor bounds. The live upper bound also follows the +/// window height so the command input and a useful drawing area remain visible. +pub const HISTORY_HEIGHT_MIN: f32 = 72.0; +pub const HISTORY_HEIGHT_DEFAULT: f32 = 180.0; +pub const HISTORY_HEIGHT_MAX: f32 = 560.0; + +pub fn history_max_height(window_height: f32) -> f32 { + if window_height.is_finite() { + (window_height * 0.60).clamp(HISTORY_HEIGHT_MIN, HISTORY_HEIGHT_MAX) + } else { + HISTORY_HEIGHT_DEFAULT + } +} + /// How many autocomplete matches the suggestion popup shows at once. const AUTOCOMPLETE_LIMIT: usize = 8; @@ -63,6 +79,8 @@ pub struct CommandLine { recall_draft: String, /// When `true`, the dropdown showing the full backlog is open. pub history_open: bool, + /// Persisted height of the full-history editor in logical pixels. + pub history_height: f32, /// Index of the currently-highlighted autocomplete suggestion, or /// `None` when the user hasn't yet started navigating with the /// arrow keys. Reset on every keystroke. @@ -112,6 +130,7 @@ pub enum EntryKind { impl CommandLine { pub fn new() -> Self { let mut cl = Self::default(); + cl.history_height = HISTORY_HEIGHT_DEFAULT; cl.push_info(&crate::tr!("command-line-ready")); cl.push_info(&crate::tr!("command-line-hint")); cl @@ -358,6 +377,7 @@ impl CommandLine { show_autocomplete: bool, dyn_capturing: bool, history_content: &'a text_editor::Content, + window_height: f32, ) -> Element<'a, Message> { // Only the most recent entries pushed within the last few // seconds show on the overlay. The dropdown button keeps the @@ -482,9 +502,12 @@ impl CommandLine { .on_input(Message::CommandInput) .on_submit(Message::CommandSubmit); } - let input = input - .size(11) - .padding([4, 6]); + let input = input.size(11).padding(Padding { + top: 4.0, + right: 30.0, + bottom: 4.0, + left: 6.0, + }); // Autocomplete suggestions panel, shown above the input row // when the user has typed a prefix that matches at least one // command. Each row is a button — clicking it dispatches the @@ -546,9 +569,18 @@ impl CommandLine { }; let dropdown_btn = button(dropdown_icon) .on_press(Message::CommandHistoryToggle) - .style(button::subtle) - .padding([2, 6]); - let input_row = row![prompt, literal_btn, input, dropdown_btn] + .style(button::text) + .padding([4, 8]); + let input_with_history = stack![ + input, + container(dropdown_btn) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Right) + .align_y(iced::alignment::Vertical::Center), + ] + .width(Length::Fill); + let input_row = row![prompt, literal_btn, input_with_history] .spacing(4) .align_y(iced::Center); @@ -556,16 +588,18 @@ impl CommandLine { // whole log is rendered in ONE read-only `text_editor` (backed by // `history_content`) rather than per-line labels, so a single mouse // drag selects across lines and Ctrl+C copies the lot — issue #232. - // Edits are dropped in the update handler, keeping it read-only. The - // editor scrolls internally past `max_height`; `opaque` stops its - // mouse-wheel events bubbling to the viewport shader behind it (else - // scrolling the history zoomed the drawing). + // Edits are dropped in the update handler, keeping it read-only. A + // surrounding scrollable supplies the visible scrollbar while + // `opaque` stops its mouse-wheel events reaching the drawing behind it. let dropdown: Element<'a, Message> = if self.history_open { + let history_height = self + .history_height + .clamp(HISTORY_HEIGHT_MIN, history_max_height(window_height)); let log = text_editor(history_content) .on_action(Message::CommandHistoryEdit) .size(11) .padding([2, 8]) - .height(Length::Fit.max(180.0)) + .height(Length::Shrink) .style(|theme: &Theme, _status| { let palette = theme.palette(); text_editor::Style { @@ -576,6 +610,15 @@ impl CommandLine { selection: palette.primary.base.color.scale_alpha(0.5), } }); + let log = scrollable(log) + .id(iced::widget::Id::new(HISTORY_SCROLL_ID)) + .height(Length::Fixed(history_height)) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(8) + .scroller_width(6), + )) + .anchor_bottom(); // Header strip: a Copy-all and a Clear button pinned above the log. let copy_btn = button( row![ @@ -608,7 +651,25 @@ impl CommandLine { .padding([2, 6]); let panel = container(column![header, log]) .width(Length::Fill) - .padding([4, 0]); + .padding([4, 8]); + let resize = iced::widget::mouse_area( + container(crate::ui::icons::themed_primary( + crate::ui::icons::RESIZE, + 15.0, + )) + .padding([0, 2]), + ) + .on_press(Message::CommandHistoryResizeGrab) + .on_double_click(Message::CommandHistoryHeightReset) + .interaction(iced::mouse::Interaction::ResizingVertically); + let panel = stack![ + panel, + container(resize) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Left) + .align_y(iced::alignment::Vertical::Top), + ]; opaque(panel).into() } else { container(column![]).height(0).into()