map_gui/tools/
ui.rs

1//! Generic UI tools. Some of this should perhaps be lifted to widgetry.
2
3use std::cmp::Ordering;
4use std::collections::BTreeSet;
5
6use anyhow::Result;
7
8use abstutil::prettyprint_usize;
9use geom::{Distance, Duration, Polygon};
10use synthpop::TripMode;
11use widgetry::tools::{FutureLoader, PopupMsg};
12use widgetry::{Color, EventCtx, GeomBatch, Line, State, Text, Toggle, Transition, Widget};
13
14use crate::AppLike;
15
16pub struct FilePicker;
17type PickerOutput = (String, Vec<u8>);
18
19impl FilePicker {
20    // The callback gets the filename and file contents as bytes
21    pub fn new_state<A: 'static + AppLike>(
22        ctx: &mut EventCtx,
23        start_dir: Option<String>,
24        on_load: Box<
25            dyn FnOnce(&mut EventCtx, &mut A, Result<Option<PickerOutput>>) -> Transition<A>,
26        >,
27    ) -> Box<dyn State<A>> {
28        let (_, outer_progress_rx) = futures_channel::mpsc::channel(1);
29        let (_, inner_progress_rx) = futures_channel::mpsc::channel(1);
30        FutureLoader::<A, Option<PickerOutput>>::new_state(
31            ctx,
32            Box::pin(async move {
33                let mut builder = rfd::AsyncFileDialog::new();
34                if let Some(dir) = start_dir {
35                    builder = builder.set_directory(&dir);
36                }
37                // Can't get map() or and_then() to work with async
38                let result = if let Some(handle) = builder.pick_file().await {
39                    Some((handle.file_name(), handle.read().await))
40                } else {
41                    None
42                };
43                let wrap: Box<dyn Send + FnOnce(&A) -> Option<PickerOutput>> =
44                    Box::new(move |_: &A| result);
45                Ok(wrap)
46            }),
47            outer_progress_rx,
48            inner_progress_rx,
49            "Waiting for a file to be chosen",
50            on_load,
51        )
52    }
53}
54
55pub struct FileSaver;
56
57// TODO Lift to abstio, or just do AsRef<[u8]>
58pub enum FileSaverContents {
59    String(String),
60    Bytes(Vec<u8>),
61}
62
63impl FileSaver {
64    // The callback gets the filename
65    pub fn new_state<A: 'static + AppLike>(
66        ctx: &mut EventCtx,
67        filename: String,
68        start_dir: Option<String>,
69        write: FileSaverContents,
70        // TODO The double wrapped Result is silly, can't figure this out
71        on_load: Box<dyn FnOnce(&mut EventCtx, &mut A, Result<Result<String>>) -> Transition<A>>,
72    ) -> Box<dyn State<A>> {
73        let (_, outer_progress_rx) = futures_channel::mpsc::channel(1);
74        let (_, inner_progress_rx) = futures_channel::mpsc::channel(1);
75        FutureLoader::<A, Result<String>>::new_state(
76            ctx,
77            Box::pin(async move {
78                let mut builder = rfd::AsyncFileDialog::new().set_file_name(&filename);
79                if let Some(dir) = start_dir {
80                    builder = builder.set_directory(&dir);
81                }
82
83                #[cfg(not(target_arch = "wasm32"))]
84                let result = if let Some(handle) = builder.save_file().await {
85                    let path = handle.path().display().to_string();
86                    // Both cases do AsRef<[u8]>
87                    match write {
88                        FileSaverContents::String(string) => fs_err::write(&path, string),
89                        FileSaverContents::Bytes(bytes) => fs_err::write(&path, bytes),
90                    }
91                    .map(|_| path)
92                    .map_err(|err| err.into())
93                } else {
94                    Err(anyhow!("no file chosen to save"))
95                };
96
97                #[cfg(target_arch = "wasm32")]
98                let result = {
99                    // Hide an unused warning
100                    let _ = builder;
101
102                    // TODO No file save dialog on WASM until
103                    // https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker
104                    match write {
105                        FileSaverContents::String(string) => {
106                            abstio::write_file(filename.clone(), string)
107                        }
108                        FileSaverContents::Bytes(_bytes) => {
109                            // We need to use write_file (which downloads a file), but encode
110                            // binary data the right way
111                            Err(anyhow!("writing binary files on web unsupported"))
112                        }
113                    }
114                };
115
116                let wrap: Box<dyn Send + FnOnce(&A) -> Result<String>> =
117                    Box::new(move |_: &A| result);
118                Ok(wrap)
119            }),
120            outer_progress_rx,
121            inner_progress_rx,
122            "Waiting for a file to be chosen",
123            on_load,
124        )
125    }
126
127    // Popup a success or failure message after
128    pub fn with_default_messages<A: 'static + AppLike>(
129        ctx: &mut EventCtx,
130        filename: String,
131        start_dir: Option<String>,
132        write: FileSaverContents,
133    ) -> Box<dyn State<A>> {
134        Self::new_state(
135            ctx,
136            filename,
137            start_dir,
138            write,
139            Box::new(|ctx, _, result| {
140                Transition::Replace(match result {
141                    Ok(Ok(path)) => PopupMsg::new_state(
142                        ctx,
143                        "File saved",
144                        vec![format!("File saved to {path}")],
145                    ),
146                    Err(err) | Ok(Err(err)) => {
147                        PopupMsg::new_state(ctx, "Save failed", vec![err.to_string()])
148                    }
149                })
150            }),
151        )
152    }
153}
154
155pub fn percentage_bar(ctx: &EventCtx, txt: Text, pct_green: f64) -> Widget {
156    let bad_color = Color::RED;
157    let good_color = Color::GREEN;
158
159    let total_width = 450.0;
160    let height = 32.0;
161    let radius = 4.0;
162
163    let mut batch = GeomBatch::new();
164    // Background
165    batch.push(
166        bad_color,
167        Polygon::rounded_rectangle(total_width, height, radius),
168    );
169    // Foreground
170    if let Some(poly) = Polygon::maybe_rounded_rectangle(pct_green * total_width, height, radius) {
171        batch.push(good_color, poly);
172    }
173    // Text
174    let label = txt.render_autocropped(ctx);
175    let dims = label.get_dims();
176    batch.append(label.translate(10.0, height / 2.0 - dims.height / 2.0));
177    batch.into_widget(ctx)
178}
179
180/// Shorter is better
181pub fn cmp_dist(txt: &mut Text, app: &dyn AppLike, dist: Distance, shorter: &str, longer: &str) {
182    match dist.cmp(&Distance::ZERO) {
183        Ordering::Less => {
184            txt.add_line(
185                Line(format!(
186                    "{} {}",
187                    (-dist).to_string(&app.opts().units),
188                    shorter
189                ))
190                .fg(Color::GREEN),
191            );
192        }
193        Ordering::Greater => {
194            txt.add_line(
195                Line(format!("{} {}", dist.to_string(&app.opts().units), longer)).fg(Color::RED),
196            );
197        }
198        Ordering::Equal => {}
199    }
200}
201
202/// Shorter is better
203pub fn cmp_duration(
204    txt: &mut Text,
205    app: &dyn AppLike,
206    duration: Duration,
207    shorter: &str,
208    longer: &str,
209) {
210    match duration.cmp(&Duration::ZERO) {
211        Ordering::Less => {
212            txt.add_line(
213                Line(format!(
214                    "{} {}",
215                    (-duration).to_string(&app.opts().units),
216                    shorter
217                ))
218                .fg(Color::GREEN),
219            );
220        }
221        Ordering::Greater => {
222            txt.add_line(
223                Line(format!(
224                    "{} {}",
225                    duration.to_string(&app.opts().units),
226                    longer
227                ))
228                .fg(Color::RED),
229            );
230        }
231        Ordering::Equal => {}
232    }
233}
234
235/// Less is better
236pub fn cmp_count(txt: &mut Text, before: usize, after: usize) {
237    match after.cmp(&before) {
238        std::cmp::Ordering::Equal => {
239            txt.add_line(Line("same"));
240        }
241        std::cmp::Ordering::Less => {
242            txt.add_appended(vec![
243                Line(prettyprint_usize(before - after)).fg(Color::GREEN),
244                Line(" less"),
245            ]);
246        }
247        std::cmp::Ordering::Greater => {
248            txt.add_appended(vec![
249                Line(prettyprint_usize(after - before)).fg(Color::RED),
250                Line(" more"),
251            ]);
252        }
253    }
254}
255
256pub fn color_for_mode(app: &dyn AppLike, m: TripMode) -> Color {
257    match m {
258        TripMode::Walk => app.cs().unzoomed_pedestrian,
259        TripMode::Bike => app.cs().unzoomed_bike,
260        TripMode::Transit => app.cs().unzoomed_bus,
261        TripMode::Drive => app.cs().unzoomed_car,
262    }
263}
264
265pub fn checkbox_per_mode(
266    ctx: &mut EventCtx,
267    app: &dyn AppLike,
268    current_state: &BTreeSet<TripMode>,
269) -> Widget {
270    let mut filters = Vec::new();
271    for m in TripMode::all() {
272        filters.push(
273            Toggle::colored_checkbox(
274                ctx,
275                m.ongoing_verb(),
276                color_for_mode(app, m),
277                current_state.contains(&m),
278            )
279            .margin_right(24),
280        );
281    }
282    Widget::custom_row(filters)
283}