finally make autocomplete a real widget

This commit is contained in:
Dustin Carlino 2020-04-02 13:33:42 -07:00
parent 6f56bf64bf
commit 0b6418bdb6
10 changed files with 272 additions and 239 deletions

View File

@ -1,8 +1,7 @@
use crate::assets::Assets;
use crate::backend::{GfxCtxInnards, PrerenderInnards};
use crate::{
Canvas, Color, Drawable, FancyColor, GeomBatch, HorizontalAlignment, ScreenDims, ScreenPt,
ScreenRectangle, Text, VerticalAlignment,
Canvas, Color, Drawable, FancyColor, GeomBatch, ScreenDims, ScreenPt, ScreenRectangle, Text,
};
use geom::{Bounds, Circle, Distance, Line, Polygon, Pt2D};
use std::cell::Cell;
@ -177,25 +176,6 @@ impl<'a> GfxCtx<'a> {
// Canvas stuff.
// The text box covers up what's beneath and eats the cursor (for get_cursor_in_map_space).
// TODO Super close to deleting this.
pub(crate) fn draw_blocking_text(
&mut self,
txt: Text,
(horiz, vert): (HorizontalAlignment, VerticalAlignment),
) {
let batch = txt.render_g(self);
let dims = batch.get_dims();
let top_left = self
.canvas
.align_window(&self.prerender.assets, dims, horiz, vert);
self.canvas
.mark_covered_area(ScreenRectangle::top_left(top_left, dims));
let draw = self.upload(batch);
self.redraw_at(top_left, &draw);
}
pub fn draw_mouse_tooltip(&mut self, txt: Text) {
// Add some padding
let pad = 5.0;

View File

@ -165,15 +165,6 @@ impl UserInput {
}
}
// TODO I'm not sure this is even useful anymore
pub(crate) fn use_event_directly(&mut self) -> Option<Event> {
if self.event_consumed {
return None;
}
self.consume_event();
Some(self.event)
}
pub(crate) fn consume_event(&mut self) {
assert!(!self.event_consumed);
self.event_consumed = true;

View File

@ -68,7 +68,7 @@ pub use crate::widgets::spinner::Spinner;
pub(crate) use crate::widgets::text_box::TextBox;
pub use crate::widgets::WidgetImpl;
pub enum InputResult<T: Clone> {
pub(crate) enum InputResult<T: Clone> {
Canceled,
StillActive,
Done(String, T),

View File

@ -1,8 +1,8 @@
use crate::widgets::containers::{Container, Nothing};
use crate::{
Btn, Button, Checkbox, Choice, Color, Drawable, Dropdown, EventCtx, Filler, GeomBatch, GfxCtx,
HorizontalAlignment, JustDraw, Menu, MultiKey, RewriteColor, ScreenDims, ScreenPt,
ScreenRectangle, Slider, Spinner, TextBox, VerticalAlignment, WidgetImpl,
Autocomplete, Btn, Button, Checkbox, Choice, Color, Drawable, Dropdown, EventCtx, Filler,
GeomBatch, GfxCtx, HorizontalAlignment, JustDraw, Menu, MultiKey, RewriteColor, ScreenDims,
ScreenPt, ScreenRectangle, Slider, Spinner, TextBox, VerticalAlignment, WidgetImpl,
};
use geom::{Distance, Polygon};
use std::collections::HashSet;
@ -423,6 +423,10 @@ impl Widget {
pub(crate) fn take_btn(self) -> Button {
*self.widget.downcast::<Button>().ok().unwrap()
}
pub(crate) fn take_menu<T: 'static + Clone>(self) -> Menu<T> {
*self.widget.downcast::<Menu<T>>().ok().unwrap()
}
}
enum Dims {
@ -692,6 +696,10 @@ impl Composite {
self.find::<Dropdown<T>>(name).current_value()
}
pub fn autocomplete_done<T: 'static + Clone>(&self, name: &str) -> Option<Vec<T>> {
self.find::<Autocomplete<T>>(name).final_value()
}
pub fn filler_rect(&self, name: &str) -> ScreenRectangle {
if let Some(w) = self.top_level.find(name) {
if w.widget.is::<Filler>() {

View File

@ -120,6 +120,7 @@ impl Text {
txt
}
// TODO Remove this
pub fn with_bg(mut self) -> Text {
assert!(self.bg_color.is_none());
self.bg_color = Some(BG_COLOR);

View File

@ -1,140 +1,120 @@
use crate::{
text, Event, EventCtx, GfxCtx, HorizontalAlignment, InputResult, Key, Line, Text,
VerticalAlignment,
Choice, EventCtx, GfxCtx, InputResult, Menu, Outcome, ScreenDims, ScreenPt, TextBox, Widget,
WidgetImpl,
};
use simsearch::SimSearch;
use std::collections::{BTreeMap, HashSet};
use std::hash::Hash;
use std::collections::HashMap;
const NUM_SEARCH_RESULTS: usize = 5;
const NUM_SEARCH_RESULTS: usize = 10;
pub struct Autocomplete<T: Clone + Hash + Eq> {
prompt: String,
choices: BTreeMap<String, HashSet<T>>,
// TODO I don't even think we need to declare Clone...
pub struct Autocomplete<T: Clone> {
choices: HashMap<String, Vec<T>>,
// Maps index to choice
search_map: Vec<String>,
search: SimSearch<usize>,
line: String,
cursor_x: usize,
shift_pressed: bool,
current_results: Vec<usize>,
cursor_y: usize,
tb: TextBox,
menu: Menu<()>,
current_line: String,
chosen_values: Option<Vec<T>>,
}
impl<T: Clone + Hash + Eq> Autocomplete<T> {
pub fn new(prompt: &str, choices_list: Vec<(String, T)>) -> Autocomplete<T> {
let mut choices = BTreeMap::new();
for (name, data) in choices_list {
if !choices.contains_key(&name) {
choices.insert(name.clone(), HashSet::new());
}
choices.get_mut(&name).unwrap().insert(data);
}
let mut search_map = Vec::new();
let mut search = SimSearch::new();
let mut current_results = Vec::new();
for (idx, name) in choices.keys().enumerate() {
search_map.push(name.to_string());
search.insert(idx, name);
if idx < NUM_SEARCH_RESULTS {
current_results.push(idx);
}
impl<T: 'static + Clone> Autocomplete<T> {
// If multiple names map to the same data, all of the possible values will be returned
pub fn new(ctx: &mut EventCtx, raw_choices: Vec<(String, T)>) -> Widget {
let mut choices = HashMap::new();
for (name, data) in raw_choices {
choices.entry(name).or_insert_with(Vec::new).push(data);
}
Autocomplete {
prompt: prompt.to_string(),
let mut search_map = Vec::new();
let mut search = SimSearch::new();
for name in choices.keys() {
search.insert(search_map.len(), name);
search_map.push(name.to_string());
}
let mut a = Autocomplete {
choices,
search_map,
search,
line: String::new(),
cursor_x: 0,
shift_pressed: false,
current_results,
cursor_y: 0,
}
}
tb: TextBox::new(ctx, 50, String::new(), true),
menu: Menu::<()>::new(ctx, Vec::new()).take_menu(),
pub fn draw(&self, g: &mut GfxCtx) {
let mut txt = Text::from(Line(&self.prompt).small_heading()).with_bg();
txt.add(Line(&self.line[0..self.cursor_x]));
if self.cursor_x < self.line.len() {
// TODO This "cursor" looks awful!
txt.append_all(vec![
Line("|").fg(text::SELECTED_COLOR),
Line(&self.line[self.cursor_x..=self.cursor_x]),
Line(&self.line[self.cursor_x + 1..]),
]);
} else {
txt.append(Line("|").fg(text::SELECTED_COLOR));
}
for (idx, id) in self.current_results.iter().enumerate() {
if idx == self.cursor_y {
txt.add_highlighted(Line(&self.search_map[*id]), text::SELECTED_COLOR);
} else {
txt.add(Line(&self.search_map[*id]));
}
}
g.draw_blocking_text(
txt,
(HorizontalAlignment::Center, VerticalAlignment::Center),
);
}
pub fn event(&mut self, ctx: &mut EventCtx) -> InputResult<HashSet<T>> {
let maybe_ev = ctx.input.use_event_directly();
if maybe_ev.is_none() {
return InputResult::StillActive;
}
let ev = maybe_ev.unwrap();
if ev == Event::KeyPress(Key::Escape) {
return InputResult::Canceled;
} else if ev == Event::KeyPress(Key::Enter) {
if self.current_results.is_empty() {
return InputResult::Canceled;
}
let name = &self.search_map[self.current_results[self.cursor_y]];
return InputResult::Done(name.to_string(), self.choices.remove(name).unwrap());
} else if ev == Event::KeyPress(Key::LeftShift) {
self.shift_pressed = true;
} else if ev == Event::KeyRelease(Key::LeftShift) {
self.shift_pressed = false;
} else if ev == Event::KeyPress(Key::LeftArrow) {
if self.cursor_x > 0 {
self.cursor_x -= 1;
}
} else if ev == Event::KeyPress(Key::RightArrow) {
self.cursor_x = (self.cursor_x + 1).min(self.line.len());
} else if ev == Event::KeyPress(Key::UpArrow) {
if self.cursor_y > 0 {
self.cursor_y -= 1;
}
} else if ev == Event::KeyPress(Key::DownArrow) {
self.cursor_y = (self.cursor_y + 1).min(self.current_results.len() - 1);
} else if ev == Event::KeyPress(Key::Backspace) {
if self.cursor_x > 0 {
self.line.remove(self.cursor_x - 1);
self.cursor_x -= 1;
self.current_results = self.search.search(&self.line);
self.current_results.truncate(NUM_SEARCH_RESULTS);
self.cursor_y = 0;
}
} else if let Event::KeyPress(key) = ev {
if let Some(c) = key.to_char(self.shift_pressed) {
self.line.insert(self.cursor_x, c);
self.cursor_x += 1;
self.current_results = self.search.search(&self.line);
self.current_results.truncate(NUM_SEARCH_RESULTS);
self.cursor_y = 0;
}
current_line: String::new(),
chosen_values: None,
};
InputResult::StillActive
a.recalc_menu(ctx);
Widget::new(Box::new(a))
}
pub fn final_value(&self) -> Option<Vec<T>> {
self.chosen_values.clone()
}
fn recalc_menu(&mut self, ctx: &mut EventCtx) {
let mut indices = self.search.search(&self.current_line);
if indices.is_empty() {
indices = (0..NUM_SEARCH_RESULTS.min(self.search_map.len())).collect();
}
self.menu = Menu::new(
ctx,
indices
.into_iter()
.take(NUM_SEARCH_RESULTS)
.map(|idx| Choice::new(&self.search_map[idx], ()))
.collect(),
)
.take_menu();
}
}
impl<T: 'static + Clone> WidgetImpl for Autocomplete<T> {
fn get_dims(&self) -> ScreenDims {
let d1 = self.tb.get_dims();
let d2 = self.menu.get_dims();
ScreenDims::new(d1.width.max(d2.width), d1.height + d2.height)
}
fn set_pos(&mut self, top_left: ScreenPt) {
self.tb.set_pos(top_left);
self.menu.set_pos(ScreenPt::new(
top_left.x,
top_left.y + self.tb.get_dims().height,
));
}
fn event(&mut self, ctx: &mut EventCtx, redo_layout: &mut bool) -> Option<Outcome> {
assert!(self.chosen_values.is_none());
self.tb.event(ctx, redo_layout);
if self.tb.get_line() != self.current_line {
self.current_line = self.tb.get_line();
self.recalc_menu(ctx);
*redo_layout = true;
} else {
self.menu.event(ctx, redo_layout);
match self.menu.state {
InputResult::StillActive => {}
// Ignore this and make sure the Composite has a quit control
InputResult::Canceled => {}
InputResult::Done(ref name, _) => {
// Mutating choices is fine, because we're supposed to be consumed by the
// caller immediately after this.
self.chosen_values = Some(self.choices.remove(name).unwrap());
}
}
}
None
}
fn draw(&self, g: &mut GfxCtx) {
self.tb.draw(g);
self.menu.draw(g);
}
}

View File

@ -7,6 +7,7 @@ use geom::{Polygon, Pt2D};
pub struct Dropdown<T: Clone> {
current_idx: usize,
btn: Button,
// TODO Why not T?
menu: Option<Menu<usize>>,
label: String,
@ -70,7 +71,7 @@ impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
} else {
if self.btn.event(ctx, redo_layout).is_some() {
// TODO set current idx in menu
let mut menu = *Menu::new(
let mut menu = Menu::new(
ctx,
self.choices
.iter()
@ -78,10 +79,7 @@ impl<T: 'static + Clone> WidgetImpl for Dropdown<T> {
.map(|(idx, c)| c.with_value(idx))
.collect(),
)
.widget
.downcast::<Menu<usize>>()
.ok()
.unwrap();
.take_menu();
menu.set_pos(ScreenPt::new(
self.btn.top_left.x,
self.btn.top_left.y + self.btn.dims.height + 15.0,

View File

@ -80,6 +80,10 @@ impl<T: 'static + Clone> WidgetImpl for Menu<T> {
}
fn event(&mut self, ctx: &mut EventCtx, _redo_layout: &mut bool) -> Option<Outcome> {
if self.choices.is_empty() {
return None;
}
match self.state {
InputResult::StillActive => {}
_ => unreachable!(),
@ -167,6 +171,10 @@ impl<T: 'static + Clone> WidgetImpl for Menu<T> {
}
fn draw(&self, g: &mut GfxCtx) {
if self.choices.is_empty() {
return;
}
let draw = g.upload(self.calculate_txt().render_g(g));
// In between tooltip and normal screenspace
g.fork(Pt2D::new(0.0, 0.0), self.top_left, 1.0, Some(0.1));

View File

@ -94,7 +94,7 @@ impl Minimap {
self.set_zoom(ctx, app, 3);
}
x if x == "search" => {
return Some(Transition::Push(Box::new(navigate::Navigator::new(app))));
return Some(Transition::Push(navigate::Navigator::new(ctx, app)));
}
x if x == "shortcuts" => {
return Some(Transition::Push(shortcuts::ChoosingShortcut::new()));

View File

@ -1,117 +1,184 @@
use crate::app::App;
use crate::colors;
use crate::common::Warping;
use crate::game::{State, Transition};
use crate::helpers::ID;
use ezgui::{Autocomplete, EventCtx, GfxCtx, InputResult};
use ezgui::{
hotkey, Autocomplete, Btn, Composite, EventCtx, GfxCtx, Key, Line, Outcome, Text, Widget,
};
use map_model::RoadID;
use std::collections::HashSet;
// TODO Canonicalize names, handling abbreviations like east/e and street/st
pub struct Navigator {
autocomplete: Autocomplete<RoadID>,
composite: Composite,
}
impl Navigator {
pub fn new(app: &App) -> Navigator {
// TODO Canonicalize names, handling abbreviations like east/e and street/st
Navigator {
autocomplete: Autocomplete::new(
"Warp where?",
app.primary
.map
.all_roads()
.iter()
.map(|r| (r.get_name(), r.id))
.collect(),
),
}
pub fn new(ctx: &mut EventCtx, app: &App) -> Box<dyn State> {
Box::new(Navigator {
composite: Composite::new(
Widget::col(vec![
Widget::row(vec![
Line("Enter a street name").small_heading().draw(ctx),
Btn::text_fg("X")
.build_def(ctx, hotkey(Key::Escape))
.align_right(),
]),
Autocomplete::new(
ctx,
app.primary
.map
.all_roads()
.iter()
.map(|r| (r.get_name(), r.id))
.collect(),
)
.named("street"),
])
.bg(colors::PANEL_BG),
)
.build(ctx),
})
}
}
impl State for Navigator {
fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
let map = &app.primary.map;
match self.autocomplete.event(ctx) {
InputResult::Canceled => Transition::Pop,
InputResult::Done(name, ids) => {
// Roads share intersections, so of course there'll be overlap here.
let mut cross_streets = HashSet::new();
for r in &ids {
let road = map.get_r(*r);
for i in &[road.src_i, road.dst_i] {
for cross in &map.get_i(*i).roads {
if !ids.contains(cross) {
cross_streets.insert(*cross);
}
}
}
match self.composite.event(ctx) {
Some(Outcome::Clicked(x)) => match x.as_ref() {
"X" => {
return Transition::Pop;
}
Transition::Replace(Box::new(CrossStreet {
first: *ids.iter().next().unwrap(),
autocomplete: Autocomplete::new(
&format!("{} and what?", name),
cross_streets
.into_iter()
.map(|r| (map.get_r(r).get_name(), r))
.collect(),
),
}))
}
InputResult::StillActive => Transition::Keep,
_ => unreachable!(),
},
None => {}
}
if let Some(roads) = self.composite.autocomplete_done("street") {
return Transition::Replace(CrossStreet::new(ctx, app, roads));
}
if self.composite.clicked_outside(ctx) {
return Transition::Pop;
}
Transition::Keep
}
fn draw(&self, g: &mut GfxCtx, _: &App) {
self.autocomplete.draw(g);
State::grey_out_map(g);
self.composite.draw(g);
}
}
struct CrossStreet {
first: RoadID,
autocomplete: Autocomplete<RoadID>,
composite: Composite,
}
impl CrossStreet {
fn new(ctx: &mut EventCtx, app: &App, first: Vec<RoadID>) -> Box<dyn State> {
let map = &app.primary.map;
let mut cross_streets = HashSet::new();
for r in &first {
let road = map.get_r(*r);
for i in &[road.src_i, road.dst_i] {
for cross in &map.get_i(*i).roads {
cross_streets.insert(*cross);
}
}
}
// Roads share intersections, so of course there'll be overlap here.
for r in &first {
cross_streets.remove(r);
}
Box::new(CrossStreet {
first: first[0],
composite: Composite::new(
Widget::col(vec![
Widget::row(vec![
{
let mut txt = Text::from(Line("What cross street?").small_heading());
// TODO This isn't so clear...
txt.add(Line(format!(
"(Or just quit to go to {})",
map.get_r(first[0]).get_name(),
)));
txt.draw(ctx)
},
Btn::text_fg("X")
.build_def(ctx, hotkey(Key::Escape))
.align_right(),
]),
Autocomplete::new(
ctx,
cross_streets
.into_iter()
.map(|r| (map.get_r(r).get_name(), r))
.collect(),
)
.named("street"),
])
.bg(colors::PANEL_BG),
)
.build(ctx),
})
}
}
impl State for CrossStreet {
// When None, this is done.
fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
let map = &app.primary.map;
match self.autocomplete.event(ctx) {
InputResult::Canceled => {
// Just warp to somewhere on the first road
let road = map.get_r(self.first);
println!("Warping to {}", road.get_name());
Transition::Replace(Warping::new(
ctx,
road.center_pts.dist_along(road.center_pts.length() / 2.0).0,
None,
Some(ID::Lane(road.all_lanes()[0])),
&mut app.primary,
))
}
InputResult::Done(name, ids) => {
println!(
"Warping to {} and {}",
map.get_r(self.first).get_name(),
name
);
let road = map.get_r(*ids.iter().next().unwrap());
let pt = if map.get_i(road.src_i).roads.contains(&self.first) {
map.get_i(road.src_i).polygon.center()
} else {
map.get_i(road.dst_i).polygon.center()
};
Transition::Replace(Warping::new(
ctx,
pt,
None,
Some(ID::Lane(road.all_lanes()[0])),
&mut app.primary,
))
}
InputResult::StillActive => Transition::Keep,
match self.composite.event(ctx) {
Some(Outcome::Clicked(x)) => match x.as_ref() {
"X" => {
// Just warp to somewhere on the first road
let road = map.get_r(self.first);
println!("Warping to {}", road.get_name());
return Transition::Replace(Warping::new(
ctx,
road.center_pts.dist_along(road.center_pts.length() / 2.0).0,
None,
Some(ID::Lane(road.all_lanes()[0])),
&mut app.primary,
));
}
_ => unreachable!(),
},
None => {}
}
if let Some(roads) = self.composite.autocomplete_done("street") {
let road = map.get_r(roads[0]);
println!(
"Warping to {} and {}",
map.get_r(self.first).get_name(),
road.get_name()
);
let pt = if map.get_i(road.src_i).roads.contains(&self.first) {
map.get_i(road.src_i).polygon.center()
} else {
map.get_i(road.dst_i).polygon.center()
};
return Transition::Replace(Warping::new(
ctx,
pt,
None,
Some(ID::Lane(road.all_lanes()[0])),
&mut app.primary,
));
}
if self.composite.clicked_outside(ctx) {
return Transition::Pop;
}
Transition::Keep
}
fn draw(&self, g: &mut GfxCtx, _: &App) {
self.autocomplete.draw(g);
State::grey_out_map(g);
self.composite.draw(g);
}
}