From 08861cfa79af402ea4ba85b147cc218b54bc5c22 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:37:03 +0100 Subject: [PATCH 1/4] bug fix: handle loading waypoints with minutes non-multiples of 5 --- src/virtualship/cli/_plan.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index 81c8f857..a3f12fbb 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -700,12 +700,36 @@ def compose(self) -> ComposeResult: classes="hour-select", ) yield Label("Min:") + minute_options = [(f"{m:02d}", m) for m in range(0, 60, 5)] + minute_value = ( + int(self.waypoint.time.minute) + if self.waypoint.time + else Select.BLANK + ) + + # if the current minute is not a multiple of 5, add it to the options + if ( + self.waypoint.time + and self.waypoint.time.minute % 5 != 0 + and ( + f"{self.waypoint.time.minute:02d}", + self.waypoint.time.minute, + ) + not in minute_options + ): + minute_options = [ + ( + f"{self.waypoint.time.minute:02d}", + self.waypoint.time.minute, + ) + ] + minute_options + + minute_options = sorted(minute_options, key=lambda x: x[1]) + yield Select( - [(f"{m:02d}", m) for m in range(0, 60, 5)], + minute_options, id=f"wp{self.index}_minute", - value=int(self.waypoint.time.minute) - if self.waypoint.time - else Select.BLANK, + value=minute_value, prompt="mm", classes="minute-select", ) From c10e50e5a2300d0fb1b6edd502da05417ed88cf0 Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:44:44 +0100 Subject: [PATCH 2/4] add +/- 1 day/hour/30 minutes buttons --- src/virtualship/cli/_plan.py | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index a3f12fbb..642433dd 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -734,6 +734,18 @@ def compose(self) -> ComposeResult: classes="minute-select", ) + # fmt: off + yield Horizontal( + Button("+1 day", id="plus_one_day", variant="primary"), + Button("+1 hour", id="plus_one_hour", variant="primary"), + Button("+30 minutes", id="plus_thirty_minutes", variant="primary"), + Button("-1 day", id="minus_one_day", variant="default"), + Button("-1 hour", id="minus_one_hour", variant="default"), + Button("-30 minutes", id="minus_thirty_minutes", variant="default"), + classes="time-adjust-buttons", + ) + # fmt: on + yield Label("Instruments:") for instrument in [i for i in InstrumentType if not i.is_underway]: is_selected = instrument in (self.waypoint.instrument or []) @@ -816,6 +828,59 @@ def on_switch_changed(self, event: Switch.Changed) -> None: if not drifter_count_input.value: drifter_count_input.value = "1" + # fmt: off + def update_time(self) -> None: + """Update the time selects to match the current waypoint time.""" + self.query_one(f"#wp{self.index}_year", Select).value = self.waypoint.time.year + self.query_one(f"#wp{self.index}_month", Select).value = self.waypoint.time.month + self.query_one(f"#wp{self.index}_day", Select).value = self.waypoint.time.day + self.query_one(f"#wp{self.index}_hour", Select).value = self.waypoint.time.hour + self.query_one(f"#wp{self.index}_minute", Select).value = self.waypoint.time.minute + # fmt: on + + def round_minutes(self) -> None: + """Round the waypoint time minutes to the nearest 5 minutes, for compatability with UI selection fields.""" + if self.waypoint.time: + minute = self.waypoint.time.minute + if minute % 5 == 0: + return + else: + rounded_minute = 5 * round(minute / 5) + if rounded_minute == 60: # increment hour + self.waypoint.time += datetime.timedelta(hours=1) + rounded_minute = 0 + self.waypoint.time = self.waypoint.time.replace(minute=rounded_minute) + + @on(Button.Pressed) + def time_adjust_buttons(self, event: Button.Pressed) -> None: + if self.waypoint.time: + if event.button.id == "plus_one_day": + self.waypoint.time += datetime.timedelta(days=1) + self.update_time() + if event.button.id == "plus_one_hour": + self.waypoint.time += datetime.timedelta(hours=1) + self.update_time() + elif event.button.id == "plus_thirty_minutes": + self.waypoint.time += datetime.timedelta(minutes=30) + self.round_minutes() + self.update_time() + elif event.button.id == "minus_one_day": + self.waypoint.time -= datetime.timedelta(days=1) + self.update_time() + elif event.button.id == "minus_one_hour": + self.waypoint.time -= datetime.timedelta(hours=1) + self.update_time() + elif event.button.id == "minus_thirty_minutes": + self.waypoint.time -= datetime.timedelta(minutes=30) + self.round_minutes() + self.update_time() + else: + self.notify( + "Cannot adjust time: Time is not set for this waypoint.", + severity="error", + timeout=20, + ) + class PlanScreen(Screen): def __init__(self, path: str): @@ -1099,6 +1164,12 @@ class PlanApp(App): Label.validation-failure { color: $error; } + + .time-adjust-buttons { + margin-left: 5; + + + } """ def __init__(self, path: str): From fd79a08cd3f44e5c4861824142b2045ca1fe335f Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:06:14 +0100 Subject: [PATCH 3/4] add remove waypoint button with an "are you sure" check --- src/virtualship/cli/_plan.py | 105 ++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index 642433dd..16feb0bc 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -6,7 +6,7 @@ from textual.app import App, ComposeResult from textual.containers import Container, Horizontal, VerticalScroll from textual.dom import NoMatches -from textual.screen import Screen +from textual.screen import ModalScreen, Screen from textual.validation import Function, Integer from textual.widgets import ( Button, @@ -153,11 +153,43 @@ def log_exception_to_file( } +class WaypointRemoveConfirmScreen(ModalScreen): + """Modal confirmation dialog for waypoint removal.""" + + def __init__(self, waypoint_index: int): + super().__init__() + self.waypoint_index = waypoint_index + + def compose(self) -> ComposeResult: + yield Container( + Label( + f"Are you sure you want to remove waypoint {self.waypoint_index + 1}?", + id="confirm-label", + ), + Horizontal( + Button("Yes", id="confirm-yes", variant="error"), + Button("No", id="confirm-no", variant="primary"), + id="confirm-buttons", + ), + id="confirm-container", + classes="confirm-modal", + ) + + @on(Button.Pressed, "#confirm-yes") + def confirm_yes(self) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#confirm-no") + def confirm_no(self) -> None: + self.dismiss(False) + + class ExpeditionEditor(Static): def __init__(self, path: str): super().__init__() self.path = path self.expedition = None + self._pending_remove_idx = None def compose(self) -> ComposeResult: try: @@ -525,14 +557,36 @@ def remove_waypoint(self) -> None: except Exception as e: raise UnexpectedError(unexpected_msg_compose(e)) from None - @on(Button.Pressed, "#info_button") - def info_pressed(self) -> None: - self.notify( - "[b]SeaSeven[/b]:\nShallow ADCP profiler capable of providing information to a depth of 150 m every 4 meters (300kHz)" - "\n\n[b]OceanObserver[/b]:\nLong-range ADCP profiler capable of providing ~ 1000m of range every 24 meters (38kHz)", - severity="warning", - timeout=20, - ) + @on(Button.Pressed) + def remove_specific_waypoint(self, event: Button.Pressed) -> None: + """Ask for confirmation before removing a specific waypoint.""" + btn_id = event.button.id + if btn_id and btn_id.startswith("wp") and btn_id.endswith("_remove"): + try: + # Extract index from id, e.g. "wp2_remove" -> 2 + idx_str = btn_id[2:-7] + idx = int(idx_str) + if 0 <= idx < len(self.expedition.schedule.waypoints): + self._pending_remove_idx = idx + self.app.push_screen( + WaypointRemoveConfirmScreen(idx), self._on_remove_confirmed + ) + else: + self.notify("Invalid waypoint index.", severity="error", timeout=20) + except Exception as e: + raise UnexpectedError(unexpected_msg_compose(e)) from None + + def _on_remove_confirmed(self, confirmed: bool) -> None: + """Callback after confirmation dialog.""" + if confirmed and self._pending_remove_idx is not None: + try: + idx = self._pending_remove_idx + if 0 <= idx < len(self.expedition.schedule.waypoints): + self.expedition.schedule.waypoints.pop(idx) + self.refresh_waypoint_widgets() + except Exception as e: + raise UnexpectedError(unexpected_msg_compose(e)) from None + self._pending_remove_idx = None def show_hide_adcp_type(self, show: bool) -> None: container = self.query_one("#adcp_type_container") @@ -776,6 +830,12 @@ def compose(self) -> ComposeResult: classes="-hidden validation-failure", ) + yield Horizontal( + Button( + "Remove Waypoint", id=f"wp{self.index}_remove", variant="error" + ) + ) + except Exception as e: raise UnexpectedError(unexpected_msg_compose(e)) from None @@ -1170,6 +1230,33 @@ class PlanApp(App): } + + .confirm-modal { + align: center middle; + width: 50; + min-height: 9; + border: round $primary; + background: $panel; + padding: 2 4; + content-align: center middle; + margin: 2 4; + layout: vertical; + } + + #confirm-label { + content-align: center middle; + text-align: center; + width: 100%; + margin-bottom: 2; + } + + #confirm-buttons { + align: center middle; + width: 100%; + margin-top: 1; + content-align: center middle; + layout: horizontal; + } """ def __init__(self, path: str): From 43c63d5cd9851ea37a786a054dc7d377b8a8458c Mon Sep 17 00:00:00 2001 From: j-atkins <106238905+j-atkins@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:54:08 +0100 Subject: [PATCH 4/4] add reset changes button to waypoints editor --- src/virtualship/cli/_plan.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/virtualship/cli/_plan.py b/src/virtualship/cli/_plan.py index 16feb0bc..b10a7080 100644 --- a/src/virtualship/cli/_plan.py +++ b/src/virtualship/cli/_plan.py @@ -1,3 +1,4 @@ +import copy import datetime import os import traceback @@ -190,10 +191,12 @@ def __init__(self, path: str): self.path = path self.expedition = None self._pending_remove_idx = None + self._original_schedule = None # Store original schedule def compose(self) -> ComposeResult: try: self.expedition = Expedition.from_yaml(self.path.joinpath(EXPEDITION)) + self._original_schedule = copy.deepcopy(self.expedition.schedule) except Exception as e: raise UserError( f"There is an issue in {self.path.joinpath(EXPEDITION)}:\n\n{e}" @@ -365,6 +368,11 @@ def compose(self) -> ComposeResult: id="remove_waypoint", variant="error", ), + Button( + "Reset changes (all waypoints)", + id="reset_changes", + variant="warning", + ), ) yield VerticalScroll(id="waypoint_list", classes="waypoint-list") @@ -557,13 +565,22 @@ def remove_waypoint(self) -> None: except Exception as e: raise UnexpectedError(unexpected_msg_compose(e)) from None + @on(Button.Pressed, "#reset_changes") + def reset_changes(self) -> None: + """Reset all changes to the schedule, reverting to the original loaded schedule.""" + try: + self.expedition.schedule = copy.deepcopy(self._original_schedule) + self.refresh_waypoint_widgets() + + except Exception as e: + raise UnexpectedError(unexpected_msg_compose(e)) from None + @on(Button.Pressed) def remove_specific_waypoint(self, event: Button.Pressed) -> None: """Ask for confirmation before removing a specific waypoint.""" btn_id = event.button.id if btn_id and btn_id.startswith("wp") and btn_id.endswith("_remove"): try: - # Extract index from id, e.g. "wp2_remove" -> 2 idx_str = btn_id[2:-7] idx = int(idx_str) if 0 <= idx < len(self.expedition.schedule.waypoints):