game/edit/
mod.rs

1use maplit::btreeset;
2
3use crate::ID;
4use abstutil::{prettyprint_usize, Timer};
5use geom::Speed;
6use map_gui::options::OptionsPanel;
7use map_gui::render::DrawMap;
8use map_gui::tools::grey_out_map;
9use map_model::{EditCmd, IntersectionID, LaneID, MapEdits};
10use widgetry::mapspace::ToggleZoomed;
11use widgetry::tools::{ChooseSomething, ColorLegend, PopupMsg};
12use widgetry::{
13    lctrl, Choice, Color, ControlState, EventCtx, GfxCtx, HorizontalAlignment, Image, Key, Line,
14    Menu, Outcome, Panel, State, Text, TextBox, TextExt, VerticalAlignment, Widget,
15};
16
17pub use self::roads::RoadEditor;
18pub use self::routes::RouteEditor;
19pub use self::stop_signs::StopSignEditor;
20pub use self::traffic_signals::TrafficSignalEditor;
21pub use self::validate::check_sidewalk_connectivity;
22use crate::app::{App, Transition};
23use crate::common::{tool_panel, CommonState, Warping};
24use crate::debug::DebugMode;
25use crate::sandbox::{GameplayMode, SandboxMode, TimeWarpScreen};
26
27mod crosswalks;
28mod multiple_roads;
29mod roads;
30mod routes;
31mod stop_signs;
32mod traffic_signals;
33mod validate;
34mod zones;
35
36pub struct EditMode {
37    tool_panel: Panel,
38    top_center: Panel,
39    changelist: Panel,
40    orig_edits: MapEdits,
41    orig_dirty: bool,
42
43    // Retained state from the SandboxMode that spawned us
44    mode: GameplayMode,
45
46    map_edit_key: usize,
47
48    draw: ToggleZoomed,
49}
50
51impl EditMode {
52    pub fn new_state(ctx: &mut EventCtx, app: &mut App, mode: GameplayMode) -> Box<dyn State<App>> {
53        let orig_dirty = app.primary.dirty_from_edits;
54        assert!(app.primary.suspended_sim.is_none());
55        app.primary.suspended_sim = Some(app.primary.clear_sim());
56        let layer = crate::layer::map::Static::edits(ctx, app);
57        Box::new(EditMode {
58            tool_panel: tool_panel(ctx),
59            top_center: make_topcenter(ctx, app),
60            changelist: make_changelist(ctx, app),
61            orig_edits: app.primary.map.get_edits().clone(),
62            orig_dirty,
63            mode,
64            map_edit_key: app.primary.map.get_edits_change_key(),
65            draw: layer.draw,
66        })
67    }
68
69    fn quit(&self, ctx: &mut EventCtx, app: &mut App) -> Transition {
70        let old_sim = app.primary.suspended_sim.take().unwrap();
71
72        // If nothing changed, short-circuit
73        if app.primary.map.get_edits() == &self.orig_edits {
74            app.primary.sim = old_sim;
75            app.primary.dirty_from_edits = self.orig_dirty;
76            // Could happen if we load some edits, then load whatever we entered edit mode with.
77            ctx.loading_screen("apply edits", |_, timer| {
78                app.primary.map.recalculate_pathfinding_after_edits(timer);
79            });
80            return Transition::Pop;
81        }
82
83        ctx.loading_screen("apply edits", move |ctx, timer| {
84            app.primary.map.recalculate_pathfinding_after_edits(timer);
85            if GameplayMode::FixTrafficSignals == self.mode {
86                app.primary.sim = old_sim;
87                app.primary.dirty_from_edits = true;
88                app.primary
89                    .sim
90                    .handle_live_edited_traffic_signals(&app.primary.map);
91                Transition::Pop
92            } else if app.primary.current_flags.live_map_edits {
93                app.primary.sim = old_sim;
94                app.primary.dirty_from_edits = true;
95                app.primary
96                    .sim
97                    .handle_live_edited_traffic_signals(&app.primary.map);
98                let (trips, parked_cars) =
99                    app.primary.sim.handle_live_edits(&app.primary.map, timer);
100                if trips == 0 && parked_cars == 0 {
101                    Transition::Pop
102                } else {
103                    Transition::Replace(PopupMsg::new_state(
104                        ctx,
105                        "Map changes complete",
106                        vec![
107                            format!(
108                                "Your edits interrupted {} trips and displaced {} parked cars",
109                                prettyprint_usize(trips),
110                                prettyprint_usize(parked_cars)
111                            ),
112                            "Simulation results won't be finalized unless you restart from \
113                             midnight with your changes"
114                                .to_string(),
115                        ],
116                    ))
117                }
118            } else {
119                Transition::Multi(vec![
120                    Transition::Pop,
121                    Transition::Replace(SandboxMode::async_new(
122                        app,
123                        self.mode.clone(),
124                        Box::new(move |ctx, app| {
125                            vec![Transition::Push(TimeWarpScreen::new_state(
126                                ctx,
127                                app,
128                                old_sim.time(),
129                                None,
130                            ))]
131                        }),
132                    )),
133                ])
134            }
135        })
136    }
137}
138
139impl State<App> for EditMode {
140    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
141        {
142            // We would normally use Cached, but so many values depend on one key, so this is more
143            // clear.
144            let key = app.primary.map.get_edits_change_key();
145            if self.map_edit_key != key {
146                self.map_edit_key = key;
147                self.changelist = make_changelist(ctx, app);
148                let layer = crate::layer::map::Static::edits(ctx, app);
149                self.draw = layer.draw;
150            }
151        }
152
153        if let Some(t) = CommonState::debug_actions(ctx, app) {
154            return t;
155        }
156
157        ctx.canvas_movement();
158        // Restrict what can be selected.
159        if ctx.redo_mouseover() {
160            app.primary.current_selection = app.mouseover_unzoomed_roads_and_intersections(ctx);
161            if match app.primary.current_selection {
162                Some(ID::Lane(l)) => !self.mode.can_edit_roads() || !can_edit_lane(app, l),
163                Some(ID::Intersection(i)) => {
164                    !self.mode.can_edit_stop_signs()
165                        && app.primary.map.maybe_get_stop_sign(i).is_some()
166                }
167                Some(ID::Road(_)) => false,
168                _ => true,
169            } {
170                app.primary.current_selection = None;
171            }
172        }
173
174        if app.opts.dev && ctx.input.pressed(lctrl(Key::D)) {
175            return Transition::Push(DebugMode::new_state(ctx, app));
176        }
177
178        if let Outcome::Clicked(x) = self.top_center.event(ctx) {
179            match x.as_ref() {
180                "finish editing" => {
181                    return self.quit(ctx, app);
182                }
183                "Fix sidewalk direction errors" => {
184                    let new_fixes = validate::fix_sidewalk_direction(&app.primary.map);
185                    let msg = if new_fixes.is_empty() {
186                        format!("No sidewalk direction errors found")
187                    } else {
188                        let count = new_fixes.len();
189                        let mut edits = app.primary.map.get_edits().clone();
190                        edits.commands.extend(new_fixes);
191                        apply_map_edits(ctx, app, edits);
192                        format!("Fixed {count} sidewalk directions")
193                    };
194                    return Transition::Push(PopupMsg::new_state(
195                        ctx,
196                        "Fix sidewalk directions",
197                        vec![msg],
198                    ));
199                }
200                _ => unreachable!(),
201            }
202        }
203        if let Outcome::Clicked(x) = self.changelist.event(ctx) {
204            match x.as_ref() {
205                "manage proposals" => {
206                    let mode = self.mode.clone();
207                    return Transition::Push(ChooseSomething::new_state(
208                        ctx,
209                        "Manage proposals",
210                        vec![
211                            Choice::string("rename current proposal"),
212                            Choice::string("open a saved proposal").multikey(lctrl(Key::L)),
213                            Choice::string("create a blank proposal"),
214                            Choice::string("save this proposal as..."),
215                            // TODO Disable if empty edits
216                            Choice::string("share proposal"),
217                            Choice::string("delete this proposal and remove all edits")
218                                .fg(ctx.style().text_destructive_color),
219                        ],
220                        Box::new(move |choice, ctx, app| match choice.as_ref() {
221                            "rename current proposal" => {
222                                let old_name = app.primary.map.get_edits().edits_name.clone();
223                                Transition::Replace(SaveEdits::new_state(
224                                    ctx,
225                                    app,
226                                    format!("Rename \"{}\"", old_name),
227                                    false,
228                                    Some(Transition::Pop),
229                                    Box::new(move |_, app| {
230                                        abstio::delete_file(abstio::path_edits(
231                                            app.primary.map.get_name(),
232                                            &old_name,
233                                        ));
234                                    }),
235                                ))
236                            }
237                            "open a saved proposal" => {
238                                if app.primary.map.unsaved_edits() {
239                                    Transition::Multi(vec![
240                                        Transition::Replace(LoadEdits::new_state(ctx, app, mode)),
241                                        Transition::Push(SaveEdits::new_state(
242                                            ctx,
243                                            app,
244                                            "Do you want to save your proposal first?",
245                                            true,
246                                            Some(Transition::Multi(vec![
247                                                Transition::Pop,
248                                                Transition::Pop,
249                                            ])),
250                                            Box::new(|_, _| {}),
251                                        )),
252                                    ])
253                                } else {
254                                    Transition::Replace(LoadEdits::new_state(ctx, app, mode))
255                                }
256                            }
257                            "create a blank proposal" => {
258                                if app.primary.map.unsaved_edits() {
259                                    Transition::Replace(SaveEdits::new_state(
260                                        ctx,
261                                        app,
262                                        "Do you want to save your proposal first?",
263                                        true,
264                                        Some(Transition::Pop),
265                                        Box::new(|ctx, app| {
266                                            apply_map_edits(ctx, app, app.primary.map.new_edits());
267                                        }),
268                                    ))
269                                } else {
270                                    apply_map_edits(ctx, app, app.primary.map.new_edits());
271                                    Transition::Pop
272                                }
273                            }
274                            "save this proposal as..." => {
275                                Transition::Replace(SaveEdits::new_state(
276                                    ctx,
277                                    app,
278                                    format!(
279                                        "Save \"{}\" as",
280                                        app.primary.map.get_edits().edits_name
281                                    ),
282                                    false,
283                                    Some(Transition::Pop),
284                                    Box::new(|_, _| {}),
285                                ))
286                            }
287                            "share proposal" => {
288                                // TODO This'll always set or share a URL with the map, losing any
289                                // info about the current scenario.
290                                // https://github.com/a-b-street/abstreet/issues/766
291                                Transition::Replace(crate::common::share::ShareProposal::new_state(
292                                    ctx, app, "--dev",
293                                ))
294                            }
295                            "delete this proposal and remove all edits" => {
296                                abstio::delete_file(abstio::path_edits(
297                                    app.primary.map.get_name(),
298                                    &app.primary.map.get_edits().edits_name,
299                                ));
300                                apply_map_edits(ctx, app, app.primary.map.new_edits());
301                                Transition::Pop
302                            }
303                            _ => unreachable!(),
304                        }),
305                    ));
306                }
307                "load proposal" => {}
308                "undo" => {
309                    let mut edits = app.primary.map.get_edits().clone();
310                    let maybe_id = cmd_to_id(&edits.commands.pop().unwrap());
311                    apply_map_edits(ctx, app, edits);
312                    if let Some(id) = maybe_id {
313                        return Transition::Push(Warping::new_state(
314                            ctx,
315                            app.primary.canonical_point(id.clone()).unwrap(),
316                            Some(10.0),
317                            Some(id),
318                            &mut app.primary,
319                        ));
320                    }
321                }
322                x => {
323                    let idx = x["change #".len()..].parse::<usize>().unwrap();
324                    if let Some(id) = cmd_to_id(&app.primary.map.get_edits().commands[idx - 1]) {
325                        return Transition::Push(Warping::new_state(
326                            ctx,
327                            app.primary.canonical_point(id.clone()).unwrap(),
328                            Some(10.0),
329                            Some(id),
330                            &mut app.primary,
331                        ));
332                    }
333                }
334            }
335        }
336
337        // So useful that the hotkey should work even before opening the menu
338        if ctx.input.pressed(lctrl(Key::L)) {
339            if app.primary.map.unsaved_edits() {
340                return Transition::Multi(vec![
341                    Transition::Push(LoadEdits::new_state(ctx, app, self.mode.clone())),
342                    Transition::Push(SaveEdits::new_state(
343                        ctx,
344                        app,
345                        "Do you want to save your proposal first?",
346                        true,
347                        Some(Transition::Multi(vec![Transition::Pop, Transition::Pop])),
348                        Box::new(|_, _| {}),
349                    )),
350                ]);
351            } else {
352                return Transition::Push(LoadEdits::new_state(ctx, app, self.mode.clone()));
353            }
354        }
355
356        if ctx.canvas.is_unzoomed() {
357            if let Some(id) = app.primary.current_selection.clone() {
358                if app.per_obj.left_click(ctx, "edit this") {
359                    return Transition::Push(Warping::new_state(
360                        ctx,
361                        app.primary.canonical_point(id).unwrap(),
362                        Some(10.0),
363                        None,
364                        &mut app.primary,
365                    ));
366                }
367            }
368        } else {
369            if let Some(ID::Intersection(id)) = app.primary.current_selection {
370                if let Some(state) = maybe_edit_intersection(ctx, app, id, &self.mode) {
371                    return Transition::Push(state);
372                }
373            }
374            if let Some(ID::Lane(l)) = app.primary.current_selection {
375                if app.per_obj.left_click(ctx, "edit lane") {
376                    return Transition::Push(RoadEditor::new_state(ctx, app, l));
377                }
378            }
379        }
380
381        match self.tool_panel.event(ctx) {
382            Outcome::Clicked(x) => match x.as_ref() {
383                "back" => self.quit(ctx, app),
384                "settings" => Transition::Push(OptionsPanel::new_state(ctx, app)),
385                _ => unreachable!(),
386            },
387            _ => Transition::Keep,
388        }
389    }
390
391    fn draw(&self, g: &mut GfxCtx, app: &App) {
392        self.tool_panel.draw(g);
393        self.top_center.draw(g);
394        self.changelist.draw(g);
395        self.draw.draw(g);
396        CommonState::draw_osd(g, app);
397    }
398}
399
400pub struct SaveEdits {
401    panel: Panel,
402    current_name: String,
403    cancel: Option<Transition>,
404    on_success: Box<dyn Fn(&mut EventCtx, &mut App)>,
405    reset: bool,
406}
407
408impl SaveEdits {
409    pub fn new_state<I: Into<String>>(
410        ctx: &mut EventCtx,
411        app: &App,
412        title: I,
413        discard: bool,
414        cancel: Option<Transition>,
415        on_success: Box<dyn Fn(&mut EventCtx, &mut App)>,
416    ) -> Box<dyn State<App>> {
417        let initial_name = if app.primary.map.unsaved_edits() {
418            String::new()
419        } else {
420            format!("copy of {}", app.primary.map.get_edits().edits_name)
421        };
422        let mut save = SaveEdits {
423            current_name: initial_name.clone(),
424            panel: Panel::new_builder(Widget::col(vec![
425                Line(title).small_heading().into_widget(ctx),
426                Widget::row(vec![
427                    "Name:".text_widget(ctx).centered_vert(),
428                    TextBox::default_widget(ctx, "filename", initial_name),
429                ]),
430                // TODO Want this to always consistently be one line high, but it isn't for a blank
431                // line
432                Widget::placeholder(ctx, "warning"),
433                Widget::row(vec![
434                    if discard {
435                        ctx.style()
436                            .btn_solid_destructive
437                            .text("Discard proposal")
438                            .build_def(ctx)
439                    } else {
440                        Widget::nothing()
441                    },
442                    if cancel.is_some() {
443                        ctx.style()
444                            .btn_outline
445                            .text("Cancel")
446                            .hotkey(Key::Escape)
447                            .build_def(ctx)
448                    } else {
449                        Widget::nothing()
450                    },
451                    ctx.style()
452                        .btn_solid_primary
453                        .text("Save")
454                        .disabled(true)
455                        .build_def(ctx),
456                ])
457                .align_right(),
458            ]))
459            .build(ctx),
460            cancel,
461            on_success,
462            reset: discard,
463        };
464        save.recalc_btn(ctx, app);
465        Box::new(save)
466    }
467
468    fn recalc_btn(&mut self, ctx: &mut EventCtx, app: &App) {
469        if self.current_name.is_empty() {
470            self.panel.replace(
471                ctx,
472                "Save",
473                ctx.style()
474                    .btn_solid_primary
475                    .text("Save")
476                    .disabled(true)
477                    .build_def(ctx),
478            );
479            self.panel
480                .replace(ctx, "warning", Text::new().into_widget(ctx));
481        } else if abstio::file_exists(abstio::path_edits(
482            app.primary.map.get_name(),
483            &self.current_name,
484        )) {
485            self.panel.replace(
486                ctx,
487                "Save",
488                ctx.style()
489                    .btn_solid_primary
490                    .text("Save")
491                    .disabled(true)
492                    .build_def(ctx),
493            );
494            self.panel.replace(
495                ctx,
496                "warning",
497                Line("A proposal with this name already exists")
498                    .fg(Color::hex("#FF5E5E"))
499                    .into_widget(ctx),
500            );
501        } else {
502            self.panel.replace(
503                ctx,
504                "Save",
505                ctx.style()
506                    .btn_solid_primary
507                    .text("Save")
508                    .hotkey(Key::Enter)
509                    .build_def(ctx),
510            );
511            self.panel
512                .replace(ctx, "warning", Text::new().into_widget(ctx));
513        }
514    }
515}
516
517impl State<App> for SaveEdits {
518    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
519        if let Outcome::Clicked(x) = self.panel.event(ctx) {
520            match x.as_ref() {
521                "Save" => {
522                    let mut edits = app.primary.map.get_edits().clone();
523                    edits.edits_name = self.current_name.clone();
524                    app.primary
525                        .map
526                        .must_apply_edits(edits, &mut Timer::throwaway());
527                    app.primary.map.save_edits();
528                    if self.reset {
529                        apply_map_edits(ctx, app, app.primary.map.new_edits());
530                    }
531                    (self.on_success)(ctx, app);
532                    return Transition::Pop;
533                }
534                "Discard proposal" => {
535                    apply_map_edits(ctx, app, app.primary.map.new_edits());
536                    return Transition::Pop;
537                }
538                "Cancel" => {
539                    return self.cancel.take().unwrap();
540                }
541                _ => unreachable!(),
542            }
543        }
544        let name = self.panel.text_box("filename");
545        if name != self.current_name {
546            self.current_name = name;
547            self.recalc_btn(ctx, app);
548        }
549
550        Transition::Keep
551    }
552
553    fn draw(&self, g: &mut GfxCtx, app: &App) {
554        grey_out_map(g, app);
555        self.panel.draw(g);
556    }
557}
558
559pub struct LoadEdits {
560    panel: Panel,
561    mode: GameplayMode,
562}
563
564impl LoadEdits {
565    /// Mode is just used for `allows`.
566    pub fn new_state(ctx: &mut EventCtx, app: &App, mode: GameplayMode) -> Box<dyn State<App>> {
567        let current_edits_name = &app.primary.map.get_edits().edits_name;
568
569        let mut your_proposals =
570            abstio::list_all_objects(abstio::path_all_edits(app.primary.map.get_name()))
571                .into_iter()
572                .map(|name| Choice::new(name.clone(), ()).active(&name != current_edits_name))
573                .collect::<Vec<_>>();
574        // These're sorted alphabetically, but the "Untitled Proposal"s wind up before lowercase
575        // names!
576        your_proposals.sort_by_key(|x| (x.label.starts_with("Untitled Proposal"), x.label.clone()));
577
578        let your_edits = vec![
579            Line("Your proposals").small_heading().into_widget(ctx),
580            Menu::widget(ctx, your_proposals),
581        ];
582        // widgetry can't toggle keyboard focus between two menus, so just use buttons for the less
583        // common use case.
584        let mut proposals = vec![Line("Community proposals").small_heading().into_widget(ctx)];
585        // Up-front filter out proposals that definitely don't fit the current map
586        for name in abstio::list_all_objects(abstio::path("system/proposals")) {
587            let path = abstio::path(format!("system/proposals/{}.json", name));
588            if let Ok(edits) =
589                MapEdits::load_from_file(&app.primary.map, path.clone(), &mut Timer::throwaway())
590            {
591                proposals.push(
592                    ctx.style()
593                        .btn_outline
594                        .text(edits.get_title())
595                        .build_widget(ctx, &path),
596                );
597            }
598        }
599
600        Box::new(LoadEdits {
601            mode,
602            panel: Panel::new_builder(Widget::col(vec![
603                Widget::row(vec![
604                    Line("Load proposal").small_heading().into_widget(ctx),
605                    ctx.style().btn_close_widget(ctx),
606                ]),
607                ctx.style()
608                    .btn_outline
609                    .text("Start over with blank proposal")
610                    .build_def(ctx),
611                Widget::row(vec![Widget::col(your_edits), Widget::col(proposals)]).evenly_spaced(),
612            ]))
613            .exact_size_percent(50, 50)
614            .build(ctx),
615        })
616    }
617}
618
619impl State<App> for LoadEdits {
620    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
621        match self.panel.event(ctx) {
622            Outcome::Clicked(x) => {
623                match x.as_ref() {
624                    "close" => Transition::Pop,
625                    "Start over with blank proposal" => {
626                        apply_map_edits(ctx, app, app.primary.map.new_edits());
627                        Transition::Pop
628                    }
629                    path => {
630                        // TODO Kind of a hack. If it ends with .json, it's already a path.
631                        // Otherwise it's a result from the menu.
632                        let path = if path.ends_with(".json") {
633                            path.to_string()
634                        } else {
635                            abstio::path_edits(app.primary.map.get_name(), path)
636                        };
637
638                        match MapEdits::load_from_file(
639                            &app.primary.map,
640                            path.clone(),
641                            &mut Timer::throwaway(),
642                        )
643                        .and_then(|edits| {
644                            if self.mode.allows(&edits) {
645                                Ok(edits)
646                            } else {
647                                Err(anyhow!(
648                                    "The current gameplay mode restricts edits. This proposal has \
649                                     a banned command."
650                                ))
651                            }
652                        }) {
653                            Ok(edits) => {
654                                apply_map_edits(ctx, app, edits);
655                                app.primary
656                                    .sim
657                                    .handle_live_edited_traffic_signals(&app.primary.map);
658                                Transition::Pop
659                            }
660                            // TODO Hack. Have to replace ourselves, because the Menu might be
661                            // invalidated now that something was chosen.
662                            Err(err) => {
663                                println!("Can't load {}: {}", path, err);
664                                Transition::Multi(vec![
665                                    Transition::Replace(LoadEdits::new_state(
666                                        ctx,
667                                        app,
668                                        self.mode.clone(),
669                                    )),
670                                    // TODO Menu draws at a weird Z-order to deal with tooltips, so
671                                    // now the menu underneath
672                                    // bleeds through
673                                    Transition::Push(PopupMsg::new_state(
674                                        ctx,
675                                        "Error",
676                                        vec![format!("Can't load {}", path), err.to_string()],
677                                    )),
678                                ])
679                            }
680                        }
681                    }
682                }
683            }
684            _ => Transition::Keep,
685        }
686    }
687
688    fn draw(&self, g: &mut GfxCtx, app: &App) {
689        grey_out_map(g, app);
690        self.panel.draw(g);
691    }
692}
693
694fn make_topcenter(ctx: &mut EventCtx, app: &App) -> Panel {
695    Panel::new_builder(Widget::col(vec![
696        Line("Editing map")
697            .small_heading()
698            .into_widget(ctx)
699            .centered_horiz(),
700        ctx.style()
701            .btn_solid_primary
702            .text(format!(
703                "Finish & resume from {}",
704                app.primary
705                    .suspended_sim
706                    .as_ref()
707                    .unwrap()
708                    .time()
709                    .ampm_tostring()
710            ))
711            .hotkey(Key::Escape)
712            .build_widget(ctx, "finish editing"),
713        if app.opts.dev {
714            ctx.style()
715                .btn_outline
716                .text("Fix sidewalk direction errors")
717                .tooltip(Text::from_multiline(vec![
718                    Line("Sidewalk directions must match the side of the road in a certain way."),
719                    Line("It's easy to get this wrong; this tool will automatically fix things."),
720                ]))
721                .build_def(ctx)
722        } else {
723            Widget::nothing()
724        },
725    ]))
726    .aligned(HorizontalAlignment::Center, VerticalAlignment::Top)
727    .build(ctx)
728}
729
730pub fn apply_map_edits(ctx: &mut EventCtx, app: &mut App, edits: MapEdits) {
731    ctx.loading_screen("apply map edits", |ctx, timer| {
732        if !app.store_unedited_map_in_secondary && app.primary.unedited_map.is_none() {
733            timer.start("save unedited map");
734            assert!(app.primary.map.get_edits().commands.is_empty());
735            app.primary.unedited_map = Some(app.primary.map.clone());
736            timer.stop("save unedited map");
737        }
738        if app.store_unedited_map_in_secondary && app.secondary.is_none() {
739            timer.start("save unedited map for toggling");
740            assert!(app.primary.map.get_edits().commands.is_empty());
741            let mut per_map = crate::app::PerMap::map_loaded(
742                app.primary.map.clone(),
743                app.primary.sim.clone(),
744                app.primary.current_flags.clone(),
745                &app.opts,
746                &app.cs,
747                ctx,
748                timer,
749            );
750            // is_secondary indicates the unedited map
751            per_map.is_secondary = true;
752            app.secondary = Some(per_map);
753            timer.stop("save unedited map for toggling");
754        }
755
756        timer.start("edit map");
757        let effects = app.primary.map.must_apply_edits(edits, timer);
758        timer.stop("edit map");
759
760        if !effects.changed_roads.is_empty() || !effects.changed_intersections.is_empty() {
761            app.primary
762                .draw_map
763                .draw_all_unzoomed_roads_and_intersections = DrawMap::regenerate_unzoomed_layer(
764                ctx,
765                &app.primary.map,
766                &app.cs,
767                &app.opts,
768                timer,
769            );
770        }
771
772        for r in effects.changed_roads {
773            let road = app.primary.map.get_r(r);
774            app.primary.draw_map.recreate_road(road, &app.primary.map);
775        }
776
777        for i in effects.changed_intersections {
778            app.primary
779                .draw_map
780                .recreate_intersection(i, &app.primary.map);
781        }
782
783        for pl in effects.changed_parking_lots {
784            app.primary.draw_map.get_pl(pl).clear_rendering();
785        }
786
787        if app.primary.layer.as_ref().and_then(|l| l.name()) == Some("map edits") {
788            app.primary.layer = Some(Box::new(crate::layer::map::Static::edits(ctx, app)));
789        }
790        // Other parts of the UI poll map.get_edits_change_key() to recalculate things based on
791        // edits.
792
793        // Autosave
794        app.primary.map.save_edits();
795    });
796}
797
798pub fn can_edit_lane(app: &App, l: LaneID) -> bool {
799    let map = &app.primary.map;
800    let lane = map.get_l(l);
801    if lane.is_light_rail() || lane.is_footway() {
802        return false;
803    }
804    let r = map.get_parent(l);
805    // Some bus-only roads are marked as service roads; allow editing those.
806    if r.is_service() && r.lanes.iter().all(|l| !l.is_bus()) {
807        return false;
808    }
809
810    true
811}
812
813pub fn speed_limit_choices(app: &App, preset: Option<Speed>) -> Vec<Choice<Speed>> {
814    // Don't need anything higher than 70mph. Though now I kind of miss 3am drives on TX-71...
815    let mut speeds = (10..=70)
816        .step_by(5)
817        .map(|mph| Speed::miles_per_hour(mph as f64))
818        .collect::<Vec<_>>();
819    if let Some(preset) = preset {
820        if !speeds.contains(&preset) {
821            speeds.push(preset);
822            speeds.sort();
823        }
824    }
825    speeds
826        .into_iter()
827        .map(|x| Choice::new(x.to_string(&app.opts.units), x))
828        .collect()
829}
830
831pub fn maybe_edit_intersection(
832    ctx: &mut EventCtx,
833    app: &mut App,
834    id: IntersectionID,
835    mode: &GameplayMode,
836) -> Option<Box<dyn State<App>>> {
837    if app.primary.map.maybe_get_stop_sign(id).is_some()
838        && mode.can_edit_stop_signs()
839        && app.per_obj.left_click(ctx, "edit stop signs")
840    {
841        return Some(StopSignEditor::new_state(ctx, app, id, mode.clone()));
842    }
843
844    if app.primary.map.maybe_get_traffic_signal(id).is_some()
845        && app.per_obj.left_click(ctx, "edit traffic signal")
846    {
847        return Some(TrafficSignalEditor::new_state(
848            ctx,
849            app,
850            btreeset! {id},
851            mode.clone(),
852        ));
853    }
854
855    if app.primary.map.get_i(id).is_closed()
856        && app.per_obj.left_click(ctx, "re-open closed intersection")
857    {
858        // This resets to the original state; it doesn't undo the closure to the last
859        // state. Seems reasonable to me.
860        let mut edits = app.primary.map.get_edits().clone();
861        edits
862            .commands
863            .push(app.primary.map.edit_intersection_cmd(id, |new| {
864                new.control = edits.original_intersections[&id].control.clone();
865            }));
866        apply_map_edits(ctx, app, edits);
867    }
868
869    None
870}
871
872fn make_changelist(ctx: &mut EventCtx, app: &App) -> Panel {
873    // TODO Support redo. Bit harder here to reset the redo_stack when the edits
874    // change, because nested other places modify it too.
875    let edits = app.primary.map.get_edits();
876    let mut col = vec![
877        Widget::row(vec![
878            ctx.style()
879                .btn_outline
880                .popup(&edits.edits_name)
881                .hotkey(lctrl(Key::P))
882                .build_widget(ctx, "manage proposals"),
883            "autosaved"
884                .text_widget(ctx)
885                .container()
886                .padding(10)
887                .bg(Color::hex("#5D9630")),
888        ]),
889        ColorLegend::row(
890            ctx,
891            app.cs.edits_layer,
892            format!(
893                "{} roads, {} intersections changed",
894                edits.original_roads.len(),
895                edits.original_intersections.len()
896            ),
897        ),
898    ];
899
900    if edits.commands.len() > 5 {
901        col.push(format!("{} more...", edits.commands.len() - 5).text_widget(ctx));
902    }
903    for idx in edits.commands.len().max(5) - 5..edits.commands.len() {
904        let (summary, details) = edits.commands[idx].describe(&app.primary.map);
905        let mut txt = Text::from(format!("{}) {}", idx + 1, summary));
906        for line in details {
907            txt.add_line(Line(line).secondary());
908        }
909        let btn = ctx
910            .style()
911            .btn_plain
912            .btn()
913            .label_styled_text(txt, ControlState::Default)
914            .build_widget(ctx, format!("change #{}", idx + 1));
915        if idx == edits.commands.len() - 1 {
916            col.push(
917                Widget::row(vec![
918                    btn,
919                    ctx.style()
920                        .btn_close()
921                        .hotkey(lctrl(Key::Z))
922                        .build_widget(ctx, "undo"),
923                ])
924                .padding(16)
925                .outline(ctx.style().btn_outline.outline),
926            );
927        } else {
928            col.push(btn);
929        }
930    }
931
932    Panel::new_builder(Widget::col(col))
933        .aligned(HorizontalAlignment::Right, VerticalAlignment::Center)
934        .build(ctx)
935}
936
937// TODO Ideally a Tab.
938fn cmd_to_id(cmd: &EditCmd) -> Option<ID> {
939    match cmd {
940        EditCmd::ChangeRoad { r, .. } => Some(ID::Road(*r)),
941        EditCmd::ChangeIntersection { i, .. } => Some(ID::Intersection(*i)),
942        EditCmd::ChangeRouteSchedule { .. } => None,
943    }
944}
945
946pub struct ConfirmDiscard {
947    panel: Panel,
948    discard: Box<dyn Fn(&mut App)>,
949}
950
951impl ConfirmDiscard {
952    pub fn new_state(ctx: &mut EventCtx, discard: Box<dyn Fn(&mut App)>) -> Box<dyn State<App>> {
953        Box::new(ConfirmDiscard {
954            discard,
955            panel: Panel::new_builder(Widget::col(vec![
956                Widget::row(vec![
957                    Image::from_path("system/assets/tools/alert.svg")
958                        .untinted()
959                        .into_widget(ctx)
960                        .container()
961                        .padding_top(6),
962                    Line("Alert").small_heading().into_widget(ctx),
963                    ctx.style().btn_close_widget(ctx),
964                ]),
965                "Are you sure you want to discard changes you made?".text_widget(ctx),
966                Widget::row(vec![
967                    ctx.style()
968                        .btn_outline
969                        .text("Cancel")
970                        .hotkey(Key::Escape)
971                        .build_def(ctx),
972                    ctx.style()
973                        .btn_solid_destructive
974                        .text("Yes, discard")
975                        .build_def(ctx),
976                ])
977                .align_right(),
978            ]))
979            .build(ctx),
980        })
981    }
982}
983
984impl State<App> for ConfirmDiscard {
985    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
986        match self.panel.event(ctx) {
987            Outcome::Clicked(x) => match x.as_ref() {
988                "close" | "Cancel" => Transition::Pop,
989                "Yes, discard" => {
990                    (self.discard)(app);
991                    Transition::Multi(vec![Transition::Pop, Transition::Pop])
992                }
993                _ => unreachable!(),
994            },
995            _ => Transition::Keep,
996        }
997    }
998
999    fn draw(&self, g: &mut GfxCtx, _: &App) {
1000        self.panel.draw(g);
1001    }
1002}