use super::toolbar::button_style;
use super::{Action, Command, EditorState, Inner, Mode};
use crate::doc::ColumnAlign;
use crate::fonts;
use crate::i18n;
use iced::widget::text::Wrapping;
use iced::widget::{button, column, container, opaque, pin, row, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Point, Shadow, Size, Theme, Vector,
};
const WIDTH: f32 = 280.0;
const ITEM: f32 = 26.0;
const SEPARATOR: f32 = 9.0;
const PADDING: f32 = 4.0;
enum Entry {
Item {
label: String,
hint: Option<String>,
checked: bool,
command: Command,
},
Separator,
}
fn shortcut(key: char) -> Option<String> {
Some(if cfg!(target_os = "macos") {
format!("\u{2318}{key}")
} else {
format!("Ctrl+{key}")
})
}
fn entries(state: &EditorState) -> Vec<Entry> {
let tr = |id| i18n::text(state.language, id);
let item = |id, hint: Option<String>, command| Entry::Item {
label: tr(id),
hint,
checked: false,
command,
};
let (a, b) = state.sel.range();
let selected = a != b;
let format = state.formatting();
let features = state.features;
let mut entries = Vec::new();
if selected {
entries.push(item("menu-cut", shortcut('X'), Command::Cut));
entries.push(item("menu-copy", shortcut('C'), Command::Copy));
}
entries.push(item("menu-paste", shortcut('V'), Command::Paste));
entries.push(item("menu-select-all", shortcut('A'), Command::SelectAll));
if selected {
entries.push(Entry::Separator);
entries.push(item("menu-bold", shortcut('B'), Command::ToggleBold));
entries.push(item("menu-italic", shortcut('I'), Command::ToggleItalic));
if features.strikethrough {
entries.push(item("menu-strike", shortcut('D'), Command::ToggleStrike));
}
entries.push(item("menu-code", shortcut('E'), Command::ToggleCode));
entries.push(item("menu-link", shortcut('K'), Command::EditLink));
} else if format.table && features.tables && state.mode == Mode::Wysiwyg {
entries.push(Entry::Separator);
entries.push(item(
"menu-row-above",
None,
Command::InsertRow { below: false },
));
entries.push(item(
"menu-row-below",
None,
Command::InsertRow { below: true },
));
entries.push(item(
"menu-column-left",
None,
Command::InsertColumn { right: false },
));
entries.push(item(
"menu-column-right",
None,
Command::InsertColumn { right: true },
));
entries.push(Entry::Separator);
entries.push(item("menu-delete-row", None, Command::DeleteRow));
entries.push(item("menu-delete-column", None, Command::DeleteColumn));
entries.push(item("menu-delete-table", None, Command::DeleteTable));
entries.push(Entry::Separator);
let current = format.column_align.unwrap_or_default();
for (id, align) in [
("menu-align-left", ColumnAlign::Left),
("menu-align-center", ColumnAlign::Center),
("menu-align-right", ColumnAlign::Right),
] {
entries.push(Entry::Item {
label: tr(id),
hint: None,
checked: current == align,
command: Command::SetColumnAlign(align),
});
}
}
entries
}
fn place(at: Point, size: Size, area: Size) -> Point {
let x = if at.x + size.width <= area.width {
at.x
} else {
(area.width - size.width).max(0.0)
};
let y = if at.y + size.height <= area.height {
at.y
} else if at.y >= size.height {
at.y - size.height
} else {
(area.height - size.height).max(0.0)
};
Point::new(x, y)
}
pub(crate) fn view(state: &EditorState, at: Point) -> Element<'static, Action> {
let palette = state.style.palette;
let entries = entries(state);
let visual = state.mode == Mode::Wysiwyg;
let height = 2.0 * PADDING
+ entries
.iter()
.map(|entry| match entry {
Entry::Item { .. } => ITEM,
Entry::Separator => SEPARATOR,
})
.sum::<f32>();
let position = place(at, Size::new(WIDTH, height), state.viewport.get());
let mut list = column![].width(Length::Fill);
for entry in entries {
list = list.push(match entry {
Entry::Item {
label,
hint,
checked,
command,
} => {
let check = text(if checked { "\u{2713}" } else { "" })
.size(13)
.font(fonts::SANS)
.width(Length::Fixed(18.0));
let name = text(label)
.size(13)
.font(fonts::SANS)
.wrapping(Wrapping::None)
.width(Length::Fill);
let mut content = row![check, name]
.height(Length::Fill)
.align_y(Alignment::Center);
if let Some(hint) = hint {
content = content.push(
text(hint)
.size(12)
.font(fonts::SANS)
.color(palette.muted)
.wrapping(Wrapping::None),
);
}
Element::from(
button(content)
.on_press(match command {
Command::EditLink if visual => Action(Inner::LinkDialog),
command => Action::from(command),
})
.padding([0, 8])
.width(Length::Fill)
.height(Length::Fixed(ITEM))
.style(button_style(palette, false)),
)
}
Entry::Separator => container(
container(column![])
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(palette.toolbar_border)),
..container::Style::default()
}),
)
.padding([(SEPARATOR - 1.0) / 2.0, 6.0])
.into(),
});
}
let menu = container(list)
.padding(PADDING)
.width(Length::Fixed(WIDTH))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(palette.toolbar_background)),
text_color: Some(palette.toolbar_text),
border: Border {
color: palette.toolbar_border,
width: 1.0,
radius: 6.0.into(),
},
shadow: Shadow {
color: Color {
a: 0.25,
..Color::BLACK
},
offset: Vector::new(0.0, 3.0),
blur_radius: 10.0,
},
..container::Style::default()
});
pin(opaque(menu)).x(position.x).y(position.y).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn menu_stays_inside_the_page() {
let area = Size::new(800.0, 600.0);
let size = Size::new(280.0, 200.0);
assert_eq!(
place(Point::new(10.0, 20.0), size, area),
Point::new(10.0, 20.0)
);
assert_eq!(
place(Point::new(10.0, 500.0), size, area),
Point::new(10.0, 300.0)
);
assert_eq!(
place(Point::new(700.0, 20.0), size, area),
Point::new(520.0, 20.0)
);
let small = Size::new(800.0, 250.0);
assert_eq!(
place(Point::new(0.0, 100.0), size, small),
Point::new(0.0, 50.0)
);
}
fn commands(state: &EditorState) -> Vec<Command> {
entries(state)
.into_iter()
.filter_map(|entry| match entry {
Entry::Item { command, .. } => Some(command),
Entry::Separator => None,
})
.collect()
}
#[test]
fn selection_offers_formatting() {
let mut state = EditorState::with_markdown("hello world\n");
state.execute(Command::SelectAll);
let commands = commands(&state);
for command in [
Command::Cut,
Command::Copy,
Command::ToggleBold,
Command::ToggleItalic,
Command::ToggleStrike,
Command::ToggleCode,
Command::EditLink,
] {
assert!(commands.contains(&command), "{command:?}");
}
assert!(!commands.contains(&Command::DeleteRow));
}
#[test]
fn caret_in_a_table_offers_rows_and_columns() {
let state = EditorState::with_markdown("| a | b |\n| - | - |\n");
let commands = commands(&state);
for command in [
Command::InsertRow { below: false },
Command::InsertRow { below: true },
Command::InsertColumn { right: false },
Command::InsertColumn { right: true },
Command::DeleteRow,
Command::DeleteColumn,
Command::DeleteTable,
Command::SetColumnAlign(ColumnAlign::Center),
] {
assert!(commands.contains(&command), "{command:?}");
}
assert!(!commands.contains(&Command::Cut));
assert!(!commands.contains(&Command::ToggleBold));
}
#[test]
fn disabled_features_are_hidden() {
let mut state = EditorState::with_markdown("a\n").features(crate::Features {
strikethrough: false,
..crate::Features::default()
});
state.execute(Command::SelectAll);
assert!(!commands(&state).contains(&Command::ToggleStrike));
}
}