Skip to content

Commit 2345acb

Browse files
authored
New features in plan UI for enhanced usability (#278)
* bug fix: handle loading waypoints with minutes non-multiples of 5 * add +/- 1 day/hour/30 minutes buttons * add remove waypoint button with an "are you sure" check * add reset changes button to waypoints editor
1 parent 323b357 commit 2345acb

1 file changed

Lines changed: 212 additions & 13 deletions

File tree

src/virtualship/cli/_plan.py

Lines changed: 212 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import datetime
23
import os
34
import traceback
@@ -6,7 +7,7 @@
67
from textual.app import App, ComposeResult
78
from textual.containers import Container, Horizontal, VerticalScroll
89
from textual.dom import NoMatches
9-
from textual.screen import Screen
10+
from textual.screen import ModalScreen, Screen
1011
from textual.validation import Function, Integer
1112
from textual.widgets import (
1213
Button,
@@ -153,15 +154,49 @@ def log_exception_to_file(
153154
}
154155

155156

157+
class WaypointRemoveConfirmScreen(ModalScreen):
158+
"""Modal confirmation dialog for waypoint removal."""
159+
160+
def __init__(self, waypoint_index: int):
161+
super().__init__()
162+
self.waypoint_index = waypoint_index
163+
164+
def compose(self) -> ComposeResult:
165+
yield Container(
166+
Label(
167+
f"Are you sure you want to remove waypoint {self.waypoint_index + 1}?",
168+
id="confirm-label",
169+
),
170+
Horizontal(
171+
Button("Yes", id="confirm-yes", variant="error"),
172+
Button("No", id="confirm-no", variant="primary"),
173+
id="confirm-buttons",
174+
),
175+
id="confirm-container",
176+
classes="confirm-modal",
177+
)
178+
179+
@on(Button.Pressed, "#confirm-yes")
180+
def confirm_yes(self) -> None:
181+
self.dismiss(True)
182+
183+
@on(Button.Pressed, "#confirm-no")
184+
def confirm_no(self) -> None:
185+
self.dismiss(False)
186+
187+
156188
class ExpeditionEditor(Static):
157189
def __init__(self, path: str):
158190
super().__init__()
159191
self.path = path
160192
self.expedition = None
193+
self._pending_remove_idx = None
194+
self._original_schedule = None # Store original schedule
161195

162196
def compose(self) -> ComposeResult:
163197
try:
164198
self.expedition = Expedition.from_yaml(self.path.joinpath(EXPEDITION))
199+
self._original_schedule = copy.deepcopy(self.expedition.schedule)
165200
except Exception as e:
166201
raise UserError(
167202
f"There is an issue in {self.path.joinpath(EXPEDITION)}:\n\n{e}"
@@ -333,6 +368,11 @@ def compose(self) -> ComposeResult:
333368
id="remove_waypoint",
334369
variant="error",
335370
),
371+
Button(
372+
"Reset changes (all waypoints)",
373+
id="reset_changes",
374+
variant="warning",
375+
),
336376
)
337377

338378
yield VerticalScroll(id="waypoint_list", classes="waypoint-list")
@@ -525,14 +565,45 @@ def remove_waypoint(self) -> None:
525565
except Exception as e:
526566
raise UnexpectedError(unexpected_msg_compose(e)) from None
527567

528-
@on(Button.Pressed, "#info_button")
529-
def info_pressed(self) -> None:
530-
self.notify(
531-
"[b]SeaSeven[/b]:\nShallow ADCP profiler capable of providing information to a depth of 150 m every 4 meters (300kHz)"
532-
"\n\n[b]OceanObserver[/b]:\nLong-range ADCP profiler capable of providing ~ 1000m of range every 24 meters (38kHz)",
533-
severity="warning",
534-
timeout=20,
535-
)
568+
@on(Button.Pressed, "#reset_changes")
569+
def reset_changes(self) -> None:
570+
"""Reset all changes to the schedule, reverting to the original loaded schedule."""
571+
try:
572+
self.expedition.schedule = copy.deepcopy(self._original_schedule)
573+
self.refresh_waypoint_widgets()
574+
575+
except Exception as e:
576+
raise UnexpectedError(unexpected_msg_compose(e)) from None
577+
578+
@on(Button.Pressed)
579+
def remove_specific_waypoint(self, event: Button.Pressed) -> None:
580+
"""Ask for confirmation before removing a specific waypoint."""
581+
btn_id = event.button.id
582+
if btn_id and btn_id.startswith("wp") and btn_id.endswith("_remove"):
583+
try:
584+
idx_str = btn_id[2:-7]
585+
idx = int(idx_str)
586+
if 0 <= idx < len(self.expedition.schedule.waypoints):
587+
self._pending_remove_idx = idx
588+
self.app.push_screen(
589+
WaypointRemoveConfirmScreen(idx), self._on_remove_confirmed
590+
)
591+
else:
592+
self.notify("Invalid waypoint index.", severity="error", timeout=20)
593+
except Exception as e:
594+
raise UnexpectedError(unexpected_msg_compose(e)) from None
595+
596+
def _on_remove_confirmed(self, confirmed: bool) -> None:
597+
"""Callback after confirmation dialog."""
598+
if confirmed and self._pending_remove_idx is not None:
599+
try:
600+
idx = self._pending_remove_idx
601+
if 0 <= idx < len(self.expedition.schedule.waypoints):
602+
self.expedition.schedule.waypoints.pop(idx)
603+
self.refresh_waypoint_widgets()
604+
except Exception as e:
605+
raise UnexpectedError(unexpected_msg_compose(e)) from None
606+
self._pending_remove_idx = None
536607

537608
def show_hide_adcp_type(self, show: bool) -> None:
538609
container = self.query_one("#adcp_type_container")
@@ -700,16 +771,52 @@ def compose(self) -> ComposeResult:
700771
classes="hour-select",
701772
)
702773
yield Label("Min:")
774+
minute_options = [(f"{m:02d}", m) for m in range(0, 60, 5)]
775+
minute_value = (
776+
int(self.waypoint.time.minute)
777+
if self.waypoint.time
778+
else Select.BLANK
779+
)
780+
781+
# if the current minute is not a multiple of 5, add it to the options
782+
if (
783+
self.waypoint.time
784+
and self.waypoint.time.minute % 5 != 0
785+
and (
786+
f"{self.waypoint.time.minute:02d}",
787+
self.waypoint.time.minute,
788+
)
789+
not in minute_options
790+
):
791+
minute_options = [
792+
(
793+
f"{self.waypoint.time.minute:02d}",
794+
self.waypoint.time.minute,
795+
)
796+
] + minute_options
797+
798+
minute_options = sorted(minute_options, key=lambda x: x[1])
799+
703800
yield Select(
704-
[(f"{m:02d}", m) for m in range(0, 60, 5)],
801+
minute_options,
705802
id=f"wp{self.index}_minute",
706-
value=int(self.waypoint.time.minute)
707-
if self.waypoint.time
708-
else Select.BLANK,
803+
value=minute_value,
709804
prompt="mm",
710805
classes="minute-select",
711806
)
712807

808+
# fmt: off
809+
yield Horizontal(
810+
Button("+1 day", id="plus_one_day", variant="primary"),
811+
Button("+1 hour", id="plus_one_hour", variant="primary"),
812+
Button("+30 minutes", id="plus_thirty_minutes", variant="primary"),
813+
Button("-1 day", id="minus_one_day", variant="default"),
814+
Button("-1 hour", id="minus_one_hour", variant="default"),
815+
Button("-30 minutes", id="minus_thirty_minutes", variant="default"),
816+
classes="time-adjust-buttons",
817+
)
818+
# fmt: on
819+
713820
yield Label("Instruments:")
714821
for instrument in [i for i in InstrumentType if not i.is_underway]:
715822
is_selected = instrument in (self.waypoint.instrument or [])
@@ -740,6 +847,12 @@ def compose(self) -> ComposeResult:
740847
classes="-hidden validation-failure",
741848
)
742849

850+
yield Horizontal(
851+
Button(
852+
"Remove Waypoint", id=f"wp{self.index}_remove", variant="error"
853+
)
854+
)
855+
743856
except Exception as e:
744857
raise UnexpectedError(unexpected_msg_compose(e)) from None
745858

@@ -792,6 +905,59 @@ def on_switch_changed(self, event: Switch.Changed) -> None:
792905
if not drifter_count_input.value:
793906
drifter_count_input.value = "1"
794907

908+
# fmt: off
909+
def update_time(self) -> None:
910+
"""Update the time selects to match the current waypoint time."""
911+
self.query_one(f"#wp{self.index}_year", Select).value = self.waypoint.time.year
912+
self.query_one(f"#wp{self.index}_month", Select).value = self.waypoint.time.month
913+
self.query_one(f"#wp{self.index}_day", Select).value = self.waypoint.time.day
914+
self.query_one(f"#wp{self.index}_hour", Select).value = self.waypoint.time.hour
915+
self.query_one(f"#wp{self.index}_minute", Select).value = self.waypoint.time.minute
916+
# fmt: on
917+
918+
def round_minutes(self) -> None:
919+
"""Round the waypoint time minutes to the nearest 5 minutes, for compatability with UI selection fields."""
920+
if self.waypoint.time:
921+
minute = self.waypoint.time.minute
922+
if minute % 5 == 0:
923+
return
924+
else:
925+
rounded_minute = 5 * round(minute / 5)
926+
if rounded_minute == 60: # increment hour
927+
self.waypoint.time += datetime.timedelta(hours=1)
928+
rounded_minute = 0
929+
self.waypoint.time = self.waypoint.time.replace(minute=rounded_minute)
930+
931+
@on(Button.Pressed)
932+
def time_adjust_buttons(self, event: Button.Pressed) -> None:
933+
if self.waypoint.time:
934+
if event.button.id == "plus_one_day":
935+
self.waypoint.time += datetime.timedelta(days=1)
936+
self.update_time()
937+
if event.button.id == "plus_one_hour":
938+
self.waypoint.time += datetime.timedelta(hours=1)
939+
self.update_time()
940+
elif event.button.id == "plus_thirty_minutes":
941+
self.waypoint.time += datetime.timedelta(minutes=30)
942+
self.round_minutes()
943+
self.update_time()
944+
elif event.button.id == "minus_one_day":
945+
self.waypoint.time -= datetime.timedelta(days=1)
946+
self.update_time()
947+
elif event.button.id == "minus_one_hour":
948+
self.waypoint.time -= datetime.timedelta(hours=1)
949+
self.update_time()
950+
elif event.button.id == "minus_thirty_minutes":
951+
self.waypoint.time -= datetime.timedelta(minutes=30)
952+
self.round_minutes()
953+
self.update_time()
954+
else:
955+
self.notify(
956+
"Cannot adjust time: Time is not set for this waypoint.",
957+
severity="error",
958+
timeout=20,
959+
)
960+
795961

796962
class PlanScreen(Screen):
797963
def __init__(self, path: str):
@@ -1075,6 +1241,39 @@ class PlanApp(App):
10751241
Label.validation-failure {
10761242
color: $error;
10771243
}
1244+
1245+
.time-adjust-buttons {
1246+
margin-left: 5;
1247+
1248+
1249+
}
1250+
1251+
.confirm-modal {
1252+
align: center middle;
1253+
width: 50;
1254+
min-height: 9;
1255+
border: round $primary;
1256+
background: $panel;
1257+
padding: 2 4;
1258+
content-align: center middle;
1259+
margin: 2 4;
1260+
layout: vertical;
1261+
}
1262+
1263+
#confirm-label {
1264+
content-align: center middle;
1265+
text-align: center;
1266+
width: 100%;
1267+
margin-bottom: 2;
1268+
}
1269+
1270+
#confirm-buttons {
1271+
align: center middle;
1272+
width: 100%;
1273+
margin-top: 1;
1274+
content-align: center middle;
1275+
layout: horizontal;
1276+
}
10781277
"""
10791278

10801279
def __init__(self, path: str):

0 commit comments

Comments
 (0)