The quick keyboard-entry sequence (type a letter then three digits to fill X/Y/x/y in one go) is the whole point of the Exact tab, but the picker buttons are deliberately non-focusable (keyboard focus stays on the dialog itself, see _make_picker's comment), so nothing was auto-scrolling the group you're about to type into view the way a real focused field would, on a dialog taller than its visible area you'd end up typing digits blind past the fold. _scroll_to_stage() now scrolls the upcoming picker group (or the Identity group, once all four digits are in) to the top of the scroll area after each character, and hands real keyboard focus to the id field once there's nothing left for the digit sequence to fill. Hit and fixed a real crash while wiring this up: translate_coordinates() actually returns a plain (x, y) tuple on success in this PyGObject version (not the documented (bool, x, y)) and a falsy value on failure, unconditionally unpacking three values crashed immediately on the very first keystroke. Verified directly against a live widget before trusting the fix, not just against the docstring. Verified with a GTK smoke test driving the real key-press handler through a full X/Y/x/y sequence, confirming the scroll position advances monotonically at each stage.
322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""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
|