"""Modal dialog for entering a Coord, or a free-text relative description. Two ways to specify a location, matching the two tabs: - "Exact": X/Y/x/y fields (+ id/type when adding a target). - "Description": free-form text box, parsed with the same Bearing/Distance clue grammar the OCR pipeline uses (ocr.parse_clues_from_text). Prefilled with whatever description is already stored, if any. Either tab calls on_submit with a Location: from_coord() for Exact, from_desc() for Description. The caller applies just that half without clobbering the other (a coord and a description can coexist). """ from __future__ import annotations from typing import Callable import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("Gdk", "4.0") from gi.repository import Adw, Gdk, Gtk # noqa: E402 from . import ocr from .models import LARGE_X, Coord, Location, TargetType class CoordDialog(Adw.Dialog): """Emits a Location (and, if enabled, id/type) via on_submit.""" def __init__( self, *, title: str, on_submit: Callable[[Location, str | None, TargetType | None], None], show_id: bool = False, show_type: bool = False, id_placeholder: str | None = None, initial_location: Location | None = None, initial_id: str | None = None, initial_type: TargetType | None = None, ) -> None: super().__init__(title=title, content_width=420, content_height=580) self._on_submit = on_submit self._show_id = show_id self._show_type = show_type self._id_placeholder = id_placeholder or "ID (blank = auto)" self._initial_location = initial_location or Location() self._initial_id = initial_id self._initial_type = initial_type toolbar_view = Adw.ToolbarView() self.set_child(toolbar_view) toolbar_view.add_top_bar(Adw.HeaderBar()) stack = Adw.ViewStack() switcher = Adw.ViewSwitcherBar(stack=stack, reveal=True) page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) page.append(stack) page.append(switcher) toolbar_view.set_content(page) stack.add_titled_with_icon( self._build_exact_tab(), "exact", "Exact", "input-keyboard-symbolic" ) stack.add_titled_with_icon( self._build_desc_tab(), "desc", "Description", "text-x-generic-symbolic" ) # Quick keyboard entry: type e.g. "C433" (X digit×3) to fill and # submit in one go. Y takes '0' to mean 10. Any letter A-T restarts # the sequence, so a typo just means retyping the letter. self._kb_stage = 0 key_controller = Gtk.EventControllerKey() key_controller.connect("key-pressed", self._on_key_pressed) self.add_controller(key_controller) # Grab keyboard focus onto the dialog itself as soon as it's shown, # otherwise no descendant is focused (we made the picker buttons # non-focusable) so key events never reach our controller at all. self.set_focusable(True) self.connect("map", lambda *_a: self.grab_focus()) def _make_picker(self, labels, initial_index: int, on_select) -> tuple[Gtk.FlowBox, list]: """A wrapping row of toggle buttons acting as a radio group.""" flow = Gtk.FlowBox() flow.set_selection_mode(Gtk.SelectionMode.NONE) flow.set_homogeneous(True) flow.set_row_spacing(4) flow.set_column_spacing(4) flow.set_max_children_per_line(10) flow.set_min_children_per_line(5) buttons = [] group_leader = None for i, lbl in enumerate(labels): btn = Gtk.ToggleButton(label=str(lbl)) btn.set_size_request(34, 34) btn.set_focusable(False) # keep keyboard focus on the dialog, not the grid if group_leader is None: group_leader = btn else: btn.set_group(group_leader) if i == initial_index: btn.set_active(True) def _on_toggled(b, i=i): if b.get_active(): on_select(i) btn.connect("toggled", _on_toggled) flow.append(btn) buttons.append(btn) return flow, buttons def _picker_group(self, title: str, picker: Gtk.FlowBox) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) heading = Gtk.Label(label=title, xalign=0) heading.add_css_class("heading") box.append(heading) box.append(picker) return box def _build_exact_tab(self) -> Gtk.Widget: outer = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=16, margin_top=16, margin_bottom=16, margin_start=16, margin_end=16, ) initial = self._initial_location.coord init_X_idx = LARGE_X.index(initial.X) if initial else 0 init_Y_idx = (initial.Y - 1) if initial else 0 init_x = initial.x if initial else 0 init_y = initial.y if initial else 0 self._X_idx = init_X_idx self._Y_val = init_Y_idx + 1 self._x_val = init_x self._y_val = init_y X_flow, self._X_buttons = self._make_picker( list(LARGE_X), init_X_idx, lambda i: setattr(self, "_X_idx", i) ) Y_flow, self._Y_buttons = self._make_picker( range(1, 11), init_Y_idx, lambda i: setattr(self, "_Y_val", i + 1) ) x_flow, self._x_buttons = self._make_picker( range(0, 10), init_x, lambda i: setattr(self, "_x_val", i) ) y_flow, self._y_buttons = self._make_picker( range(0, 10), init_y, lambda i: setattr(self, "_y_val", i) ) Y_group = self._picker_group("Y (1–10)", Y_flow) x_group = self._picker_group("x (0–9)", x_flow) y_group = self._picker_group("y (0–9)", y_flow) outer.append(self._picker_group("X (A–T)", X_flow)) outer.append(Y_group) outer.append(x_group) outer.append(y_group) # Index-matched to _kb_stage (0->just typed X, waiting on Y; # 1->just typed Y, waiting on x; 2->just typed x, waiting on y; # 3->all four digits in, whatever comes next: the Identity # group if there is one, otherwise nothing left to scroll to). # See _scroll_to_stage(), keyboard-only entry means the picker # group for the digit you're about to type can easily be # scrolled out of view with nothing to auto-follow it the way # a real focusable field would. self._stage_widgets = [Y_group, x_group, y_group, None] self.row_id = None self.row_type = None if self._show_id or self._show_type: extra_group = Adw.PreferencesGroup(title="Identity") if self._show_id: self.row_id = Adw.EntryRow(title=self._id_placeholder) if self._initial_id is not None: self.row_id.set_text(str(self._initial_id)) extra_group.add(self.row_id) if self._show_type: self.row_type = Adw.ComboRow( title="Type", model=Gtk.StringList.new([t.short for t in TargetType]), ) if self._initial_type is not None: self.row_type.set_selected(list(TargetType).index(self._initial_type)) extra_group.add(self.row_type) outer.append(extra_group) self._stage_widgets[3] = extra_group submit = Gtk.Button(label="Set coordinates") submit.add_css_class("suggested-action") submit.add_css_class("pill") submit.set_halign(Gtk.Align.CENTER) submit.connect("clicked", self._on_submit_clicked) outer.append(submit) self._exact_scroller = Gtk.ScrolledWindow(child=outer) self._exact_content = outer return self._exact_scroller def _scroll_to_stage(self, stage: int) -> None: """Scroll the picker group for the digit about to be typed (or the Identity group, once all four are in) to the top of the dialog's visible area. The picker buttons are deliberately non- focusable (see _make_picker) so nothing else auto-scrolls this for us as the keyboard-only entry sequence advances.""" widget = self._stage_widgets[stage] if 0 <= stage < len(self._stage_widgets) else None if widget is None: return # Despite the C API being (bool, dest_x, dest_y), this # PyGObject version's translate_coordinates() actually returns # a plain (x, y) tuple on success and a falsy value (None) on # failure (e.g. widget not yet realized/allocated), verified # directly rather than trusting the documented signature. result = widget.translate_coordinates(self._exact_content, 0, 0) if result: _x, y = result self._exact_scroller.get_vadjustment().set_value(y) if stage == 3 and self.row_id is not None: # Nothing left to type via the digit-sequence controller, # hand real keyboard focus to the id field so continuing to # type just works. self.row_id.grab_focus() def _build_desc_tab(self) -> Gtk.Widget: outer = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=12, margin_top=16, margin_bottom=16, margin_start=16, margin_end=16, ) outer.append(Gtk.Label( label="Paste or type a description, lines like 'Bearing 293 " "from Alpha' or 'Distance 13.59km from Spotter#1' are " "parsed into clues; everything else is kept as context.", wrap=True, xalign=0, )) self._desc_view = Gtk.TextView(vexpand=True, wrap_mode=Gtk.WrapMode.WORD) self._desc_view.add_css_class("card") if self._initial_location.desc_raw: self._desc_view.get_buffer().set_text(self._initial_location.desc_raw) scroller = Gtk.ScrolledWindow(child=self._desc_view, vexpand=True) outer.append(scroller) parse_btn = Gtk.Button(label="Parse description") parse_btn.add_css_class("suggested-action") parse_btn.add_css_class("pill") parse_btn.set_halign(Gtk.Align.CENTER) parse_btn.connect("clicked", self._on_desc_submit_clicked) outer.append(parse_btn) return outer def _id_and_type(self) -> tuple[str | None, TargetType | None]: id_ = self.row_id.get_text().strip() or None if self.row_id is not None else None type_ = list(TargetType)[self.row_type.get_selected()] if self.row_type is not None else None return id_, type_ def _on_submit_clicked(self, _button: Gtk.Button) -> None: coord = Coord(X=LARGE_X[self._X_idx], Y=self._Y_val, x=self._x_val, y=self._y_val) id_, type_ = self._id_and_type() self._on_submit(Location.from_coord(coord), id_, type_) self.close() def _on_desc_submit_clicked(self, _button: Gtk.Button) -> None: buf = self._desc_view.get_buffer() text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), True) clues = ocr.parse_clues_from_text(text) id_, type_ = self._id_and_type() self._on_submit(Location.from_desc(text, clues), id_, type_) self.close() def _on_key_pressed(self, _controller, keyval, _keycode, _state) -> bool: unicode_val = Gdk.keyval_to_unicode(keyval) if not unicode_val: return False ch = chr(unicode_val) if ch.isalpha(): letter = ch.upper() if letter not in LARGE_X: return False self._X_buttons[LARGE_X.index(letter)].set_active(True) self._kb_stage = 1 self._scroll_to_stage(0) # Y picker, up next return True if ch.isdigit(): digit = int(ch) if self._kb_stage == 0: return False # need X first if self._kb_stage == 1: self._Y_buttons[(10 if digit == 0 else digit) - 1].set_active(True) self._kb_stage = 2 self._scroll_to_stage(1) # x picker, up next elif self._kb_stage == 2: self._x_buttons[digit].set_active(True) self._kb_stage = 3 self._scroll_to_stage(2) # y picker, up next elif self._kb_stage == 3: self._y_buttons[digit].set_active(True) self._kb_stage = 0 self._scroll_to_stage(3) # Identity group, if there is one # Don't auto-submit when there's an id/type field still to # fill in (Add spotter / Add target), the 4-digit sequence # only ever fills the coordinate, so submitting immediately # would lock in id/type before the user's touched them. if not (self._show_id or self._show_type): self._on_submit_clicked(None) return True return False