use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::{Pos, Rect};
use super::StatefulWidget;
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::interact::Sense;
use crate::state::MenuState;
use crate::text::draw_clipped;
use crate::theme::Theme;
use crate::ui::Ui;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Marker {
None,
Checkbox(bool),
Choice(bool),
}
impl Marker {
const fn text(self) -> &'static str {
match self {
Self::None => "",
Self::Checkbox(true) => "[x] ",
Self::Checkbox(false) => "[ ] ",
Self::Choice(true) => "(o) ",
Self::Choice(false) => "( ) ",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuRow<'a> {
Item {
label: &'a str,
marker: Marker,
enabled: bool,
},
Separator,
Label(&'a str),
}
impl MenuRow<'_> {
#[must_use]
pub const fn is_selectable(&self) -> bool {
matches!(self, Self::Item { enabled: true, .. })
}
}
struct Layout {
show_top: bool,
show_bottom: bool,
content_capacity: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct Menu<'a> {
rows: &'a [MenuRow<'a>],
item_style: Style,
selected_style: Style,
disabled_style: Style,
separator_style: Style,
label_style: Style,
overflow_style: Style,
}
impl<'a> Menu<'a> {
#[must_use]
pub fn new(rows: &'a [MenuRow<'a>]) -> Self {
Self {
rows,
item_style: Style::new(),
selected_style: Style::new(),
disabled_style: Style::new(),
separator_style: Style::new(),
label_style: Style::new(),
overflow_style: Style::new(),
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn item_style(mut self, style: Style) -> Self {
self.item_style = style;
self
}
#[must_use]
pub const fn selected_style(mut self, style: Style) -> Self {
self.selected_style = style;
self
}
#[must_use]
pub const fn disabled_style(mut self, style: Style) -> Self {
self.disabled_style = style;
self
}
#[must_use]
pub const fn separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
#[must_use]
pub const fn label_style(mut self, style: Style) -> Self {
self.label_style = style;
self
}
#[must_use]
pub const fn overflow_style(mut self, style: Style) -> Self {
self.overflow_style = style;
self
}
#[must_use]
pub fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.item_style = Style::new().fg(theme.fg).bg(bg);
self.label_style = Style::new().fg(theme.fg).bg(bg);
self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
self.disabled_style = Style::new().fg(theme.dim).bg(bg);
self.separator_style = Style::new().fg(theme.border).bg(bg);
self.overflow_style = Style::new().fg(theme.border).bg(bg);
self
}
}
impl Menu<'_> {
fn layout(&self, state: &MenuState, capacity: usize) -> Layout {
if capacity == 0 {
return Layout {
show_top: false,
show_bottom: false,
content_capacity: 0,
};
}
let show_top = state.offset() > 0;
let mut content_capacity = capacity - usize::from(show_top);
let show_bottom = self.rows.len().saturating_sub(state.offset()) > content_capacity;
if show_bottom {
content_capacity = content_capacity.saturating_sub(1);
}
Layout {
show_top,
show_bottom,
content_capacity,
}
}
fn draw(&self, surface: &mut Surface<'_>, state: &MenuState) {
let (width, height) = (surface.width(), surface.height());
if width == 0 || height == 0 || self.rows.is_empty() {
return;
}
let layout = self.layout(state, usize::from(height));
let mut y: u16 = 0;
if layout.show_top {
let _ = draw_clipped(
surface,
(0, y),
width,
"^",
Align::Left,
self.overflow_style,
);
y += 1;
}
for (index, row) in self
.rows
.iter()
.enumerate()
.skip(state.offset())
.take(layout.content_capacity)
{
let is_selected = Some(index) == state.selected();
let style = match row {
MenuRow::Separator => self.separator_style,
MenuRow::Label(_) => self.label_style,
MenuRow::Item { enabled: false, .. } => self.disabled_style,
MenuRow::Item { .. } if is_selected => self.selected_style,
MenuRow::Item { .. } => self.item_style,
};
if is_selected {
fill_rect(
surface,
Rect::new(0, y, width, 1),
' ',
Style::new().bg(self.selected_style.background()),
);
}
match row {
MenuRow::Separator => {
fill_rect(surface, Rect::new(0, y, width, 1), '\u{2500}', style);
}
MenuRow::Label(text) => {
let _ = draw_clipped(surface, (0, y), width, text, Align::Left, style);
}
MenuRow::Item { label, marker, .. } => {
let marker_text = marker.text();
let marker_width = retroglyph_core::text::width(marker_text).min(width);
if marker_width > 0 {
let _ = draw_clipped(
surface,
(0, y),
marker_width,
marker_text,
Align::Left,
style,
);
}
let _ = draw_clipped(
surface,
(marker_width, y),
width - marker_width,
label,
Align::Left,
style,
);
}
}
y += 1;
}
if layout.show_bottom {
let _ = draw_clipped(
surface,
(0, y),
width,
"v",
Align::Left,
self.overflow_style,
);
}
}
fn index_at(&self, area: Rect, state: &MenuState, pos: Pos) -> Option<usize> {
let capacity = usize::from(area.height());
let layout = self.layout(state, capacity);
let row = usize::from(pos.y.checked_sub(area.top())?);
let content_row = row.checked_sub(usize::from(layout.show_top))?;
if content_row >= layout.content_capacity {
return None; }
let index = state.offset() + content_row;
(index < self.rows.len()).then_some(index)
}
}
impl StatefulWidget for Menu<'_> {
type State = MenuState;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
self.draw(surface, state);
}
}
impl Menu<'_> {
#[must_use]
pub fn show<Id: Copy + PartialEq>(
&self,
ui: &mut Ui<'_, '_, Id>,
screen: Rect,
popup_area: Rect,
content_id: Id,
backdrop_id: Id,
state: &mut MenuState,
) -> Option<usize> {
if !state.is_open() {
return None;
}
let backdrop = ui.region(screen, backdrop_id, Sense::click()).0;
let mut activated = None;
ui.modal(popup_area, |inner| {
let (response, mut surface) = inner.region(
popup_area,
content_id,
Sense::click() | Sense::HOVER | Sense::SCROLL,
);
let scroll_delta = response.scroll_delta();
if scroll_delta != 0 {
state.scroll_by(scroll_delta);
}
if response.clicked()
&& let Some(pos) = response.pointer_pos()
&& let Some(index) = self.index_at(popup_area, state, pos)
&& self.rows[index].is_selectable()
{
state.select(Some(index));
activated = Some(index);
}
state.ensure_visible(usize::from(popup_area.height()));
self.draw(&mut surface, state);
});
if activated.is_some() || backdrop.clicked() {
state.close();
}
activated
}
}
#[cfg(test)]
mod tests {
use retroglyph_core::grid::{Grid, Pos};
use super::*;
use crate::interact::Interaction;
const ROWS: [MenuRow<'static>; 5] = [
MenuRow::Separator,
MenuRow::Item {
label: "Cut",
marker: Marker::None,
enabled: false,
},
MenuRow::Item {
label: "Copy",
marker: Marker::None,
enabled: true,
},
MenuRow::Item {
label: "Word Wrap",
marker: Marker::Checkbox(true),
enabled: true,
},
MenuRow::Label("More"),
];
fn selectable(i: usize) -> bool {
ROWS[i].is_selectable()
}
#[test]
fn draws_a_row_per_line() {
let area = Rect::new(0, 0, 20, 5);
let mut grid = Grid::new(20, 5);
let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
StatefulWidget::render(
&Menu::new(&ROWS),
&mut Surface::new(&mut grid, area, 0),
&mut state,
);
assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C'); assert_eq!(grid[Pos::new(0, 2)].glyph(), 'C'); assert_eq!(grid[Pos::new(0, 3)].glyph(), '['); }
#[test]
fn selected_row_is_highlighted() {
let area = Rect::new(0, 0, 20, 5);
let mut grid = Grid::new(20, 5);
let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
StatefulWidget::render(
&Menu::new(&ROWS),
&mut Surface::new(&mut grid, area, 0),
&mut state,
);
let selected_bg = grid[Pos::new(0, 2)].style().background();
let plain_bg = grid[Pos::new(0, 3)].style().background();
assert_ne!(selected_bg, plain_bg);
}
#[test]
fn overflow_draws_indicators_instead_of_silent_truncation() {
let area = Rect::new(0, 0, 20, 3); let mut grid = Grid::new(20, 3);
let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
state.set_offset(1);
StatefulWidget::render(
&Menu::new(&ROWS),
&mut Surface::new(&mut grid, area, 0),
&mut state,
);
assert_eq!(grid[Pos::new(0, 0)].glyph(), '^');
assert_eq!(grid[Pos::new(0, 2)].glyph(), 'v');
}
#[test]
fn index_at_skips_the_overflow_indicator_rows() {
let area = Rect::new(0, 0, 20, 3);
let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
state.set_offset(1);
let menu = Menu::new(&ROWS);
assert_eq!(menu.index_at(area, &state, Pos::new(0, 0)), None); assert_eq!(menu.index_at(area, &state, Pos::new(0, 1)), Some(1)); assert_eq!(menu.index_at(area, &state, Pos::new(0, 2)), None); }
#[derive(Clone, Copy, PartialEq, Eq)]
enum Id {
Content,
Backdrop,
}
#[test]
fn show_returns_none_and_draws_nothing_while_closed() {
let screen = Rect::new(0, 0, 20, 10);
let popup = Rect::new(2, 2, 10, 5);
let mut grid = Grid::new(20, 10);
let mut state = MenuState::new();
let mut interaction = Interaction::<Id>::new();
let activated = interaction.frame(&mut Surface::new(&mut grid, screen, 0), |ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
assert_eq!(activated, None);
assert_eq!(grid[Pos::new(2, 2)].glyph(), ' ');
}
#[test]
#[cfg(feature = "testing")]
fn clicking_a_selectable_row_activates_and_closes() {
use crate::testing::WidgetHarness;
let screen = Rect::new(0, 0, 20, 10);
let popup = Rect::new(0, 0, 20, 5); let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
let mut harness = WidgetHarness::<Id>::new(20, 10);
harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
harness.click(1, 2); let _ = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
let activated = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
assert_eq!(activated, Some(2));
assert!(!state.is_open());
}
#[test]
#[cfg(feature = "testing")]
fn clicking_outside_the_popup_closes_without_activating() {
use crate::testing::WidgetHarness;
let screen = Rect::new(0, 0, 20, 10);
let popup = Rect::new(0, 0, 10, 5);
let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
let mut harness = WidgetHarness::<Id>::new(20, 10);
harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
harness.click(15, 8);
let _ = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
let activated = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
assert_eq!(activated, None);
assert!(!state.is_open());
}
#[test]
#[cfg(feature = "testing")]
fn clicking_inside_the_popup_but_off_every_row_stays_open() {
use crate::testing::WidgetHarness;
let screen = Rect::new(0, 0, 20, 10);
let popup = Rect::new(0, 0, 20, 8); let mut state = MenuState::new();
state.open(ROWS.len(), selectable);
let mut harness = WidgetHarness::<Id>::new(20, 10);
harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
harness.click(1, 7); let _ = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
let activated = harness.step(|ui| {
Menu::new(&ROWS).show(ui, screen, popup, Id::Content, Id::Backdrop, &mut state)
});
assert_eq!(activated, None);
assert!(state.is_open()); }
}