widgetry_demo/
lib.rs

1use rand::SeedableRng;
2use rand_xorshift::XorShiftRng;
3
4use geom::{Angle, Duration, Polygon, Pt2D, Time, UnitFmt};
5use widgetry::{
6    lctrl, Choice, Color, ContentMode, DragDrop, Drawable, EventCtx, Fill, GeomBatch, GfxCtx,
7    HorizontalAlignment, Image, Key, Line, LinePlot, Outcome, Panel, PanelDims, PersistentSplit,
8    PlotOptions, ScreenDims, Series, Settings, SharedAppState, StackAxis, State, TabController,
9    Text, TextExt, Texture, Toggle, Transition, UpdateType, VerticalAlignment, Widget,
10};
11
12pub fn main() {
13    let settings = Settings::new("widgetry demo");
14    run(settings);
15}
16
17fn run(mut settings: Settings) {
18    abstutil::logger::setup();
19    settings = settings.read_svg(Box::new(abstio::slurp_bytes));
20    // Control flow surrendered here. App implements State, which has an event handler and a draw
21    // callback.
22    //
23    // TODO The demo loads a .svg file, so to make it work on both native and web, for now we use
24    // read_svg. But we should have a more minimal example of how to do that here.
25    widgetry::run(settings, |ctx| {
26        // TODO: Add a toggle to switch theme in demo (and recreate UI in that new theme)
27        // ctx.set_style(widgetry::Style::dark_bg());
28
29        (App {}, vec![Box::new(Demo::new(ctx))])
30    });
31}
32
33struct App {}
34
35impl SharedAppState for App {}
36
37struct Demo {
38    controls: Panel,
39    timeseries_panel: Option<(Duration, Panel)>,
40    scrollable_canvas: Drawable,
41    texture_demo: Drawable,
42    elapsed: Duration,
43    tabs: TabController,
44}
45
46impl Demo {
47    fn new(ctx: &mut EventCtx) -> Self {
48        let mut tabs = make_tabs(ctx);
49        Self {
50            controls: make_controls(ctx, &mut tabs),
51            timeseries_panel: None,
52            scrollable_canvas: setup_scrollable_canvas(ctx),
53            texture_demo: setup_texture_demo(ctx, Texture::SAND, Texture::CACTUS),
54            elapsed: Duration::ZERO,
55            tabs,
56        }
57    }
58    fn create_series<F>(&self, label: &str, color: Color, func: F) -> Series<Time, usize>
59    where
60        F: Fn(usize) -> usize,
61    {
62        Series {
63            label: label.to_string(),
64            color,
65            pts: (0..(self.elapsed.inner_seconds() as usize))
66                .map(|s: usize| (Time::START_OF_DAY + Duration::seconds(s as f64), func(s)))
67                .collect(),
68        }
69    }
70    fn make_timeseries_panel(&self, ctx: &mut EventCtx) -> Panel {
71        // Make a table with 3 columns.
72        let mut col1 = vec![Line("Time").into_widget(ctx)];
73        let mut col = vec![Line("Linear").into_widget(ctx)];
74        let mut col3 = vec![Line("Quadratic").into_widget(ctx)];
75        for s in 0..(self.elapsed.inner_seconds() as usize) {
76            col1.push(
77                Line(format!("{}", Duration::seconds(s as f64)))
78                    .secondary()
79                    .into_widget(ctx),
80            );
81            col.push(Line(s.to_string()).secondary().into_widget(ctx));
82            col3.push(Line(s.pow(2).to_string()).secondary().into_widget(ctx));
83        }
84
85        let mut c = Panel::new_builder(Widget::col(vec![
86            Text::from_multiline(vec![
87                Line("Here's a bunch of text to force some scrolling.").small_heading(),
88                Line(
89                    "Bug: scrolling by clicking and dragging doesn't work while the stopwatch is \
90                     running.",
91                )
92                .fg(Color::RED),
93            ])
94            .into_widget(ctx),
95            Widget::row(vec![
96                // Examples of styling widgets
97                Widget::col(col1).outline((2.0, Color::BLACK)).padding(5),
98                Widget::col(col).outline((5.0, Color::BLACK)).padding(5),
99                Widget::col(col3).outline((5.0, Color::BLUE)).padding(5),
100            ]),
101            LinePlot::new_widget(
102                ctx,
103                "timeseries",
104                vec![
105                    self.create_series("Linear", Color::GREEN, |s| s),
106                    self.create_series("Quadratic", Color::BLUE, |s| s.pow(2)),
107                ],
108                PlotOptions {
109                    // Without this, the plot doesn't stretch to cover times in between whole
110                    // seconds.
111                    max_x: Some(Time::START_OF_DAY + self.elapsed),
112                    ..Default::default()
113                },
114                UnitFmt::metric(),
115            ),
116        ]))
117        // Don't let the panel exceed this percentage of the window. Scrollbars appear
118        // automatically if needed.
119        .dims_width(PanelDims::MaxPercent(0.3))
120        .dims_height(PanelDims::MaxPercent(0.4))
121        // We take up 30% width, and we want to leave 10% window width as buffer.
122        .aligned(HorizontalAlignment::Percent(0.6), VerticalAlignment::Center)
123        .build(ctx);
124
125        // Since we're creating an entirely new panel when the time changes, we need to preserve
126        // some internal state, like scroll and whether plot checkboxes were enabled.
127        if let Some((_, ref old)) = self.timeseries_panel {
128            c.restore(ctx, old);
129        }
130        c
131    }
132
133    fn redraw_stopwatch(&mut self, ctx: &mut EventCtx) {
134        // We can replace any named widget with another one. Layout gets recalculated.
135        self.controls.replace(
136            ctx,
137            "stopwatch",
138            format!("Stopwatch: {}", self.elapsed).text_widget(ctx),
139        );
140    }
141}
142
143impl State<App> for Demo {
144    fn event(&mut self, ctx: &mut EventCtx, _: &mut App) -> Transition<App> {
145        // Allow panning and zooming to work.
146        ctx.canvas_movement();
147
148        // This dispatches event handling to all of the widgets inside.
149        if let Outcome::Clicked(x) = self.controls.event(ctx) {
150            match x.as_ref() {
151                // These outcomes should probably be a custom enum per Panel, to be more
152                // typesafe.
153                "reset the stopwatch" => {
154                    self.elapsed = Duration::ZERO;
155                    self.redraw_stopwatch(ctx);
156                }
157                "generate new faces" => {
158                    self.scrollable_canvas = setup_scrollable_canvas(ctx);
159                }
160                "adjust timer" => {
161                    let offset: Duration = self.controls.persistent_split_value("adjust timer");
162                    self.elapsed += offset;
163                    self.redraw_stopwatch(ctx);
164                }
165                "apply" => {
166                    let (v_align, h_align) = self.controls.dropdown_value("alignment");
167                    self.controls.align(v_align, h_align);
168                    let (bg_texture, fg_texture) = self.controls.dropdown_value("texture");
169                    self.texture_demo = setup_texture_demo(ctx, bg_texture, fg_texture);
170                }
171                action => {
172                    if self.tabs.handle_action(ctx, action, &mut self.controls) {
173                        // if true, tabs has handled the action
174                    } else if action.contains("btn_") {
175                        log::info!("clicked button: {:?}", action);
176                    } else {
177                        unimplemented!("clicked: {:?}", x);
178                    }
179                }
180            }
181        }
182
183        // An update event means that no keyboard/mouse input happened, but time has passed.
184        // (Ignore the "nonblocking"; this API is funky right now. Only one caller "consumes" an
185        // event, so that multiple things don't all respond to one keypress, but that's set up
186        // oddly for update events.)
187        if let Some(dt) = ctx.input.nonblocking_is_update_event() {
188            ctx.input.use_update_event();
189            self.elapsed += dt;
190            self.redraw_stopwatch(ctx);
191        }
192
193        if self.controls.is_checked("Show timeseries") {
194            // Update the panel when time changes.
195            if self
196                .timeseries_panel
197                .as_ref()
198                .map(|(dt, _)| *dt != self.elapsed)
199                .unwrap_or(true)
200            {
201                self.timeseries_panel = Some((self.elapsed, self.make_timeseries_panel(ctx)));
202            }
203        } else {
204            self.timeseries_panel = None;
205        }
206
207        if let Some((_, ref mut p)) = self.timeseries_panel {
208            if let Outcome::Clicked(_) = p.event(ctx) {
209                unreachable!()
210            }
211        }
212
213        // If we're paused, only call event() again when there's some kind of input. If not, also
214        // sprinkle in periodic update events as time passes.
215        if !self.controls.is_checked("paused") {
216            ctx.request_update(UpdateType::Game);
217        }
218
219        Transition::Keep
220    }
221
222    fn draw(&self, g: &mut GfxCtx, _: &App) {
223        g.clear(Color::BLACK);
224
225        if self.controls.is_checked("Draw scrollable canvas") {
226            g.redraw(&self.scrollable_canvas);
227        }
228
229        self.controls.draw(g);
230
231        if let Some((_, ref p)) = self.timeseries_panel {
232            p.draw(g);
233        }
234
235        g.redraw(&self.texture_demo);
236    }
237}
238
239fn setup_texture_demo(ctx: &mut EventCtx, bg_texture: Texture, fg_texture: Texture) -> Drawable {
240    let mut batch = GeomBatch::new();
241
242    let mut rect = Polygon::rectangle(100.0, 100.0);
243    rect = rect.translate(200.0, 900.0);
244    // Texture::NOOP should always be pure white, since all "non-textured" colors are multiplied by
245    // Texture::NOOP (Texture::NOOP.0 == 0)
246    batch.push(Texture::NOOP, rect);
247
248    let triangle = geom::Triangle {
249        pt1: Pt2D::new(0.0, 100.0),
250        pt2: Pt2D::new(50.0, 0.0),
251        pt3: Pt2D::new(100.0, 100.0),
252    };
253    let mut triangle_poly = Polygon::from_triangle(&triangle);
254    triangle_poly = triangle_poly.translate(400.0, 900.0);
255    batch.push(bg_texture, triangle_poly);
256
257    let circle = geom::Circle::new(Pt2D::new(50.0, 50.0), geom::Distance::meters(50.0));
258    let mut circle_poly = circle.to_polygon();
259    circle_poly = circle_poly.translate(600.0, 900.0);
260    batch.push(
261        Fill::ColoredTexture(Color::RED, bg_texture),
262        circle_poly.clone(),
263    );
264    batch.push(fg_texture, circle_poly);
265
266    batch.upload(ctx)
267}
268
269// This prepares a bunch of geometry (colored polygons) and uploads it to the GPU once. Then it can
270// be redrawn cheaply later.
271fn setup_scrollable_canvas(ctx: &mut EventCtx) -> Drawable {
272    let mut batch = GeomBatch::new();
273    batch.push(
274        Color::hex("#4E30A6"),
275        Polygon::rounded_rectangle(5000.0, 5000.0, 25.0),
276    );
277    // SVG support using lyon and usvg. Map-space means don't scale for high DPI monitors.
278    batch
279        .append(GeomBatch::load_svg(ctx, "system/assets/pregame/logo.svg").translate(300.0, 300.0));
280    // Text rendering also goes through lyon and usvg.
281    batch.append(
282        Text::from(Line("Awesome vector text thanks to usvg and lyon").fg(Color::hex("#DF8C3D")))
283            .render_autocropped(ctx)
284            .scale(2.0)
285            .centered_on(Pt2D::new(600.0, 500.0))
286            .rotate(Angle::degrees(-30.0)),
287    );
288
289    let mut rng = if cfg!(target_arch = "wasm32") {
290        XorShiftRng::seed_from_u64(0)
291    } else {
292        XorShiftRng::from_entropy()
293    };
294    for i in 0..10 {
295        let mut svg_data = Vec::new();
296        svg_face::generate_face(&mut svg_data, &mut rng).unwrap();
297        let face = GeomBatch::load_svg_bytes_uncached(&svg_data).autocrop();
298        let dims = face.get_dims();
299        batch.append(
300            face.scale((200.0 / dims.width).min(200.0 / dims.height))
301                .translate(250.0 * (i as f64), 0.0),
302        );
303    }
304
305    // This is a bit of a hack; it's needed so that zooming in/out has reasonable limits.
306    ctx.canvas.map_dims = (5000.0, 5000.0);
307    batch.upload(ctx)
308}
309
310fn make_tabs(ctx: &mut EventCtx) -> TabController {
311    let style = ctx.style();
312
313    let mut tabs = TabController::new("demo_tabs");
314
315    let gallery_bar_item = style.btn_tab.text("Component Gallery");
316    let gallery_content = Widget::col(vec![
317        Text::from(Line("Text").big_heading_styled().size(18)).into_widget(ctx),
318        Text::from_all(vec![
319            Line("You can "),
320            Line("change fonts ").big_heading_plain(),
321            Line("on the same ").small().fg(Color::BLUE),
322            Line("line!").small_heading(),
323        ])
324        .bg(Color::GREEN)
325        .into_widget(ctx),
326        // Button Style Gallery
327        Text::from(Line("Buttons").big_heading_styled().size(18)).into_widget(ctx),
328        Widget::row(vec![
329            style
330                .btn_solid_primary
331                .text("Primary")
332                .build_widget(ctx, "btn_solid_primary_text"),
333            Widget::row(vec![
334                style
335                    .btn_solid_primary
336                    .icon("system/assets/tools/map.svg")
337                    .build_widget(ctx, "btn_solid_primary_icon"),
338                style
339                    .btn_plain_primary
340                    .icon("system/assets/tools/map.svg")
341                    .build_widget(ctx, "btn_plain_primary_icon"),
342            ]),
343            style
344                .btn_solid_primary
345                .icon_text("system/assets/tools/location.svg", "Primary")
346                .build_widget(ctx, "btn_solid_primary_icon_text"),
347        ]),
348        Widget::row(vec![
349            style
350                .btn_outline
351                .text("Secondary")
352                .build_widget(ctx, "btn_outline_text"),
353            Widget::row(vec![
354                style
355                    .btn_outline
356                    .icon("system/assets/tools/map.svg")
357                    .build_widget(ctx, "btn_outline_icon"),
358                style
359                    .btn_plain
360                    .icon("system/assets/tools/map.svg")
361                    .build_widget(ctx, "btn_plain_icon"),
362            ]),
363            style
364                .btn_outline
365                .icon_text("system/assets/tools/home.svg", "Secondary")
366                .build_widget(ctx, "btn_outline.icon_text"),
367        ]),
368        Widget::row(vec![style
369            .btn_popup_icon_text("system/assets/tools/map.svg", "Popup")
370            .build_widget(ctx, "btn_popup_icon_text")]),
371        Text::from_multiline(vec![
372            Line("Images").big_heading_styled().size(18),
373            Line(
374                "Images can be colored, scaled, and stretched. They can have a background and \
375                 padding.",
376            ),
377        ])
378        .into_widget(ctx),
379        Widget::row(vec![
380            Image::from_path("system/assets/tools/home.svg").into_widget(ctx),
381            Image::from_path("system/assets/tools/home.svg")
382                .color(Color::ORANGE)
383                .bg_color(Color::BLACK)
384                .dims(50.0)
385                .into_widget(ctx),
386            Image::from_path("system/assets/tools/home.svg")
387                .color(Color::RED)
388                .bg_color(Color::BLACK)
389                .padding(20)
390                .dims(ScreenDims::new(50.0, 100.0))
391                .content_mode(ContentMode::ScaleAspectFit)
392                .tooltip(
393                    "With ScaleAspectFit content grows, without distorting its aspect ratio, \
394                     until it reaches its padding bounds.",
395                )
396                .into_widget(ctx),
397            Image::from_path("system/assets/tools/home.svg")
398                .color(Color::GREEN)
399                .bg_color(Color::PURPLE)
400                .padding(20)
401                .dims(ScreenDims::new(50.0, 100.0))
402                .content_mode(ContentMode::ScaleToFill)
403                .tooltip("With ScaleToFill content can stretches to fill its size (less padding)")
404                .into_widget(ctx),
405            Image::from_path("system/assets/tools/home.svg")
406                .color(Color::BLUE)
407                .bg_color(Color::YELLOW)
408                .padding(20)
409                .dims(ScreenDims::new(50.0, 100.0))
410                .content_mode(ContentMode::ScaleAspectFill)
411                .tooltip("With ScaleAspectFill content can exceed its visible bounds")
412                .into_widget(ctx),
413        ]),
414        Text::from(Line("Spinner").big_heading_styled().size(18)).into_widget(ctx),
415        widgetry::Spinner::widget(ctx, "spinner", (0, 11), 1, 1),
416        Text::from(Line("Drag & Drop Cards").big_heading_styled().size(18)).into_widget(ctx),
417        build_drag_drop(ctx, 5).into_widget(ctx),
418    ]);
419    tabs.push_tab(gallery_bar_item, gallery_content);
420
421    let qa_bar_item = style.btn_tab.text("Conformance Checks");
422    let qa_content = Widget::col(vec![
423        Text::from(
424            Line("Controls should be same height")
425                .big_heading_styled()
426                .size(18),
427        )
428        .into_widget(ctx),
429        {
430            let row_height = 10;
431            let mut id = 0;
432            let mut next_id = || {
433                id += 1;
434                format!("btn_height_check_{}", id)
435            };
436            Widget::row(vec![
437                Widget::col(
438                    (0..row_height)
439                        .map(|_| {
440                            style
441                                .btn_outline
442                                .icon("system/assets/tools/layers.svg")
443                                .build_widget(ctx, &next_id())
444                        })
445                        .collect::<Vec<_>>(),
446                ),
447                Widget::col(
448                    (0..row_height)
449                        .map(|_| style.btn_outline.text("text").build_widget(ctx, &next_id()))
450                        .collect::<Vec<_>>(),
451                ),
452                Widget::col(
453                    (0..row_height)
454                        .map(|_| {
455                            style
456                                .btn_outline
457                                .icon_text("system/assets/tools/layers.svg", "icon+text")
458                                .build_widget(ctx, &next_id())
459                        })
460                        .collect::<Vec<_>>(),
461                ),
462                Widget::col(
463                    (0..row_height)
464                        .map(|_| {
465                            style
466                                .btn_popup_icon_text("system/assets/tools/layers.svg", "icon+text")
467                                .build_widget(ctx, &next_id())
468                        })
469                        .collect::<Vec<_>>(),
470                ),
471                Widget::col(
472                    (0..row_height)
473                        .map(|_| {
474                            style
475                                .btn_outline
476                                .popup("popup")
477                                .build_widget(ctx, &next_id())
478                        })
479                        .collect::<Vec<_>>(),
480                ),
481                Widget::col(
482                    (0..row_height)
483                        .map(|i| {
484                            widgetry::Spinner::widget(ctx, format!("spinner {}", i), (0, 11), 1, 1)
485                        })
486                        .collect::<Vec<_>>(),
487                ),
488                Widget::col(
489                    (0..row_height)
490                        .map(|_| widgetry::Toggle::checkbox(ctx, "checkbox", None, true))
491                        .collect::<Vec<_>>(),
492                ),
493            ])
494        },
495    ]);
496
497    tabs.push_tab(qa_bar_item, qa_content);
498
499    tabs
500}
501
502fn make_controls(ctx: &mut EventCtx, tabs: &mut TabController) -> Panel {
503    Panel::new_builder(Widget::col(vec![
504        Text::from(Line("widgetry demo").big_heading_styled()).into_widget(ctx),
505        Widget::col(vec![
506            Text::from(
507                "Click and drag the background to pan, use touchpad or scroll wheel to zoom",
508            )
509            .into_widget(ctx),
510            Widget::row(vec![
511                ctx.style()
512                    .btn_outline
513                    .text("New faces")
514                    .hotkey(Key::F)
515                    .build_widget(ctx, "generate new faces"),
516                Toggle::switch(ctx, "Draw scrollable canvas", None, true),
517                Toggle::switch(ctx, "Show timeseries", lctrl(Key::T), false),
518            ]),
519            "Stopwatch: ..."
520                .text_widget(ctx)
521                .named("stopwatch")
522                .margin_above(30),
523            Widget::row(vec![
524                Toggle::new_widget(
525                    false,
526                    ctx.style()
527                        .btn_outline
528                        .text("Pause")
529                        .hotkey(Key::Space)
530                        .build(ctx, "pause the stopwatch"),
531                    ctx.style()
532                        .btn_outline
533                        .text("Resume")
534                        .hotkey(Key::Space)
535                        .build(ctx, "resume the stopwatch"),
536                )
537                .named("paused"),
538                PersistentSplit::widget(
539                    ctx,
540                    "adjust timer",
541                    Duration::seconds(20.0),
542                    None,
543                    vec![
544                        Choice::new("+20s", Duration::seconds(20.0)),
545                        Choice::new("-10s", Duration::seconds(-10.0)),
546                    ],
547                ),
548                ctx.style()
549                    .btn_outline
550                    .text("Reset Timer")
551                    .build_widget(ctx, "reset the stopwatch"),
552            ])
553            .evenly_spaced(),
554            Widget::row(vec![
555                Widget::dropdown(
556                    ctx,
557                    "alignment",
558                    (HorizontalAlignment::Center, VerticalAlignment::Top),
559                    vec![
560                        Choice::new("Top", (HorizontalAlignment::Center, VerticalAlignment::Top)),
561                        Choice::new(
562                            "Left",
563                            (HorizontalAlignment::Left, VerticalAlignment::Center),
564                        ),
565                        Choice::new(
566                            "Bottom",
567                            (HorizontalAlignment::Center, VerticalAlignment::Bottom),
568                        ),
569                        Choice::new(
570                            "Right",
571                            (HorizontalAlignment::Right, VerticalAlignment::Center),
572                        ),
573                        Choice::new(
574                            "Center",
575                            (HorizontalAlignment::Center, VerticalAlignment::Center),
576                        ),
577                    ],
578                ),
579                Widget::dropdown(
580                    ctx,
581                    "texture",
582                    (Texture::SAND, Texture::CACTUS),
583                    vec![
584                        Choice::new("Cold", (Texture::SNOW, Texture::SNOW_PERSON)),
585                        Choice::new("Hot", (Texture::SAND, Texture::CACTUS)),
586                    ],
587                ),
588                ctx.style()
589                    .btn_solid_primary
590                    .text("Apply")
591                    .build_widget(ctx, "apply"),
592            ])
593            .margin_above(30),
594        ])
595        .section(ctx),
596        tabs.build_widget(ctx),
597    ])) // end panel
598    .aligned(HorizontalAlignment::Center, VerticalAlignment::Top)
599    .build(ctx)
600}
601
602fn build_drag_drop(ctx: &EventCtx, num_cards: usize) -> DragDrop<usize> {
603    fn build_card(ctx: &EventCtx, num: usize) -> (ScreenDims, GeomBatch, GeomBatch, GeomBatch) {
604        let dims = ScreenDims::new(100.0 + (num % 2) as f64 * 50.0, 150.0);
605
606        let mut default_batch = GeomBatch::new();
607        default_batch.push(Color::ORANGE, Polygon::rectangle(dims.width, dims.height));
608        default_batch.append(Text::from(format!("Card {}", num)).render(ctx));
609
610        let mut hovering_batch = GeomBatch::new();
611        hovering_batch.push(Color::RED, Polygon::rectangle(dims.width, dims.height));
612        hovering_batch.append(Text::from(format!("Card {}", num)).render(ctx));
613
614        let mut selected_batch = GeomBatch::new();
615        selected_batch.push(Color::BLUE, Polygon::rectangle(dims.width, dims.height));
616        selected_batch.append(Text::from(format!("Card {}", num)).render(ctx));
617
618        (dims, default_batch, hovering_batch, selected_batch)
619    }
620
621    let mut drag_drop = DragDrop::new(ctx, "drag and drop cards", StackAxis::Horizontal);
622    for i in 0..num_cards {
623        let (dims, default_batch, hovering_batch, selected_batch) = build_card(ctx, i);
624        drag_drop.push_card(i, dims, default_batch, hovering_batch, selected_batch);
625    }
626    drag_drop
627}
628
629// Boilerplate for web support
630
631#[cfg(target_arch = "wasm32")]
632use wasm_bindgen::prelude::*;
633
634#[cfg(target_arch = "wasm32")]
635#[wasm_bindgen(js_name = "run")]
636pub fn run_wasm(root_dom_id: String, assets_base_url: String, assets_are_gzipped: bool) {
637    let settings = Settings::new("widgetry demo")
638        .root_dom_element_id(root_dom_id)
639        .assets_base_url(assets_base_url)
640        .assets_are_gzipped(assets_are_gzipped);
641    run(settings);
642}