render separators between groups of menu items. very messy

implementation, but many other ideas fell through, and this works.
This commit is contained in:
Dustin Carlino 2019-06-24 18:07:35 -07:00
parent f7b5cf9a9f
commit 8d1581241f
6 changed files with 246 additions and 179 deletions

View File

@ -39,10 +39,10 @@ impl ContextMenu {
} else {
ContextMenu::Displaying(Menu::new(
Text::new(),
actions
vec![actions
.into_iter()
.map(|(key, action)| (hotkey(key), action, key))
.collect(),
.collect()],
false,
false,
Position::SomeCornerAt(origin),

View File

@ -21,13 +21,4 @@ impl ScreenRectangle {
pub fn contains(&self, pt: ScreenPt) -> bool {
pt.x >= self.x1 && pt.x <= self.x2 && pt.y >= self.y1 && pt.y <= self.y2
}
pub fn translate(&self, dx: f64, dy: f64) -> ScreenRectangle {
ScreenRectangle {
x1: self.x1 + dx,
y1: self.y1 + dy,
x2: self.x2 + dx,
y2: self.y2 + dy,
}
}
}

View File

@ -9,7 +9,7 @@ use nom::{alt, char, do_parse, many1, named, separated_pair, take_till1, take_un
use textwrap;
const FG_COLOR: Color = Color::WHITE;
const BG_COLOR: Color = Color::grey(0.2);
pub const BG_COLOR: Color = Color::grey(0.2);
pub const PROMPT_COLOR: Color = Color::BLUE;
pub const SELECTED_COLOR: Color = Color::RED;
pub const HOTKEY_COLOR: Color = Color::GREEN;
@ -42,6 +42,7 @@ pub struct Text {
// The bg_color will cover the entire block, but some lines can have extra highlighting.
lines: Vec<(Option<Color>, Vec<TextSpan>)>,
bg_color: Option<Color>,
pub(crate) override_width: Option<f64>,
}
impl Text {
@ -49,6 +50,7 @@ impl Text {
Text {
lines: Vec::new(),
bg_color: Some(BG_COLOR),
override_width: None,
}
}
@ -62,6 +64,7 @@ impl Text {
Text {
lines: Vec::new(),
bg_color,
override_width: None,
}
}
@ -173,7 +176,10 @@ impl Text {
max_width = max_width.max(width);
height += canvas.line_height(max_size);
}
(f64::from(max_width), height)
(
self.override_width.unwrap_or_else(|| f64::from(max_width)),
height,
)
}
}

View File

@ -1,27 +1,33 @@
use crate::screen_geom::ScreenRectangle;
use crate::{
hotkey, lctrl, text, Canvas, Event, GfxCtx, InputResult, Key, MultiKey, ScreenPt, Text,
hotkey, lctrl, text, Canvas, Color, Event, GfxCtx, InputResult, Key, MultiKey, ScreenPt, Text,
};
use geom::{Distance, Polygon, Pt2D};
use std::collections::HashSet;
// Stores some associated data with each choice
pub struct Menu<T: Clone> {
prompt: Text,
// The bool is whether this choice is active or not
choices: Vec<(Option<MultiKey>, String, bool, T)>,
choices: Vec<Choice<T>>,
current_idx: Option<usize>,
mouse_in_bounds: bool,
keys_enabled: bool,
hideable: bool,
hidden: bool,
pos: Position,
geom: Geometry,
top_left: ScreenPt,
total_width: f64,
// dy1 values of the separator half-rows
separators: Vec<f64>,
}
struct Geometry {
row_height: f64,
top_left: ScreenPt,
first_choice_row: ScreenRectangle,
struct Choice<T: Clone> {
hotkey: Option<MultiKey>,
label: String,
active: bool,
data: T,
// How far is the top of this row below the prompt's bottom?
dy1: f64,
}
#[derive(Clone)]
@ -31,105 +37,66 @@ pub enum Position {
TopRightOfScreen,
}
impl Position {
fn geometry<T>(
&self,
canvas: &Canvas,
prompt: Text,
choices: &Vec<(Option<MultiKey>, String, bool, T)>,
) -> Geometry {
// This is actually a constant, effectively...
let row_height = canvas.line_height(text::FONT_SIZE);
let mut txt = prompt;
let (_, prompt_height) = canvas.text_dims(&txt);
for (hotkey, choice, _, _) in choices {
if let Some(key) = hotkey {
txt.add_line(format!("{} - {}", key.describe(), choice));
} else {
txt.add_line(choice.to_string());
}
}
let (total_width, total_height) = canvas.text_dims(&txt);
let top_left = match self {
Position::SomeCornerAt(pt) => {
// TODO Ideally also avoid covered canvas areas (modal menu)
if pt.x + total_width < canvas.window_width {
// pt.x is the left corner
if pt.y + total_height < canvas.window_height {
// pt.y is the top corner
*pt
} else {
// pt.y is the bottom corner
ScreenPt::new(pt.x, pt.y - total_height)
}
} else {
// pt.x is the right corner
if pt.y + total_height < canvas.window_height {
// pt.y is the top corner
ScreenPt::new(pt.x - total_width, pt.y)
} else {
// pt.y is the bottom corner
ScreenPt::new(pt.x - total_width, pt.y - total_height)
}
}
}
Position::ScreenCenter => {
let mut pt = canvas.center_to_screen_pt();
pt.x -= total_width / 2.0;
pt.y -= total_height / 2.0;
pt
}
Position::TopRightOfScreen => ScreenPt::new(canvas.window_width - total_width, 0.0),
};
Geometry {
row_height,
top_left,
first_choice_row: ScreenRectangle {
x1: top_left.x,
y1: top_left.y + prompt_height,
x2: top_left.x + total_width,
y2: top_left.y + prompt_height + row_height,
},
}
}
}
impl<T: Clone> Menu<T> {
pub fn new(
prompt: Text,
raw_choices: Vec<(Option<MultiKey>, String, T)>,
mut prompt: Text,
raw_choice_groups: Vec<Vec<(Option<MultiKey>, String, T)>>,
keys_enabled: bool,
hideable: bool,
pos: Position,
canvas: &Canvas,
) -> Menu<T> {
if raw_choices.is_empty() {
let row_height = row_height(canvas);
let mut used_keys = HashSet::new();
let mut used_labels = HashSet::new();
let mut choices = Vec::new();
let mut txt = prompt.clone();
let prompt_lines = prompt.num_lines();
let mut separator_offset = 0.0;
let mut separators = Vec::new();
for group in raw_choice_groups {
for (maybe_key, label, data) in group {
if let Some(key) = maybe_key {
if used_keys.contains(&key) {
panic!("Menu for {:?} uses {} twice", prompt, key.describe());
}
used_keys.insert(key);
}
if used_labels.contains(&label) {
panic!("Menu for {:?} has two entries for {}", prompt, label);
}
used_labels.insert(label.clone());
let dy1 = ((txt.num_lines() - prompt_lines) as f64) * row_height + separator_offset;
if let Some(key) = maybe_key {
txt.add_line(format!("{} - {}", key.describe(), label));
} else {
txt.add_line(label.clone());
}
choices.push(Choice {
hotkey: maybe_key,
label,
active: true,
data,
dy1,
});
}
separator_offset += row_height / 2.0;
separators.push(choices.last().unwrap().dy1 + row_height);
}
// The last one would be at the very bottom of the menu
separators.pop();
if choices.is_empty() {
panic!("Can't create a menu without choices for {:?}", prompt);
}
let mut used_keys = HashSet::new();
let mut used_actions = HashSet::new();
for (maybe_key, name, _) in &raw_choices {
if let Some(key) = maybe_key {
if used_keys.contains(&key) {
panic!("Menu for {:?} uses {} twice", prompt, key.describe());
}
used_keys.insert(key);
}
if used_actions.contains(name) {
panic!("Menu for {:?} has two entries for {}", prompt, name);
}
used_actions.insert(name.to_string());
}
// All choices start active.
let choices = raw_choices
.into_iter()
.map(|(key, choice, data)| (key, choice, true, data))
.collect();
let geom = pos.geometry(canvas, prompt.clone(), &choices);
let (total_width, total_height) = canvas.text_dims(&txt);
let top_left = pos.get_top_left(canvas, total_width, total_height);
prompt.override_width = Some(total_width);
Menu {
prompt,
@ -141,7 +108,9 @@ impl<T: Clone> Menu<T> {
pos,
hideable,
hidden: false,
geom,
top_left,
total_width,
separators,
}
}
@ -150,9 +119,9 @@ impl<T: Clone> Menu<T> {
// Handle the mouse
if ev == Event::LeftMouseButtonDown {
if let Some(i) = self.current_idx {
let (_, choice, active, data) = self.choices[i].clone();
if active && self.mouse_in_bounds {
return InputResult::Done(choice, data);
let choice = &self.choices[i];
if choice.active && self.mouse_in_bounds {
return InputResult::Done(choice.label.clone(), choice.data.clone());
} else {
return InputResult::StillActive;
}
@ -163,15 +132,22 @@ impl<T: Clone> Menu<T> {
return InputResult::Canceled;
} else if let Event::MouseMovedTo(pt) = ev {
if !canvas.is_dragging() {
for i in 0..self.choices.len() {
if self.choices[i].2
&& self
.geom
.first_choice_row
.translate(0.0, (i as f64) * self.geom.row_height)
.contains(pt)
let row_height = row_height(canvas);
for (idx, choice) in self.choices.iter().enumerate() {
let y1 = self.top_left.y
+ choice.dy1
+ (self.prompt.num_lines() as f64) * row_height;
if choice.active
&& (ScreenRectangle {
x1: self.top_left.x,
y1,
x2: self.top_left.x + self.total_width,
y2: y1 + row_height,
}
.contains(pt))
{
self.current_idx = Some(i);
self.current_idx = Some(idx);
self.mouse_in_bounds = true;
return InputResult::StillActive;
}
@ -188,9 +164,9 @@ impl<T: Clone> Menu<T> {
if self.keys_enabled {
let idx = self.current_idx.unwrap();
if ev == Event::KeyPress(Key::Enter) {
let (_, name, active, data) = self.choices[idx].clone();
if active {
return InputResult::Done(name, data);
let choice = &self.choices[idx];
if choice.active {
return InputResult::Done(choice.label.clone(), choice.data.clone());
} else {
return InputResult::StillActive;
}
@ -225,9 +201,9 @@ impl<T: Clone> Menu<T> {
} else {
hotkey(key)
};
for (maybe_key, choice, active, data) in &self.choices {
if *active && pressed == *maybe_key {
return InputResult::Done(choice.to_string(), data.clone());
for choice in &self.choices {
if choice.active && pressed == choice.hotkey {
return InputResult::Done(choice.label.clone(), choice.data.clone());
}
}
}
@ -245,71 +221,116 @@ impl<T: Clone> Menu<T> {
}
pub fn draw(&self, g: &mut GfxCtx) {
let mut txt = self.prompt.clone();
if !self.hidden {
for (idx, (hotkey, choice, active, _)) in self.choices.iter().enumerate() {
let bg = if Some(idx) == self.current_idx {
Some(text::SELECTED_COLOR)
g.draw_text_at_screenspace_topleft(&self.prompt, self.top_left);
if self.hidden {
return;
}
let row_height = row_height(g.canvas);
let base_y = self.top_left.y + (self.prompt.num_lines() as f64) * row_height;
let choices_total_height = self.choices.last().unwrap().dy1 + row_height;
g.fork_screenspace();
g.draw_polygon(
text::BG_COLOR,
&Polygon::rectangle_topleft(
Pt2D::new(self.top_left.x, base_y),
Distance::meters(self.total_width),
Distance::meters(choices_total_height),
),
);
g.canvas.mark_covered_area(ScreenRectangle {
x1: self.top_left.x,
y1: base_y,
x2: self.top_left.x + self.total_width,
y2: base_y + choices_total_height,
});
for dy1 in &self.separators {
g.draw_polygon(
Color::grey(0.4),
&Polygon::rectangle_topleft(
Pt2D::new(self.top_left.x, base_y + *dy1 + (row_height / 4.0)),
Distance::meters(self.total_width),
Distance::meters(row_height / 4.0),
),
);
}
g.unfork();
for (idx, choice) in self.choices.iter().enumerate() {
let mut txt = Text::with_bg_color(if Some(idx) == self.current_idx {
Some(text::SELECTED_COLOR)
} else {
None
});
txt.override_width = Some(self.total_width);
if choice.active {
if let Some(key) = choice.hotkey {
txt.add_styled_line(key.describe(), Some(text::HOTKEY_COLOR), None, None);
txt.append(format!(" - {}", choice.label), None);
} else {
None
};
if *active {
if let Some(key) = hotkey {
txt.add_styled_line(key.describe(), Some(text::HOTKEY_COLOR), bg, None);
txt.append(format!(" - {}", choice), None);
} else {
txt.add_styled_line(choice.to_string(), None, bg, None);
}
txt.add_line(choice.label.clone());
}
} else {
if let Some(key) = choice.hotkey {
txt.add_styled_line(
format!("{} - {}", key.describe(), choice.label),
Some(text::INACTIVE_CHOICE_COLOR),
None,
None,
);
} else {
if let Some(key) = hotkey {
txt.add_styled_line(
format!("{} - {}", key.describe(), choice),
Some(text::INACTIVE_CHOICE_COLOR),
bg,
None,
);
} else {
txt.add_styled_line(
choice.to_string(),
Some(text::INACTIVE_CHOICE_COLOR),
bg,
None,
);
}
txt.add_styled_line(
choice.label.clone(),
Some(text::INACTIVE_CHOICE_COLOR),
None,
None,
);
}
}
// Is drawing each row individually slower?
g.draw_text_at_screenspace_topleft(
&txt,
ScreenPt::new(self.top_left.x, base_y + choice.dy1),
);
}
g.draw_text_at_screenspace_topleft(&txt, self.geom.top_left);
}
pub fn current_choice(&self) -> Option<&T> {
let idx = self.current_idx?;
Some(&self.choices[idx].3)
Some(&self.choices[idx].data)
}
pub fn active_choices(&self) -> Vec<&T> {
self.choices
.iter()
.filter_map(|(_, _, active, data)| if *active { Some(data) } else { None })
.filter_map(|choice| {
if choice.active {
Some(&choice.data)
} else {
None
}
})
.collect()
}
// If there's no matching choice, be silent. The two callers don't care.
pub fn mark_active(&mut self, choice: &str) {
for (_, action, ref mut active, _) in self.choices.iter_mut() {
if choice == action {
if *active {
panic!("Menu choice for {} was already active", choice);
pub fn mark_active(&mut self, label: &str) {
for choice in self.choices.iter_mut() {
if choice.label == label {
if choice.active {
panic!("Menu choice for {} was already active", choice.label);
}
*active = true;
choice.active = true;
return;
}
}
}
pub fn mark_all_inactive(&mut self) {
for (_, _, ref mut active, _) in self.choices.iter_mut() {
*active = false;
for choice in self.choices.iter_mut() {
choice.active = false;
}
}
@ -326,14 +347,59 @@ impl<T: Clone> Menu<T> {
}
fn recalculate_geom(&mut self, canvas: &Canvas) {
if self.hidden {
self.geom = self
.pos
.geometry::<()>(canvas, self.prompt.clone(), &Vec::new());
} else {
self.geom = self
.pos
.geometry(canvas, self.prompt.clone(), &self.choices);
let mut txt = self.prompt.clone();
if !self.hidden {
for choice in &self.choices {
if let Some(key) = choice.hotkey {
txt.add_line(format!("{} - {}", key.describe(), choice.label));
} else {
txt.add_line(choice.label.clone());
}
}
}
let (total_width, total_height) = canvas.text_dims(&txt);
self.top_left = self.pos.get_top_left(canvas, total_width, total_height);
self.total_width = total_width;
self.prompt.override_width = Some(total_width);
}
}
impl Position {
fn get_top_left(&self, canvas: &Canvas, total_width: f64, total_height: f64) -> ScreenPt {
match self {
Position::SomeCornerAt(pt) => {
// TODO Ideally also avoid covered canvas areas (modal menu)
if pt.x + total_width < canvas.window_width {
// pt.x is the left corner
if pt.y + total_height < canvas.window_height {
// pt.y is the top corner
*pt
} else {
// pt.y is the bottom corner
ScreenPt::new(pt.x, pt.y - total_height)
}
} else {
// pt.x is the right corner
if pt.y + total_height < canvas.window_height {
// pt.y is the top corner
ScreenPt::new(pt.x - total_width, pt.y)
} else {
// pt.y is the bottom corner
ScreenPt::new(pt.x - total_width, pt.y - total_height)
}
}
}
Position::ScreenCenter => {
let mut pt = canvas.center_to_screen_pt();
pt.x -= total_width / 2.0;
pt.y -= total_height / 2.0;
pt
}
Position::TopRightOfScreen => ScreenPt::new(canvas.window_width - total_width, 0.0),
}
}
}
fn row_height(canvas: &Canvas) -> f64 {
canvas.line_height(text::FONT_SIZE)
}

View File

@ -16,8 +16,12 @@ impl ModalMenu {
Text::prompt(prompt_line),
choice_groups
.into_iter()
.flatten()
.map(|(multikey, action)| (multikey, action.to_string(), ()))
.map(|group| {
group
.into_iter()
.map(|(multikey, action)| (multikey, action.to_string(), ()))
.collect()
})
.collect(),
false,
true,

View File

@ -218,7 +218,7 @@ impl<'a> WrappedWizard<'a> {
.collect();
self.wizard.menu = Some(Menu::new(
Text::prompt(query),
boxed_choices,
vec![boxed_choices],
true,
false,
Position::ScreenCenter,