FEnigma/src/fenigma/coord_dialog.py
Dominik Roth 0a54178723 CoordDialog: auto-scroll the next picker group into view during keyboard entry
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.
2026-08-09 20:36:15 +02:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 (110)", Y_flow)
x_group = self._picker_group("x (09)", x_flow)
y_group = self._picker_group("y (09)", y_flow)
outer.append(self._picker_group("X (AT)", 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