use std::borrow::Cow;
use std::path::PathBuf;
use cellix_doc::table::Edit as TableEdit;
use cellix_input::Command;
use cellix_render::{Bounds, Font, Grid, Measure, Rect, Style};
use crate::palette::{keys_of, raised};
use crate::theme::Theme;
use crate::toolbar::Tool;
const INSET: f32 = 1.0;
const GAP: f32 = 3.0;
const KEYS_SIZE: u8 = 85;
const MIN_WIDTH: u16 = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Run(Command),
ServerFix(usize),
NewFile(PathBuf),
NewFolder(PathBuf),
Rename(PathBuf),
Delete(PathBuf),
CopyPath(PathBuf),
CopyRelativePath(PathBuf),
Reveal(PathBuf),
CloseTab(usize),
CloseOtherTabs(usize),
CloseAllTabs,
Tool(Tool),
Table(TableEdit),
ImageWidth(u32),
ReplaceImage,
RemoveImage,
ChooseImage,
PasteImage,
TypeImagePath,
Diagram(usize),
PaperDiagram(usize),
Fix(usize, usize, String),
Learn(String),
RejectLine(cellix_review::LineUndo),
AddCodeCell,
InPane(String, Vec<String>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
pub label: Cow<'static, str>,
pub keys: Option<&'static str>,
pub action: Option<Action>,
pub rule: bool,
}
impl Item {
pub fn new(label: impl Into<Cow<'static, str>>, action: Action) -> Self {
let keys = match action {
Action::Run(command) => keys_of(command),
_ => None,
};
Self {
label: label.into(),
keys,
action: Some(action),
rule: false,
}
}
pub fn run(label: &'static str, command: Command) -> Self {
Self::new(label, Action::Run(command))
}
pub fn after_rule(self) -> Self {
Self { rule: true, ..self }
}
pub fn when(self, available: bool) -> Self {
Self {
action: self.action.filter(|_| available),
..self
}
}
}
pub struct Menu {
area: Rect,
items: Vec<Item>,
spaced: bool,
pub selected: Option<usize>,
}
impl Menu {
pub fn new((x, y): (u16, u16), items: Vec<Item>, measure: &dyn Measure, screen: Rect) -> Self {
let width = Self::width(&items, measure).min(screen.w);
let room = screen.h.saturating_sub(2);
let rules = items.iter().filter(|i| i.rule).count() as u16;
let spaced = measure.in_cells() && items.len() as u16 + rules <= room;
let height = (items.len() as u16 + if spaced { rules } else { 0 }).min(room);
let x = x.min(screen.right().saturating_sub(width));
let bottom = screen.bottom().saturating_sub(1);
let y = if y + height > bottom {
y.saturating_sub(height)
.max(1)
.min(bottom.saturating_sub(height))
} else {
y.max(1)
};
Self {
area: Rect::new(x, y, width, height),
items,
spaced,
selected: None,
}
}
fn width(items: &[Item], measure: &dyn Measure) -> u16 {
let plain = Font::default();
let small = Font {
size: KEYS_SIZE,
..Font::default()
};
let row = |item: &Item| {
let keys = item.keys.map_or(0.0, |k| measure.width(k, small) + GAP);
measure.width(&item.label, plain) + keys
};
let widest = items.iter().map(row).fold(0.0, f32::max);
((widest + INSET * 2.0).ceil() as u16 + 1).max(MIN_WIDTH)
}
fn row_of(&self, index: usize) -> u16 {
let rules = self.items[..=index].iter().filter(|i| i.rule).count();
let blank = if self.spaced { rules } else { 0 };
self.area.y + (index + blank) as u16
}
pub fn row_at(&self, x: u16, y: u16) -> Option<usize> {
if !self.area.contains(x, y) {
return None;
}
(0..self.items.len()).find(|i| self.row_of(*i) == y)
}
pub fn action(&self, row: usize) -> Option<&Action> {
self.items.get(row)?.action.as_ref()
}
pub fn step(&mut self, delta: isize) {
let count = self.items.len() as isize;
let usable = self.items.iter().filter(|i| i.action.is_some()).count();
if usable == 0 {
return;
}
let start = match self.selected {
Some(row) => row as isize,
None if delta > 0 => -1,
None => count,
};
let mut row = start;
loop {
row = (row + delta).rem_euclid(count);
if self.items[row as usize].action.is_some() {
self.selected = Some(row as usize);
return;
}
}
}
pub fn draw(&self, grid: &mut Grid, hover: Option<(u16, u16)>, theme: &Theme) {
let area = self.area;
raised(grid, area, theme);
let base = Style {
fg: theme.text.fg,
bg: theme.popup,
..theme.bar
};
let keys_style = Style {
fg: theme.popup_dim,
..base
};
let greyed = Style {
fg: theme.disabled,
..base
};
for (row, item) in self.items.iter().enumerate() {
let y = self.row_of(row);
if y >= area.bottom() {
break;
}
let line = Rect::new(area.x, y, area.w, 1);
if item.rule {
let across = area.w as f32 - INSET * 2.0;
let above = if self.spaced { 0.5 } else { 0.0 };
let at = y as f32 - above;
let rule = Bounds::cells(area.x as f32 + INSET, at, across, 0.0);
grid.hairline(rule, theme.popup_border);
}
let usable = item.action.is_some();
let hovered = hover.is_some_and(|h| line.contains(h.0, h.1));
if usable && (self.selected == Some(row) || hovered) {
let fill = if self.selected == Some(row) {
theme.pick
} else {
theme.popup_hover
};
grid.panel(Bounds::inset(line, 0.35, 0.055), 18, Some(fill), None);
}
let style = if usable { base } else { greyed };
let x = area.x as f32 + INSET;
grid.label_at(x, area.x + 1, y, &item.label, style, area.right(), 100);
if let Some(keys) = item.keys {
let right = area.right().saturating_sub(1);
let shown = if usable { keys_style } else { greyed };
grid.label_right(right, y, keys, shown, KEYS_SIZE);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cellix_render::CellMeasure;
const SCREEN: Rect = Rect {
x: 0,
y: 0,
w: 80,
h: 24,
};
fn items() -> Vec<Item> {
vec![
Item::run("Cut", Command::Cut),
Item::run("Copy", Command::Copy).when(false),
Item::run("Paste", Command::Paste).after_rule(),
]
}
#[test]
fn it_stays_on_screen() {
let menu = Menu::new((79, 23), items(), &CellMeasure, SCREEN);
assert!(menu.area.right() <= SCREEN.right());
assert!(
menu.area.bottom() < SCREEN.bottom(),
"clear of the status bar"
);
assert_eq!(menu.area.bottom(), 23, "and it opens up from the pointer");
}
#[test]
fn the_keyboard_skips_what_cant_be_done() {
let mut menu = Menu::new((1, 1), items(), &CellMeasure, SCREEN);
menu.step(1);
assert_eq!(menu.selected, Some(0));
menu.step(1);
assert_eq!(menu.selected, Some(2), "Copy is greyed out");
menu.step(1);
assert_eq!(menu.selected, Some(0), "and it wraps");
menu.step(-1);
assert_eq!(menu.selected, Some(2));
}
#[test]
fn a_short_window_gives_up_the_blank_rows_first() {
let short = Rect::new(0, 0, 80, 5);
let menu = Menu::new((1, 1), items(), &CellMeasure, short);
assert_eq!(menu.area.h, 3);
assert_eq!(menu.row_at(2, menu.area.y + 2), Some(2));
}
#[test]
fn with_paint_groups_part_on_a_hairline_not_a_row() {
let measure = cellix_render::FontMeasure::new(17, 37);
let menu = Menu::new((1, 1), items(), &measure, SCREEN);
assert_eq!(menu.area.h, items().len() as u16, "no blank rows");
}
#[test]
fn a_greyed_row_does_nothing() {
let menu = Menu::new((1, 1), items(), &CellMeasure, SCREEN);
assert_eq!(menu.action(1), None);
assert_eq!(menu.action(0), Some(&Action::Run(Command::Cut)));
assert_eq!(menu.row_at(2, 2), Some(1));
assert_eq!(menu.row_at(2, 3), None, "the rule has this row to itself");
assert_eq!(menu.row_at(2, 4), Some(2));
assert_eq!(menu.row_at(2, 9), None);
}
}