ltn/pages/design_ltn/
page.rs

1use geom::{Angle, ArrowCap, Distance, PolyLine, Pt2D};
2use map_gui::tools::DrawSimpleRoadLabels;
3use map_model::FilterType;
4use osm2streets::Direction;
5use widgetry::mapspace::{DummyID, World};
6use widgetry::tools::{ChooseSomething, PopupMsg};
7use widgetry::{
8    lctrl, Choice, Color, ControlState, DrawBaselayer, Drawable, EventCtx, GeomBatch, GfxCtx, Key,
9    Line, Outcome, Panel, RewriteColor, State, Text, TextExt, Widget,
10};
11
12use super::{EditMode, EditNeighbourhood, EditOutcome};
13use crate::components::{AppwidePanel, BottomPanel, Mode};
14use crate::logic::AutoFilterHeuristic;
15use crate::render::colors;
16use crate::{is_private, pages, render, App, Neighbourhood, NeighbourhoodID, Transition};
17
18pub struct DesignLTN {
19    appwide_panel: AppwidePanel,
20    bottom_panel: Panel,
21    neighbourhood: Neighbourhood,
22    draw_top_layer: Drawable,
23    draw_under_roads_layer: Drawable,
24    fade_irrelevant: Drawable,
25    labels: DrawSimpleRoadLabels,
26    highlight_cell: World<DummyID>,
27    edit: EditNeighbourhood,
28    // Expensive to calculate
29    preserve_state: crate::save::PreserveState,
30
31    show_unreachable_cell: Drawable,
32    show_suspicious_perimeters: Drawable,
33}
34
35impl DesignLTN {
36    pub fn new_state(
37        ctx: &mut EventCtx,
38        app: &mut App,
39        id: NeighbourhoodID,
40    ) -> Box<dyn State<App>> {
41        app.per_map.current_neighbourhood = Some(id);
42
43        let neighbourhood = Neighbourhood::new(app, id);
44        let fade_irrelevant = neighbourhood.fade_irrelevant(ctx, app);
45
46        let mut label_roads = neighbourhood.perimeter_roads.clone();
47        label_roads.extend(neighbourhood.interior_roads.clone());
48        let labels = DrawSimpleRoadLabels::new(
49            ctx,
50            app,
51            colors::LOCAL_ROAD_LABEL,
52            Box::new(move |r| label_roads.contains(&r.id)),
53        );
54
55        let mut show_suspicious_perimeters = GeomBatch::new();
56        for r in &neighbourhood.suspicious_perimeter_roads {
57            show_suspicious_perimeters
58                .push(Color::RED, app.per_map.map.get_r(*r).get_thick_polygon());
59        }
60
61        let mut state = Self {
62            appwide_panel: AppwidePanel::new(ctx, app, Mode::ModifyNeighbourhood),
63            bottom_panel: Panel::empty(ctx),
64            neighbourhood,
65            draw_top_layer: Drawable::empty(ctx),
66            draw_under_roads_layer: Drawable::empty(ctx),
67            fade_irrelevant,
68            labels,
69            highlight_cell: World::new(),
70            edit: EditNeighbourhood::temporary(),
71            preserve_state: crate::save::PreserveState::DesignLTN(
72                app.partitioning().neighbourhood_to_blocks(id),
73            ),
74
75            show_unreachable_cell: Drawable::empty(ctx),
76            show_suspicious_perimeters: ctx.upload(show_suspicious_perimeters),
77        };
78        state.update(ctx, app);
79        Box::new(state)
80    }
81
82    fn update(&mut self, ctx: &mut EventCtx, app: &App) {
83        let (edit, draw_top_layer, draw_under_roads_layer, render_cells, highlight_cell) =
84            setup_editing(ctx, app, &self.neighbourhood, &self.labels);
85        self.edit = edit;
86        self.draw_top_layer = draw_top_layer;
87        self.draw_under_roads_layer = draw_under_roads_layer;
88        self.highlight_cell = highlight_cell;
89
90        let mut show_unreachable_cell = GeomBatch::new();
91        let mut disconnected_cells = 0;
92        for (idx, cell) in self.neighbourhood.cells.iter().enumerate() {
93            if cell.is_disconnected() {
94                disconnected_cells += 1;
95                show_unreachable_cell.extend(
96                    Color::RED.alpha(0.8),
97                    render_cells.polygons_per_cell[idx].clone(),
98                );
99            }
100        }
101        let warning1 = if disconnected_cells == 0 {
102            Widget::nothing()
103        } else {
104            let msg = if disconnected_cells == 1 {
105                "1 cell isn't reachable".to_string()
106            } else {
107                format!("{disconnected_cells} cells aren't reachable")
108            };
109
110            ctx.style()
111                .btn_plain
112                .icon_text("system/assets/tools/warning.svg", msg)
113                .label_color(Color::RED, ControlState::Default)
114                .no_tooltip()
115                .build_widget(ctx, "warning1")
116        };
117        self.show_unreachable_cell = ctx.upload(show_unreachable_cell);
118
119        let warning2 = if self.neighbourhood.suspicious_perimeter_roads.is_empty() {
120            Widget::nothing()
121        } else {
122            ctx.style()
123                .btn_plain
124                .icon_text(
125                    "system/assets/tools/warning.svg",
126                    "Part of the perimeter is a local street",
127                )
128                .label_color(Color::RED, ControlState::Default)
129                .no_tooltip()
130                .build_widget(ctx, "warning2")
131        };
132
133        self.bottom_panel = make_bottom_panel(
134            ctx,
135            app,
136            &self.appwide_panel,
137            Widget::col(vec![
138                format!(
139                    "Area: {}",
140                    app.partitioning()
141                        .neighbourhood_area_km2(self.neighbourhood.id)
142                )
143                .text_widget(ctx)
144                .centered_horiz(),
145                warning1.centered_horiz(),
146                warning2.centered_horiz(),
147            ])
148            .centered_vert(),
149        );
150    }
151}
152
153impl State<App> for DesignLTN {
154    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
155        if let Some(t) = self
156            .appwide_panel
157            .event(ctx, app, &self.preserve_state, help)
158        {
159            return t;
160        }
161        if let Some(t) = app.session.layers.event(
162            ctx,
163            &app.cs,
164            Mode::ModifyNeighbourhood,
165            Some(&self.bottom_panel),
166        ) {
167            return t;
168        }
169        if let Outcome::Clicked(x) = self.bottom_panel.event(ctx) {
170            if x == "Advanced" {
171                return launch_advanced(ctx, app, self.neighbourhood.id);
172            } else if x == "warning1" {
173                return Transition::Push(PopupMsg::new_state(
174                    ctx,
175                    "Unreachable cell",
176                    vec![
177                        "Some streets inside this area can't be reached by car at all.",
178                        "You probably drew too many filters.",
179                        "",
180                        "(This may be incorrectly detected near some private/gated roads)",
181                    ],
182                ));
183            } else if x == "warning2" {
184                return Transition::Push(PopupMsg::new_state(
185                        ctx,
186                        "Unusual perimeter",
187                        vec![
188                        "Part of this area's perimeter consists of streets classified as local.",
189                        "This is usually fine, when this area doesn't connect to other main roads farther away.",
190                        "If you're near the edge of the map, it might be an error. Try importing a larger area, including the next major road in that direction",
191                        ],
192                        ));
193            }
194
195            match self.edit.handle_panel_action(
196                ctx,
197                app,
198                x.as_ref(),
199                &self.neighbourhood,
200                &mut self.bottom_panel,
201            ) {
202                EditOutcome::Nothing => unreachable!(),
203                EditOutcome::UpdatePanelAndWorld => {
204                    self.update(ctx, app);
205                    return Transition::Keep;
206                }
207                EditOutcome::UpdateAll => {
208                    if app.session.manage_proposals {
209                        self.appwide_panel = AppwidePanel::new(ctx, app, Mode::ModifyNeighbourhood);
210                    }
211                    self.neighbourhood.edits_changed(&app.per_map.map);
212                    self.update(ctx, app);
213                    return Transition::Keep;
214                }
215                EditOutcome::Transition(t) => {
216                    return t;
217                }
218            }
219        }
220
221        match self.edit.event(ctx, app, &self.neighbourhood) {
222            EditOutcome::Nothing => {}
223            EditOutcome::UpdatePanelAndWorld => {
224                self.update(ctx, app);
225            }
226            EditOutcome::UpdateAll => {
227                if app.session.manage_proposals {
228                    self.appwide_panel = AppwidePanel::new(ctx, app, Mode::ModifyNeighbourhood);
229                }
230                self.neighbourhood.edits_changed(&app.per_map.map);
231                self.update(ctx, app);
232            }
233            EditOutcome::Transition(t) => {
234                return t;
235            }
236        }
237
238        self.highlight_cell.event(ctx);
239
240        Transition::Keep
241    }
242
243    fn draw_baselayer(&self) -> DrawBaselayer {
244        DrawBaselayer::Custom
245    }
246
247    fn draw(&self, g: &mut GfxCtx, app: &App) {
248        app.draw_with_layering(g, |g| g.redraw(&self.draw_under_roads_layer));
249        g.redraw(&self.fade_irrelevant);
250        self.draw_top_layer.draw(g);
251        self.highlight_cell.draw(g);
252        self.edit.world.draw(g);
253
254        self.appwide_panel.draw(g);
255        self.bottom_panel.draw(g);
256        self.labels.draw(g);
257        app.per_map.draw_major_road_labels.draw(g);
258        app.session.layers.draw(g, app);
259        app.per_map.draw_all_filters.draw(g);
260        app.per_map.draw_poi_icons.draw(g);
261
262        if self.bottom_panel.currently_hovering() == Some(&"warning1".to_string()) {
263            g.redraw(&self.show_unreachable_cell);
264        }
265        if self.bottom_panel.currently_hovering() == Some(&"warning2".to_string()) {
266            g.redraw(&self.show_suspicious_perimeters);
267        }
268
269        if let EditMode::FreehandFilters(ref lasso) = app.session.edit_mode {
270            lasso.draw(g);
271        }
272    }
273
274    fn recreate(&mut self, ctx: &mut EventCtx, app: &mut App) -> Box<dyn State<App>> {
275        Self::new_state(ctx, app, self.neighbourhood.id)
276    }
277}
278
279fn setup_editing(
280    ctx: &mut EventCtx,
281    app: &App,
282    neighbourhood: &Neighbourhood,
283    labels: &DrawSimpleRoadLabels,
284) -> (
285    EditNeighbourhood,
286    Drawable,
287    Drawable,
288    render::RenderCells,
289    World<DummyID>,
290) {
291    let edit = EditNeighbourhood::new(ctx, app, neighbourhood);
292    let map = &app.per_map.map;
293
294    // Draw some stuff under roads and other stuff on top
295    let mut draw_top_layer = GeomBatch::new();
296    // Use a separate world to highlight cells when hovering on them. This is separate from
297    // edit.world so that we draw it even while hovering on roads/intersections in a cell
298    let mut highlight_cell = World::new();
299
300    let render_cells = render::RenderCells::new(map, neighbourhood);
301
302    let draw_under_roads_layer = render_cells.draw_colored_areas();
303    draw_top_layer.append(render_cells.draw_island_outlines());
304
305    // Highlight border arrows when hovered
306    for (idx, polygons) in render_cells.polygons_per_cell.iter().enumerate() {
307        // Edge case happening near https://www.openstreetmap.org/way/106879596
308        if polygons.is_empty() {
309            continue;
310        }
311
312        let color = render_cells.colors[idx].alpha(1.0);
313        let mut batch = GeomBatch::new();
314        for arrow in neighbourhood.cells[idx].border_arrows(app) {
315            batch.push(color, arrow);
316        }
317
318        highlight_cell
319            .add_unnamed()
320            .hitboxes(polygons.clone())
321            // Don't draw cells by default
322            .drawn_in_master_batch()
323            .draw_hovered(batch)
324            .build(ctx);
325    }
326    highlight_cell.initialize_hover(ctx);
327
328    if !matches!(
329        app.session.edit_mode,
330        EditMode::Shortcuts(_) | EditMode::SpeedLimits
331    ) {
332        draw_top_layer.append(neighbourhood.shortcuts.draw_heatmap(app));
333    }
334
335    // Draw the borders of each cell
336    for (idx, cell) in neighbourhood.cells.iter().enumerate() {
337        let color = render_cells.colors[idx].alpha(1.0);
338        for arrow in cell.border_arrows(app) {
339            draw_top_layer.push(color, arrow.clone());
340            draw_top_layer.push(Color::BLACK, arrow.to_outline(Distance::meters(1.0)));
341        }
342    }
343
344    // Draw one-way arrows and mark private roads
345    let private_road = GeomBatch::load_svg(ctx, "system/assets/map/private_road.svg");
346
347    for r in neighbourhood
348        .interior_roads
349        .iter()
350        .chain(neighbourhood.perimeter_roads.iter())
351    {
352        let road = map.get_r(*r);
353        if let Some(dir) = road.oneway_for_driving() {
354            // Manually tuned to make arrows fit within roads of any width. We could be more
355            // specific and calculate based on the road's outline, a buffer, etc, but we'd still
356            // have to do some math to account for the triangular arrow cap. This value looks
357            // reasonable.
358            let thickness = 0.2 * road.get_width();
359            let arrow_len = 5.0 * thickness;
360
361            let slices = if let Some((start, end)) = labels.label_covers_road.get(r) {
362                vec![
363                    road.center_pts.exact_slice(Distance::ZERO, *start),
364                    road.center_pts.exact_slice(*end, road.length()),
365                ]
366            } else {
367                vec![road.center_pts.clone()]
368            };
369
370            let mut draw_arrow = |pt: Pt2D, angle: Angle| {
371                // If the user has made the one-way point opposite to how the road is originally
372                // oriented, reverse the arrows
373                let pl = PolyLine::must_new(vec![
374                    pt.project_away(arrow_len / 2.0, angle.opposite()),
375                    pt.project_away(arrow_len / 2.0, angle),
376                ])
377                .maybe_reverse(dir == Direction::Back);
378
379                draw_top_layer.push(
380                    colors::LOCAL_ROAD_LABEL,
381                    pl.make_arrow(thickness, ArrowCap::Triangle)
382                        .to_outline(thickness / 4.0),
383                );
384            };
385
386            let mut any = false;
387            for slice in slices {
388                for (pt, angle) in slice.step_along(3.0 * arrow_len, arrow_len) {
389                    any = true;
390                    draw_arrow(pt, angle);
391                }
392            }
393
394            if !any {
395                // If the label won and we haven't drawn anything, draw arrows right at the start
396                // and end. Otherwise the user has no idea what's happening
397                for dist in [Distance::ZERO, road.length()] {
398                    let (pt, angle) = road.center_pts.must_dist_along(dist);
399                    draw_arrow(pt, angle);
400                }
401            }
402        }
403
404        // Mimic the UK-style "no entry" / dead-end symbol at both ends of every private road
405        // segment
406        if is_private(road) {
407            // The outline is 1m on each side
408            let width = road.get_width() - Distance::meters(2.0);
409            for (dist, rotate) in [(width, 90.0), (road.center_pts.length() - width, -90.0)] {
410                if let Ok((pt, angle)) = road.center_pts.dist_along(dist) {
411                    draw_top_layer.append(
412                        private_road
413                            .clone()
414                            .scale_to_fit_width(width.inner_meters())
415                            .centered_on(pt)
416                            .rotate_around_batch_center(angle.rotate_degs(rotate)),
417                    );
418                }
419            }
420        }
421    }
422
423    (
424        edit,
425        draw_top_layer.build(ctx),
426        ctx.upload(draw_under_roads_layer),
427        render_cells,
428        highlight_cell,
429    )
430}
431
432fn launch_advanced(ctx: &mut EventCtx, app: &App, id: NeighbourhoodID) -> Transition {
433    let mut choices = vec![Choice::string("Automatically place modal filters")];
434    if !app.partitioning().custom_boundaries.contains_key(&id) {
435        choices.push(Choice::string("Customize boundary (for drawing only)"));
436        choices.push(Choice::string("Convert to freehand area"));
437    }
438
439    Transition::Push(ChooseSomething::new_state(
440        ctx,
441        "Advanced features",
442        choices,
443        Box::new(move |choice, ctx, app| {
444            if choice == "Customize boundary (for drawing only)" {
445                Transition::Replace(pages::CustomizeBoundary::new_state(ctx, app, id))
446            } else if choice == "Convert to freehand area" {
447                Transition::Replace(pages::FreehandBoundary::new_from_polygon(
448                    ctx,
449                    app,
450                    format!("Converted from {:?}", id),
451                    app.partitioning().get_info(id).block.polygon.clone(),
452                ))
453            } else {
454                Transition::Replace(ChooseSomething::new_state(
455                    ctx,
456                    "Add one filter automatically, using different heuristics",
457                    AutoFilterHeuristic::choices(),
458                    Box::new(move |heuristic, ctx, app| {
459                        match ctx.loading_screen(
460                            "automatically filter a neighbourhood",
461                            |ctx, timer| {
462                                let neighbourhood = Neighbourhood::new(app, id);
463                                heuristic.apply(ctx, app, &neighbourhood, timer)
464                            },
465                        ) {
466                            Ok(()) => {
467                                Transition::Multi(vec![Transition::Pop, Transition::Recreate])
468                            }
469                            Err(err) => Transition::Replace(PopupMsg::new_state(
470                                ctx,
471                                "Error",
472                                vec![err.to_string()],
473                            )),
474                        }
475                    }),
476                ))
477            }
478        }),
479    ))
480}
481
482fn help() -> Vec<&'static str> {
483    vec![
484        "The colored cells show where it's possible to drive without leaving the neighbourhood.",
485        "",
486        "The darker red roads have more predicted shortcutting traffic.",
487        "",
488        "Hint: You can place filters at roads or intersections.",
489        "Use the lasso tool to quickly sketch your idea.",
490    ]
491}
492
493fn make_bottom_panel(
494    ctx: &mut EventCtx,
495    app: &App,
496    appwide_panel: &AppwidePanel,
497    per_tab_contents: Widget,
498) -> Panel {
499    let (road_filters, diagonal_filters, one_ways, turn_restrictions) = count_edits(app);
500
501    let row = Widget::row(vec![
502        edit_mode(ctx, app),
503        if let EditMode::Shortcuts(ref focus) = app.session.edit_mode {
504            super::shortcuts::widget(ctx, app, focus.as_ref())
505        } else if let EditMode::SpeedLimits = app.session.edit_mode {
506            super::speed_limits::widget(ctx)
507        } else if let EditMode::TurnRestrictions(ref focus) = app.session.edit_mode {
508            super::turn_restrictions::widget(ctx, app, focus.as_ref())
509        } else {
510            Widget::nothing()
511        }
512        .named("edit mode contents"),
513        Widget::vertical_separator(ctx),
514        Widget::row(vec![
515            ctx.style()
516                .btn_plain
517                .icon("system/assets/tools/undo.svg")
518                // TODO Basemap edits count in here
519                .disabled(app.per_map.map.get_edits().commands.is_empty())
520                .hotkey(lctrl(Key::Z))
521                .build_widget(ctx, "undo"),
522            Widget::col(vec![
523                format!("{} new filters", road_filters + diagonal_filters).text_widget(ctx),
524                format!("{} turn restrictions changed", turn_restrictions).text_widget(ctx),
525                format!("{} road directions changed", one_ways).text_widget(ctx),
526            ]),
527        ]),
528        Widget::vertical_separator(ctx),
529        per_tab_contents,
530        if app.per_map.consultation.is_none() {
531            Widget::row(vec![
532                Widget::vertical_separator(ctx),
533                ctx.style()
534                    .btn_outline
535                    .text("Adjust boundary")
536                    .hotkey(Key::B)
537                    .build_def(ctx),
538                ctx.style()
539                    .btn_outline
540                    .text("Per-resident route impact")
541                    .build_def(ctx),
542                ctx.style().btn_outline.text("Advanced").build_def(ctx),
543            ])
544            .centered_vert()
545        } else {
546            Widget::row(vec![
547                Widget::vertical_separator(ctx),
548                ctx.style()
549                    .btn_outline
550                    .text("Per-resident route impact")
551                    .build_def(ctx),
552            ])
553            .centered_vert()
554        },
555    ])
556    .evenly_spaced();
557
558    BottomPanel::new(ctx, appwide_panel, row)
559}
560
561fn count_edits(app: &App) -> (usize, usize, usize, usize) {
562    let map = &app.per_map.map;
563    let mut road_filters = 0;
564    let mut diagonal_filters = 0;
565    let mut one_ways = 0;
566    let mut turn_restrictions = 0;
567
568    for (r, orig) in &map.get_edits().original_roads {
569        let road = map.get_r(*r);
570        // Don't count existing filters that were modified?
571        if road.modal_filter.is_some() && orig.modal_filter.is_none() {
572            road_filters += 1;
573        }
574        let dir_new = road.lanes.iter().map(|l| l.dir).collect::<Vec<_>>();
575        let dir_old = orig.lanes_ltr.iter().map(|l| l.dir).collect::<Vec<_>>();
576        // TODO This incorrectly includes some existing filters on cycleways
577        if dir_new != dir_old {
578            one_ways += 1;
579        }
580        // Counts both new adding new turn restrictions and removing pre-existing turn restrictions
581        let mut tr_added = road.turn_restrictions.clone();
582        tr_added.retain(|x| !orig.turn_restrictions.contains(x));
583        let mut tr_removed = orig.turn_restrictions.clone();
584        tr_removed.retain(|x| !road.turn_restrictions.contains(x));
585        let mut ctr_added = road.complicated_turn_restrictions.clone();
586        ctr_added.retain(|x| !orig.complicated_turn_restrictions.contains(x));
587        let mut ctr_removed = orig.complicated_turn_restrictions.clone();
588        ctr_removed.retain(|x| !road.complicated_turn_restrictions.contains(x));
589        turn_restrictions +=
590            tr_added.len() + tr_removed.len() + ctr_added.len() + ctr_removed.len();
591    }
592    for (i, orig) in &map.get_edits().original_intersections {
593        if map.get_i(*i).modal_filter.is_some() && orig.modal_filter.is_none() {
594            diagonal_filters += 1;
595        }
596    }
597
598    (road_filters, diagonal_filters, one_ways, turn_restrictions)
599}
600
601fn edit_mode(ctx: &mut EventCtx, app: &App) -> Widget {
602    let edit_mode = &app.session.edit_mode;
603    let hide_color = render::filter_hide_color(app.session.filter_type);
604    let name = match app.session.filter_type {
605        FilterType::WalkCycleOnly => "Modal filter -- walking/cycling only",
606        FilterType::NoEntry => "Modal filter - no entry",
607        FilterType::BusGate => "Bus gate",
608        FilterType::SchoolStreet => "School street",
609    };
610
611    Widget::row(vec![
612        Widget::custom_row(vec![
613            ctx.style()
614                .btn_solid_primary
615                .icon(render::filter_svg_path(app.session.filter_type))
616                .image_color(
617                    RewriteColor::Change(hide_color, Color::CLEAR),
618                    ControlState::Default,
619                )
620                .image_color(
621                    RewriteColor::Change(hide_color, Color::CLEAR),
622                    ControlState::Disabled,
623                )
624                .disabled(matches!(edit_mode, EditMode::Filters))
625                .tooltip_and_disabled({
626                    let mut txt = Text::new();
627                    txt.add_line(Line(Key::F1.describe()).fg(ctx.style().text_hotkey_color));
628                    txt.append(Line(" - "));
629                    txt.append(Line(name));
630                    txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
631                    txt.append(Line(
632                        " a road or intersection to add or remove a modal filter",
633                    ));
634                    txt
635                })
636                .hotkey(Key::F1)
637                .build_widget(ctx, name)
638                .centered_vert(),
639            ctx.style()
640                .btn_plain
641                .dropdown()
642                .build_widget(ctx, "Change modal filter")
643                .centered_vert(),
644        ]),
645        ctx.style()
646            .btn_solid_primary
647            .icon("system/assets/tools/select.svg")
648            .disabled(matches!(edit_mode, EditMode::FreehandFilters(_)))
649            .hotkey(Key::F2)
650            .tooltip_and_disabled({
651                let mut txt = Text::new();
652                txt.add_line(Line(Key::F2.describe()).fg(ctx.style().text_hotkey_color));
653                txt.append(Line(" - Freehand filters"));
654                txt.add_line(Line("Click and drag").fg(ctx.style().text_hotkey_color));
655                txt.append(Line(" across the roads you want to filter"));
656                txt
657            })
658            .build_widget(ctx, "Freehand filters")
659            .centered_vert(),
660        ctx.style()
661            .btn_solid_primary
662            .icon("system/assets/tools/one_ways.svg")
663            .disabled(matches!(edit_mode, EditMode::Oneways))
664            .hotkey(Key::F3)
665            .tooltip_and_disabled({
666                let mut txt = Text::new();
667                txt.add_line(Line(Key::F3.describe()).fg(ctx.style().text_hotkey_color));
668                txt.append(Line(" - One-ways"));
669                txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
670                txt.append(Line(" a road to change its direction"));
671                txt
672            })
673            .build_widget(ctx, "One-ways")
674            .centered_vert(),
675        ctx.style()
676            .btn_solid_primary
677            .icon("system/assets/tools/shortcut.svg")
678            .disabled(matches!(edit_mode, EditMode::Shortcuts(_)))
679            .hotkey(Key::F4)
680            .tooltip_and_disabled({
681                let mut txt = Text::new();
682                txt.add_line(Line(Key::F4.describe()).fg(ctx.style().text_hotkey_color));
683                txt.append(Line(" - Shortcuts"));
684                txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
685                txt.append(Line(" a road to view shortcuts through it"));
686                txt
687            })
688            .build_widget(ctx, "Shortcuts")
689            .centered_vert(),
690        ctx.style()
691            .btn_solid_primary
692            .icon("system/assets/tools/20_mph.svg")
693            .image_color(
694                RewriteColor::Change(Color::RED, Color::CLEAR),
695                ControlState::Default,
696            )
697            .image_color(
698                RewriteColor::Change(Color::RED, Color::CLEAR),
699                ControlState::Disabled,
700            )
701            .disabled(matches!(edit_mode, EditMode::SpeedLimits))
702            .hotkey(Key::F5)
703            .tooltip_and_disabled({
704                let mut txt = Text::new();
705                txt.add_line(Line(Key::F5.describe()).fg(ctx.style().text_hotkey_color));
706                txt.append(Line(" - Speed limits"));
707                txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
708                txt.append(Line(" a road to convert it to 20mph (32kph)"));
709                txt
710            })
711            .build_widget(ctx, "Speed limits")
712            .centered_vert(),
713        ctx.style()
714            .btn_solid_primary
715            .icon("system/assets/map/no_right_turn_button.svg")
716            .disabled(matches!(edit_mode, EditMode::TurnRestrictions(_)))
717            .hotkey(Key::F6)
718            .tooltip_and_disabled({
719                let mut txt = Text::new();
720                txt.add_line(Line(Key::F6.describe()).fg(ctx.style().text_hotkey_color));
721                txt.append(Line(" - Turn restrictions"));
722                txt.add_line(Line("Click").fg(ctx.style().text_hotkey_color));
723                txt.append(Line(
724                    " a road to edit turn restrictions at its intersections",
725                ));
726                txt
727            })
728            .build_widget(ctx, "Turn restrictions")
729            .centered_vert(),
730    ])
731}