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 }