terminal emulator
git clone https://git.lucas.co/cce-terminal.git
feat: corner menu on the window plate (cce-ui plate_dock)
The DE's circular menu trigger — the control cce-designer's panes and
cce-files' preview pane carry — on the terminal window's top-right,
opening the shared context menu. The terminal has one plate and no dock
vocabulary, so the rows are the actions a menubar-less terminal has
nowhere else to put: Copy (with a selection) / Paste, Larger / Smaller /
Reset Text Size (a session-only zoom over the configured font size,
reflowing the grid through the config-reload path), Clear Scrollback,
Reset Terminal, and New Window.
The menu hangs leftwards off the control's right edge — anchored on the
left as the designer's panes are, it would run off the window. While it
is open it owns every button event (releases are eaten so the opening
press never completes as a click on the grid) and Escape dismisses it;
a press on the control never reaches the grid as a selection or a mouse
report.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/main.rs | 89 +++++++++++++++++++----
src/plate_menu.rs | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 288 insertions(+), 13 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index ea9c165..b563724 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -19,6 +19,11 @@
//! reports at cell coordinates, plus focus in/out (1004); holding Shift
//! bypasses reporting so selection stays reachable, per convention.
//!
+//! The window plate carries the DE's corner control ([`plate_menu`]): the
+//! circular trigger on the top-right that cce-designer's panes wear, opening
+//! a menu of the actions a menubar-less terminal has nowhere else to put —
+//! copy/paste, text zoom, scrollback and terminal resets, a new window.
+//!
//! Config (KDL, live-reloaded on file change): a `terminal { … }` section in
//! the shared `~/.config/cce/config.kdl` or the per-app
//! `~/.config/cce/cce-terminal/config.kdl` (app file wins) with `font_size`,
@@ -32,6 +37,7 @@
mod clip;
mod colors;
+mod plate_menu;
mod pty;
use std::io::{Read, Write};
@@ -75,8 +81,11 @@ struct Settings {
}
impl Settings {
- fn load(font_family: &str) -> Self {
- let font_size = cce_ui::config::get_f32("/terminal/font_size", 14.0).clamp(6.0, 72.0);
+ /// `zoom_steps` is the corner menu's text zoom: one point per step over
+ /// the configured size, session-only (a config edit keeps the zoom).
+ fn load(font_family: &str, zoom_steps: i32) -> Self {
+ let font_size = (cce_ui::config::get_f32("/terminal/font_size", 14.0) + zoom_steps as f32)
+ .clamp(6.0, 72.0);
let scrollback =
cce_ui::config::get_i64("/terminal/scrollback", 5000).clamp(0, 200_000) as usize;
// Measure the real glyph advance; the 0.60 em toolkit estimate is the
@@ -230,6 +239,10 @@ struct TerminalApp {
mouse_buttons: u8,
/// Cell of the last motion report — motion is per-cell, not per-pixel.
last_mouse_cell: Option<(usize, usize)>,
+ /// Corner-menu text zoom, in points over the configured font size.
+ zoom_steps: i32,
+ /// Rows of the OPEN corner menu (empty = not open); see [`plate_menu`].
+ plate_menu_actions: Vec<plate_menu::PlateMenuAction>,
}
impl TerminalApp {
@@ -326,6 +339,23 @@ impl TerminalApp {
true
}
+ /// Swap in re-derived settings (a config edit, a zoom step) and reflow
+ /// the grid and pty to match. `true` when anything changed.
+ fn apply_settings(&mut self, reloaded: Settings) -> bool {
+ let old = std::mem::replace(&mut self.settings, reloaded);
+ if reloaded == old {
+ return false;
+ }
+ let (cols, rows) = self.grid_for(self.win_w, self.win_h);
+ if (cols, rows) != (self.cols, self.rows) {
+ self.cols = cols;
+ self.rows = rows;
+ self.pty.resize(cols, rows);
+ self.term.resize(TermDims { cols, rows });
+ }
+ true
+ }
+
/// Send pasted text to the pty and snap the view to the bottom.
fn paste(&mut self, text: &str) {
let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
@@ -503,7 +533,7 @@ impl Application for TerminalApp {
// fontconfig `terminal` alias before the DE's fonts moved into the
// shared KDL config), falling back to Noto Sans Mono.
let font = cce_ui::layout::read_preferred_fonts().3;
- let settings = Settings::load(&font);
+ let settings = Settings::load(&font, 0);
let config_stamp = cce_ui::config::config_files_modified();
let (cols, rows) = grid_dims(INIT_W as f32, INIT_H as f32, pad, &settings);
@@ -574,6 +604,8 @@ impl Application for TerminalApp {
alt_down: false,
mouse_buttons: 0,
last_mouse_cell: None,
+ zoom_steps: 0,
+ plate_menu_actions: Vec::new(),
}
}
@@ -713,17 +745,9 @@ impl Application for TerminalApp {
}
self.config_stamp = stamp;
let font = self.font.clone();
- let reloaded = Settings::load(&font);
- let old = std::mem::replace(&mut self.settings, reloaded);
- if reloaded != old {
+ let reloaded = Settings::load(&font, self.zoom_steps);
+ if self.apply_settings(reloaded) {
*needs_rebuild = true;
- let (cols, rows) = self.grid_for(self.win_w, self.win_h);
- if (cols, rows) != (self.cols, self.rows) {
- self.cols = cols;
- self.rows = rows;
- self.pty.resize(cols, rows);
- self.term.resize(TermDims { cols, rows });
- }
}
}
@@ -979,6 +1003,9 @@ impl Application for TerminalApp {
pc.quad(frame, quad_color(cfg_palette.foreground, 0.12 * self.bell));
}
+ // The corner control over the grid, and its menu over everything.
+ self.paint_plate_menu(&mut pc);
+
Some(pc.finish())
}
@@ -1005,7 +1032,19 @@ impl Application for TerminalApp {
if std::env::var_os("CCE_TERM_DEBUG").is_some() {
eprintln!("[input] move ({:.1},{:.1}) selecting={}", pos.x, pos.y, self.selecting);
}
+ // The corner control's hover emphasis is a repaint; the open menu
+ // takes the pointer exclusively (its own row hover).
+ let was_on_corner = self.plate_corner_hit(self.last_pointer.x as f32, self.last_pointer.y as f32);
self.last_pointer = pos;
+ if self.plate_menu_open() {
+ if cce_ui::widget::context_menu::cursor_moved(pos.x as f32, pos.y as f32) {
+ *needs_rebuild = true;
+ }
+ return;
+ }
+ if self.plate_corner_hit(pos.x as f32, pos.y as f32) != was_on_corner {
+ *needs_rebuild = true;
+ }
if self.mouse_reporting() && !self.selecting {
let mode = *self.term.mode();
let motion_wanted = mode.contains(TermMode::MOUSE_MOTION)
@@ -1065,6 +1104,23 @@ impl Application for TerminalApp {
}
}
+ // The corner menu, ahead of everything: an open menu owns every
+ // button event, and a left press on the control opens it (and never
+ // reaches the grid — not as a selection, not as a mouse report).
+ let (px, py) = (pos.x as f32, pos.y as f32);
+ if self.handle_plate_menu_input(button, state, px, py) {
+ *needs_rebuild = true;
+ return None;
+ }
+ if button == MouseButton::Left
+ && state == ElementState::Pressed
+ && self.plate_corner_hit(px, py)
+ {
+ self.open_plate_menu();
+ *needs_rebuild = true;
+ return None;
+ }
+
// Application-owned pointer: report and stop — no selection, no
// middle-paste (Shift bypasses via mouse_reporting).
if self.mouse_reporting() && !self.selecting {
@@ -1189,6 +1245,13 @@ impl Application for TerminalApp {
return None;
}
+ // Escape dismisses the corner menu instead of reaching the shell.
+ if self.plate_menu_open() && matches!(event.logical_key, Key::Named(NamedKey::Escape)) {
+ self.close_plate_menu();
+ *needs_rebuild = true;
+ return None;
+ }
+
// App shortcuts (input.kdl-rebindable), ahead of terminal encoding —
// a bare Ctrl+C must still reach the shell as SIGINT.
use cce_ui::widget::match_key_shortcut;
diff --git a/src/plate_menu.rs b/src/plate_menu.rs
new file mode 100644
index 0000000..37cb4ce
--- /dev/null
+++ b/src/plate_menu.rs
@@ -0,0 +1,212 @@
+//! The root plate's corner control: the DE's circular menu trigger (the same
+//! affordance cce-designer's panes and cce-files' preview pane carry, on
+//! `cce_ui::widget::plate_dock`) riding the top-right of the terminal
+//! window, and the menu it opens.
+//!
+//! The terminal has one plate — the window itself — so the toolkit's dock
+//! vocabulary (collapse, detach) does not apply; the rows are the actions a
+//! terminal without a menubar has nowhere else to put: the clipboard pair,
+//! text zoom, scrollback/state resets, and a new window. Geometry is the
+//! toolkit's (`corner_center` on the window rect), the menu is the shared
+//! `context_menu`, and the rows are dispatched here — the same
+//! `plate_menu_actions` + `handle_plate_menu_click` contract as the designer.
+
+use alacritty_terminal::grid::Scroll;
+use alacritty_terminal::vte::ansi::Handler;
+use cce_ui::widget::plate_dock::{self, CORNER_R};
+use cce_ui::widget::{context_menu, ElementState, MouseButton, WidgetId};
+
+use crate::TerminalApp;
+
+/// What the corner menu can do to the terminal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlateMenuAction {
+ /// Selection → the regular clipboard (the Ctrl+Shift+C path).
+ Copy,
+ /// Regular clipboard → the pty (the Ctrl+Shift+V path).
+ Paste,
+ /// Text zoom, one point per step over the configured size.
+ LargerText,
+ SmallerText,
+ /// Back to the configured size.
+ ResetTextSize,
+ /// Drop the scrollback history (the viewport stays).
+ ClearScrollback,
+ /// Full VT reset: modes, colors, tabs, the alternate screen.
+ ResetTerminal,
+ /// Another cce-terminal, detached.
+ NewWindow,
+ /// A "-" row: engraved, inert — keeps the actions aligned with the option
+ /// rows so a click on the line dispatches nothing.
+ Separator,
+}
+
+/// The menu has no widget target — the terminal has no widget tree and
+/// dispatches its own rows — so the shared menu's built-in action routing
+/// (`mouse_input` with a `UiContext`) is never used; the id is a placeholder.
+const NO_TARGET: WidgetId = WidgetId(0);
+
+impl TerminalApp {
+ /// Centre of the corner control, or `None` while the window is too small
+ /// to carry one.
+ pub(crate) fn plate_corner_center(&self) -> Option<(f32, f32)> {
+ plate_dock::corner_center((0.0, 0.0, self.win_w, self.win_h), false)
+ }
+
+ pub(crate) fn plate_corner_hit(&self, px: f32, py: f32) -> bool {
+ self.plate_corner_center().is_some_and(|c| plate_dock::corner_hit(c, px, py))
+ }
+
+ pub(crate) fn plate_menu_open(&self) -> bool {
+ context_menu::is_visible() && !self.plate_menu_actions.is_empty()
+ }
+
+ /// Open the corner menu under its control. Rows are contextual: Copy
+ /// only with a selection to copy, Reset Text Size only while zoomed.
+ /// The menu hangs off the control's RIGHT edge, leftwards — anchored on
+ /// the left as the designer's panes do, it would run off the window.
+ pub(crate) fn open_plate_menu(&mut self) {
+ let Some((cx, cy)) = self.plate_corner_center() else { return };
+ let mut options: Vec<String> = Vec::new();
+ let mut actions: Vec<PlateMenuAction> = Vec::new();
+ let mut row = |label: &str, action: PlateMenuAction| {
+ options.push(label.to_string());
+ actions.push(action);
+ };
+
+ if self.term.selection_to_string().is_some_and(|s| !s.is_empty()) {
+ row("Copy", PlateMenuAction::Copy);
+ }
+ row("Paste", PlateMenuAction::Paste);
+ row("-", PlateMenuAction::Separator);
+ row("Larger Text", PlateMenuAction::LargerText);
+ row("Smaller Text", PlateMenuAction::SmallerText);
+ if self.zoom_steps != 0 {
+ row("Reset Text Size", PlateMenuAction::ResetTextSize);
+ }
+ row("-", PlateMenuAction::Separator);
+ row("Clear Scrollback", PlateMenuAction::ClearScrollback);
+ row("Reset Terminal", PlateMenuAction::ResetTerminal);
+ row("-", PlateMenuAction::Separator);
+ row("New Window", PlateMenuAction::NewWindow);
+
+ // The menu sizes itself from its labels on `show`, so place it once
+ // to learn the width, then again with its right edge on the control.
+ let top = cy + CORNER_R;
+ context_menu::show(0.0, top, options.clone(), 0, NO_TARGET);
+ let left = (cx + CORNER_R - context_menu::w()).max(0.0);
+ context_menu::show(left, top, options, 0, NO_TARGET);
+ self.plate_menu_actions = actions;
+ }
+
+ pub(crate) fn close_plate_menu(&mut self) {
+ context_menu::hide();
+ self.plate_menu_actions.clear();
+ }
+
+ /// Route a button event while the corner menu is open: a left press on a
+ /// row dispatches it, any other press dismisses, and releases are eaten
+ /// so the press that opened the menu never completes as a click on the
+ /// grid underneath. `true` when the event was the menu's.
+ pub(crate) fn handle_plate_menu_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ px: f32,
+ py: f32,
+ ) -> bool {
+ if !self.plate_menu_open() {
+ return false;
+ }
+ if state != ElementState::Pressed {
+ return true;
+ }
+ let picked = if button == MouseButton::Left && context_menu::hit_test(px, py) {
+ let row = ((py - context_menu::y()) / context_menu::ROW_H).floor() as usize;
+ self.plate_menu_actions.get(row).copied()
+ } else {
+ None
+ };
+ self.close_plate_menu();
+ if let Some(action) = picked {
+ self.dispatch_plate_menu(action);
+ }
+ true
+ }
+
+ fn dispatch_plate_menu(&mut self, action: PlateMenuAction) {
+ match action {
+ PlateMenuAction::Copy => {
+ if let Some(text) = self.term.selection_to_string() {
+ if !text.is_empty() {
+ cce_ui::widget::clipboard::copy_to_clipboard(&text);
+ }
+ }
+ }
+ PlateMenuAction::Paste => {
+ if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
+ if !text.is_empty() {
+ self.paste(&text);
+ }
+ }
+ }
+ PlateMenuAction::LargerText => self.set_zoom(self.zoom_steps + 1),
+ PlateMenuAction::SmallerText => self.set_zoom(self.zoom_steps - 1),
+ PlateMenuAction::ResetTextSize => self.set_zoom(0),
+ PlateMenuAction::ClearScrollback => {
+ // Drop the view to the live screen first: a display offset
+ // into history that no longer exists is not a state the grid
+ // guards against.
+ self.term.scroll_display(Scroll::Bottom);
+ self.term.grid_mut().clear_history();
+ self.sync_scroll_motion();
+ }
+ PlateMenuAction::ResetTerminal => {
+ self.term.selection = None;
+ self.term.reset_state();
+ self.sync_scroll_motion();
+ }
+ PlateMenuAction::NewWindow => match std::env::current_exe() {
+ Ok(exe) => {
+ if let Err(e) = cce_ui::process::spawn_detached(std::process::Command::new(exe)) {
+ log::warn!("cce-terminal: failed to spawn a new window: {e}");
+ }
+ }
+ Err(e) => log::warn!("cce-terminal: cannot locate own executable: {e}"),
+ },
+ PlateMenuAction::Separator => {}
+ }
+ }
+
+ /// Text zoom: re-derive the settings at the new step and reflow the grid,
+ /// the same path a config edit takes.
+ fn set_zoom(&mut self, steps: i32) {
+ self.zoom_steps = steps.clamp(-8, 24);
+ let font = self.font.clone();
+ let reloaded = crate::Settings::load(&font, self.zoom_steps);
+ self.apply_settings(reloaded);
+ }
+
+ /// Draw the corner control (emphasized while hovered or open) and, over
+ /// everything, the open menu. Called last in `display_list`.
+ pub(crate) fn paint_plate_menu(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
+ if let Some(c) = self.plate_corner_center() {
+ let emphasized = self.plate_menu_open()
+ || plate_dock::corner_hit(c, self.last_pointer.x as f32, self.last_pointer.y as f32);
+ plate_dock::draw_corner_dot(pc, c, emphasized);
+ }
+ if !self.plate_menu_open() {
+ return;
+ }
+ // The shared menu paints as the DE's lit plate; its labels carry
+ // bounds equal to the menu rect, which the engine's text-occlusion
+ // clamp exempts, so they render inside the menu while the grid's
+ // text beneath stays clamped.
+ context_menu::paint(pc);
+ let (mx, my) = (context_menu::x(), context_menu::y());
+ let bounds = Some([mx, my, mx + context_menu::w(), my + context_menu::h()]);
+ for l in context_menu::text_labels() {
+ pc.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bounds);
+ }
+ }
+}