git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

src/plate_menu.rs (10.2K)

  1 //! The root plate's corner control: the DE's circular menu trigger (the same
  2 //! affordance cce-designer's panes and cce-files' preview pane carry, on
  3 //! `cce_ui::widget::plate_dock`) riding the top-right of the terminal
  4 //! window, and the menu it opens.
  5 //!
  6 //! The terminal has one plate — the window itself — so the toolkit's dock
  7 //! vocabulary (collapse, detach) does not apply; the rows are the actions a
  8 //! terminal without a menubar has nowhere else to put: the clipboard pair,
  9 //! text zoom, scrollback/state resets, the tabs, and a new window. The tabs
 10 //! borrow the designer's dock-tab language: every tab listed as a radio row
 11 //! (the shown one marked, so the list reads as state), then New Tab and
 12 //! Close Tab. Geometry is the toolkit's (`corner_center` on the window
 13 //! rect), the menu is the shared `context_menu`, and the rows are dispatched
 14 //! here — the same `plate_menu_actions` + `handle_plate_menu_click` contract
 15 //! as the designer.
 16 
 17 use alacritty_terminal::grid::Scroll;
 18 use alacritty_terminal::vte::ansi::Handler;
 19 use cce_ui::widget::plate_dock::{self, CORNER_R};
 20 use cce_ui::widget::{context_menu, ElementState, MouseButton, WidgetId};
 21 
 22 use crate::{TabId, TerminalApp};
 23 
 24 /// What the corner menu can do to the terminal.
 25 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 26 pub enum PlateMenuAction {
 27     /// Selection → the regular clipboard (the Ctrl+Shift+C path).
 28     Copy,
 29     /// Regular clipboard → the pty (the Ctrl+Shift+V path).
 30     Paste,
 31     /// Text zoom, one point per step over the configured size.
 32     LargerText,
 33     SmallerText,
 34     /// Back to the configured size.
 35     ResetTextSize,
 36     /// Drop the scrollback history (the viewport stays).
 37     ClearScrollback,
 38     /// Full VT reset: modes, colors, tab stops, the alternate screen.
 39     ResetTerminal,
 40     /// Bring the named tab to the front. By id, not index: a tab whose
 41     /// shell exits while the menu is open shifts the indices under it.
 42     ShowTab(TabId),
 43     /// A fresh shell in the active tab's directory, shown.
 44     NewTab,
 45     /// Close the active tab (its shell is killed).
 46     CloseTab,
 47     /// Another cce-terminal, detached.
 48     NewWindow,
 49     /// A "-" row: engraved, inert — keeps the actions aligned with the option
 50     /// rows so a click on the line dispatches nothing.
 51     Separator,
 52 }
 53 
 54 /// The menu has no widget target — the terminal has no widget tree and
 55 /// dispatches its own rows — so the shared menu's built-in action routing
 56 /// (`mouse_input` with a `UiContext`) is never used; the id is a placeholder.
 57 const NO_TARGET: WidgetId = WidgetId(0);
 58 
 59 impl TerminalApp {
 60     /// Centre of the corner control, or `None` while the window is too small
 61     /// to carry one.
 62     pub(crate) fn plate_corner_center(&self) -> Option<(f32, f32)> {
 63         plate_dock::corner_center((0.0, 0.0, self.win_w, self.win_h), false)
 64     }
 65 
 66     pub(crate) fn plate_corner_hit(&self, px: f32, py: f32) -> bool {
 67         self.plate_corner_center().is_some_and(|c| plate_dock::corner_hit(c, px, py))
 68     }
 69 
 70     pub(crate) fn plate_menu_open(&self) -> bool {
 71         context_menu::is_visible() && !self.plate_menu_actions.is_empty()
 72     }
 73 
 74     /// Open the corner menu under its control. Rows are contextual: Copy
 75     /// only with a selection to copy, Reset Text Size only while zoomed.
 76     /// The menu hangs off the control's RIGHT edge, leftwards — anchored on
 77     /// the left as the designer's panes do, it would run off the window.
 78     pub(crate) fn open_plate_menu(&mut self) {
 79         let Some((cx, cy)) = self.plate_corner_center() else { return };
 80         let mut options: Vec<String> = Vec::new();
 81         let mut actions: Vec<PlateMenuAction> = Vec::new();
 82         let mut row = |label: &str, action: PlateMenuAction| {
 83             options.push(label.to_string());
 84             actions.push(action);
 85         };
 86 
 87         if self.tab().term.selection_to_string().is_some_and(|s| !s.is_empty()) {
 88             row("Copy", PlateMenuAction::Copy);
 89         }
 90         row("Paste", PlateMenuAction::Paste);
 91         row("-", PlateMenuAction::Separator);
 92         row("Larger Text", PlateMenuAction::LargerText);
 93         row("Smaller Text", PlateMenuAction::SmallerText);
 94         if self.zoom_steps != 0 {
 95             row("Reset Text Size", PlateMenuAction::ResetTextSize);
 96         }
 97         row("-", PlateMenuAction::Separator);
 98         row("Clear Scrollback", PlateMenuAction::ClearScrollback);
 99         row("Reset Terminal", PlateMenuAction::ResetTerminal);
100         row("-", PlateMenuAction::Separator);
101         // The tabs as a RADIO group: every one listed, the shown one marked.
102         // Clicking the marked row is a no-op (show_tab declines the active
103         // index), so the list reads as state, not just as actions.
104         for (i, tab) in self.tabs.iter().enumerate() {
105             let mark = if i == self.active { "●" } else { "○" };
106             row(&format!("{mark} {}", tab.label(i)), PlateMenuAction::ShowTab(tab.id));
107         }
108         row("New Tab", PlateMenuAction::NewTab);
109         if self.tabs.len() > 1 {
110             row("Close Tab", PlateMenuAction::CloseTab);
111         }
112         row("-", PlateMenuAction::Separator);
113         row("New Window", PlateMenuAction::NewWindow);
114 
115         // The menu sizes itself from its labels on `show`, so place it once
116         // to learn the width, then again with its right edge on the control.
117         let top = cy + CORNER_R;
118         context_menu::show(0.0, top, options.clone(), 0, NO_TARGET);
119         let left = (cx + CORNER_R - context_menu::w()).max(0.0);
120         context_menu::show(left, top, options, 0, NO_TARGET);
121         self.plate_menu_actions = actions;
122     }
123 
124     pub(crate) fn close_plate_menu(&mut self) {
125         context_menu::hide();
126         self.plate_menu_actions.clear();
127     }
128 
129     /// Route a button event while the corner menu is open: a left press on a
130     /// row dispatches it, any other press dismisses, and releases are eaten
131     /// so the press that opened the menu never completes as a click on the
132     /// grid underneath. `true` when the event was the menu's.
133     pub(crate) fn handle_plate_menu_input(
134         &mut self,
135         button: MouseButton,
136         state: ElementState,
137         px: f32,
138         py: f32,
139     ) -> bool {
140         if !self.plate_menu_open() {
141             return false;
142         }
143         if state != ElementState::Pressed {
144             return true;
145         }
146         let picked = if button == MouseButton::Left {
147             context_menu::row_at(px, py).and_then(|row| self.plate_menu_actions.get(row).copied())
148         } else {
149             None
150         };
151         self.close_plate_menu();
152         if let Some(action) = picked {
153             self.dispatch_plate_menu(action);
154         }
155         true
156     }
157 
158     fn dispatch_plate_menu(&mut self, action: PlateMenuAction) {
159         match action {
160             PlateMenuAction::Copy => {
161                 if let Some(text) = self.tab().term.selection_to_string() {
162                     if !text.is_empty() {
163                         cce_ui::widget::clipboard::copy_to_clipboard(&text);
164                     }
165                 }
166             }
167             PlateMenuAction::Paste => {
168                 if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
169                     if !text.is_empty() {
170                         self.paste(&text);
171                     }
172                 }
173             }
174             PlateMenuAction::LargerText => self.set_zoom(self.zoom_steps + 1),
175             PlateMenuAction::SmallerText => self.set_zoom(self.zoom_steps - 1),
176             PlateMenuAction::ResetTextSize => self.set_zoom(0),
177             PlateMenuAction::ClearScrollback => {
178                 // Drop the view to the live screen first: a display offset
179                 // into history that no longer exists is not a state the grid
180                 // guards against.
181                 let term = &mut self.tab_mut().term;
182                 term.scroll_display(Scroll::Bottom);
183                 term.grid_mut().clear_history();
184                 self.sync_scroll_motion();
185             }
186             PlateMenuAction::ResetTerminal => {
187                 let term = &mut self.tab_mut().term;
188                 term.selection = None;
189                 term.reset_state();
190                 self.sync_scroll_motion();
191             }
192             PlateMenuAction::ShowTab(id) => self.show_tab_by_id(id),
193             PlateMenuAction::NewTab => self.new_tab(),
194             PlateMenuAction::CloseTab => self.close_active_tab(),
195             PlateMenuAction::NewWindow => match std::env::current_exe() {
196                 Ok(exe) => {
197                     if let Err(e) = cce_ui::process::spawn_detached(std::process::Command::new(exe)) {
198                         log::warn!("cce-terminal: failed to spawn a new window: {e}");
199                     }
200                 }
201                 Err(e) => log::warn!("cce-terminal: cannot locate own executable: {e}"),
202             },
203             PlateMenuAction::Separator => {}
204         }
205     }
206 
207     /// Text zoom: re-derive the settings at the new step and reflow the grid,
208     /// the same path a config edit takes.
209     fn set_zoom(&mut self, steps: i32) {
210         self.zoom_steps = steps.clamp(-8, 24);
211         let font = self.font.clone();
212         let reloaded = crate::Settings::load(&font, self.zoom_steps);
213         self.apply_settings(reloaded);
214     }
215 
216     /// Draw the corner control (emphasized while hovered or open) and, over
217     /// everything, the open menu. Called last in `display_list`.
218     pub(crate) fn paint_plate_menu(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
219         if let Some(c) = self.plate_corner_center() {
220             let emphasized = self.plate_menu_open()
221                 || plate_dock::corner_hit(c, self.last_pointer.x as f32, self.last_pointer.y as f32);
222             plate_dock::draw_corner_dot(pc, c, emphasized);
223         }
224         if !self.plate_menu_open() {
225             return;
226         }
227         // The shared menu paints as the DE's lit plate; its labels carry
228         // bounds equal to the menu rect, which the engine's text-occlusion
229         // clamp exempts, so they render inside the menu while the grid's
230         // text beneath stays clamped.
231         // Labels come with the plate: a TextLabel carries no family, so the
232         // hand-rolled label loop that used to live here passed None and drew
233         // the menu in the default sans rather than the DE's menu font.
234         context_menu::paint_with_labels(pc);
235     }
236 }