|
| 1 | +import copy |
1 | 2 | import datetime |
2 | 3 | import os |
3 | 4 | import traceback |
|
6 | 7 | from textual.app import App, ComposeResult |
7 | 8 | from textual.containers import Container, Horizontal, VerticalScroll |
8 | 9 | from textual.dom import NoMatches |
9 | | -from textual.screen import Screen |
| 10 | +from textual.screen import ModalScreen, Screen |
10 | 11 | from textual.validation import Function, Integer |
11 | 12 | from textual.widgets import ( |
12 | 13 | Button, |
@@ -153,15 +154,49 @@ def log_exception_to_file( |
153 | 154 | } |
154 | 155 |
|
155 | 156 |
|
| 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 | + |
156 | 188 | class ExpeditionEditor(Static): |
157 | 189 | def __init__(self, path: str): |
158 | 190 | super().__init__() |
159 | 191 | self.path = path |
160 | 192 | self.expedition = None |
| 193 | + self._pending_remove_idx = None |
| 194 | + self._original_schedule = None # Store original schedule |
161 | 195 |
|
162 | 196 | def compose(self) -> ComposeResult: |
163 | 197 | try: |
164 | 198 | self.expedition = Expedition.from_yaml(self.path.joinpath(EXPEDITION)) |
| 199 | + self._original_schedule = copy.deepcopy(self.expedition.schedule) |
165 | 200 | except Exception as e: |
166 | 201 | raise UserError( |
167 | 202 | f"There is an issue in {self.path.joinpath(EXPEDITION)}:\n\n{e}" |
@@ -333,6 +368,11 @@ def compose(self) -> ComposeResult: |
333 | 368 | id="remove_waypoint", |
334 | 369 | variant="error", |
335 | 370 | ), |
| 371 | + Button( |
| 372 | + "Reset changes (all waypoints)", |
| 373 | + id="reset_changes", |
| 374 | + variant="warning", |
| 375 | + ), |
336 | 376 | ) |
337 | 377 |
|
338 | 378 | yield VerticalScroll(id="waypoint_list", classes="waypoint-list") |
@@ -525,14 +565,45 @@ def remove_waypoint(self) -> None: |
525 | 565 | except Exception as e: |
526 | 566 | raise UnexpectedError(unexpected_msg_compose(e)) from None |
527 | 567 |
|
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 |
536 | 607 |
|
537 | 608 | def show_hide_adcp_type(self, show: bool) -> None: |
538 | 609 | container = self.query_one("#adcp_type_container") |
@@ -700,16 +771,52 @@ def compose(self) -> ComposeResult: |
700 | 771 | classes="hour-select", |
701 | 772 | ) |
702 | 773 | 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 | + |
703 | 800 | yield Select( |
704 | | - [(f"{m:02d}", m) for m in range(0, 60, 5)], |
| 801 | + minute_options, |
705 | 802 | 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, |
709 | 804 | prompt="mm", |
710 | 805 | classes="minute-select", |
711 | 806 | ) |
712 | 807 |
|
| 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 | + |
713 | 820 | yield Label("Instruments:") |
714 | 821 | for instrument in [i for i in InstrumentType if not i.is_underway]: |
715 | 822 | is_selected = instrument in (self.waypoint.instrument or []) |
@@ -740,6 +847,12 @@ def compose(self) -> ComposeResult: |
740 | 847 | classes="-hidden validation-failure", |
741 | 848 | ) |
742 | 849 |
|
| 850 | + yield Horizontal( |
| 851 | + Button( |
| 852 | + "Remove Waypoint", id=f"wp{self.index}_remove", variant="error" |
| 853 | + ) |
| 854 | + ) |
| 855 | + |
743 | 856 | except Exception as e: |
744 | 857 | raise UnexpectedError(unexpected_msg_compose(e)) from None |
745 | 858 |
|
@@ -792,6 +905,59 @@ def on_switch_changed(self, event: Switch.Changed) -> None: |
792 | 905 | if not drifter_count_input.value: |
793 | 906 | drifter_count_input.value = "1" |
794 | 907 |
|
| 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 | + |
795 | 961 |
|
796 | 962 | class PlanScreen(Screen): |
797 | 963 | def __init__(self, path: str): |
@@ -1075,6 +1241,39 @@ class PlanApp(App): |
1075 | 1241 | Label.validation-failure { |
1076 | 1242 | color: $error; |
1077 | 1243 | } |
| 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 | + } |
1078 | 1277 | """ |
1079 | 1278 |
|
1080 | 1279 | def __init__(self, path: str): |
|
0 commit comments