mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Let a half-awake keyboard user actually reach Submit
The escape-hatch form was a keyboard dead end. Tk traps Tab inside a tk.Text (inserts a literal tab, refocuses the widget) and binds Shift-Tab to nothing, so the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike diet_guard's equivalent box, which at least binds <Return>, this one bound nothing at all -- so a keyboard-only user who tabbed into the justification field at 6am could not get back out to Submit. Enter now activates Submit / Back / the escape button too. X11 Tk binds only <space> on Button, so the key a user actually reaches for did nothing. Escape returns to the alarm from the escape FORM. To be explicit, because this app's whole point is resisting dismissal: this does not weaken the gating. The alarm still requires DISMISS_ROUNDS_REQUIRED correct answers or the budgeted escape hatch. The absent global <Escape>, the overrideredirect + grab_set_global + VT lock, and the hidden-until-earned escape button are deliberate adversarial design and are left alone. What changed is that the *sanctioned* paths are now reachable without a pointer. Focus rings everywhere: the dismiss-code entry, the escape button, the justification Text and the three escape-form fields all kept Tk's default 1px BLACK ring, invisible against #211D1B. On a screen with no other window and a global grab, that ring is the only feedback that a keystroke landed anywhere. _alarm_view.py imported gatelock only under TYPE_CHECKING, so bind_activate would have raised NameError at runtime while the module still imported cleanly -- an import smoke test would have missed it. phone_app is Android-only, so 1366x768 does not apply; its risk is landscape. home_screen and settings_screen were bare Columns that cannot scroll, so content overflowed with the stripe pattern painted over the primary control: phone landscape leaves ~360px of body height for ~405px of content. Both now scroll inside a SafeArea. The status line also rendered an unbounded raw 'Error: $e' with no maxLines, which added 100-150px and overflowed even in portrait; it is capped at four lines. The gatelock pin moves to v0.3.0, which is where the shared Tk-default workarounds live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad2e725d52
commit
1e465e0d2c
@ -96,7 +96,7 @@ repos:
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- python-kasa
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
|
||||
@ -85,19 +85,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
'android.intent.extra.alarm.MESSAGE': 'Wake Alarm',
|
||||
},
|
||||
);
|
||||
await intent.launch().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () {},
|
||||
);
|
||||
await intent.launch().timeout(const Duration(seconds: 3), onTimeout: () {});
|
||||
}
|
||||
|
||||
Future<void> _syncAndSet() async {
|
||||
final settings = await SyncSettings.load();
|
||||
final service = _service;
|
||||
if (!settings.isConfigured || service == null) {
|
||||
setState(
|
||||
() => _status = 'Configure GitHub in Settings first.',
|
||||
);
|
||||
setState(() => _status = 'Configure GitHub in Settings first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -108,13 +103,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
try {
|
||||
await service.setLocal(_hour, _minute);
|
||||
await service.push(settings.token);
|
||||
setState(
|
||||
() => _status = 'Synced ✓ — opening Clock app…',
|
||||
);
|
||||
setState(() => _status = 'Synced ✓ — opening Clock app…');
|
||||
await _setPhoneAlarm();
|
||||
setState(
|
||||
() => _status = 'Done! Confirm the alarm in the Clock app.',
|
||||
);
|
||||
setState(() => _status = 'Done! Confirm the alarm in the Clock app.');
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
} finally {
|
||||
@ -140,85 +131,95 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPressed: () async {
|
||||
await Navigator.push<void>(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const SettingsScreen(),
|
||||
),
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
await _loadSavedTime();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Alarm time', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: _pickTime,
|
||||
child: Text(
|
||||
_fmt(_hour, _minute),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displayLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _pickTime,
|
||||
child: const Text('Tap to change'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _busy ? null : _syncAndSet,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.alarm_add),
|
||||
label: const Text('Set Alarm & Sync to PC'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_status.isNotEmpty)
|
||||
Text(
|
||||
_status,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _status.contains('Error')
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.success,
|
||||
),
|
||||
),
|
||||
if (!_configured) ...[
|
||||
// SafeArea + scroll. This was a bare Center/Padding/Column, which
|
||||
// cannot scroll, so the content simply overflowed with the black-and-
|
||||
// yellow stripe pattern painted over the primary control. Two ways that
|
||||
// happened: phone landscape leaves only ~360px of body height for ~405px
|
||||
// of content, and the status line renders an unbounded raw
|
||||
// `'Error: \$e'` with no maxLines, which overflows even in portrait.
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Alarm time', style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Open Settings (gear icon) to connect GitHub.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.warning,
|
||||
GestureDetector(
|
||||
onTap: _pickTime,
|
||||
child: Text(
|
||||
_fmt(_hour, _minute),
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _pickTime,
|
||||
child: const Text('Tap to change'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _busy ? null : _syncAndSet,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.alarm_add),
|
||||
label: const Text('Set Alarm & Sync to PC'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_status.isNotEmpty)
|
||||
Text(
|
||||
_status,
|
||||
textAlign: TextAlign.center,
|
||||
// Bounded: _status can be a raw `'Error: $e'`, i.e. an
|
||||
// arbitrarily long exception toString(). Unbounded, that
|
||||
// alone added 100-150px and pushed this column past the
|
||||
// viewport even in portrait. The scroll view above makes
|
||||
// the overflow recoverable; this keeps it from happening.
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: _status.contains('Error')
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.success,
|
||||
),
|
||||
),
|
||||
if (!_configured) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Open Settings (gear icon) to connect GitHub.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -37,9 +37,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _connectGitHub() async {
|
||||
final auth = GitHubDeviceAuth(
|
||||
clientId: SyncSettings.defaultClientId,
|
||||
);
|
||||
final auth = GitHubDeviceAuth(clientId: SyncSettings.defaultClientId);
|
||||
try {
|
||||
final device = await auth.requestDeviceCode();
|
||||
if (!mounted) return;
|
||||
@ -90,63 +88,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final isConnected = _settings.isConfigured;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_StatusCard(connected: isConnected),
|
||||
const SizedBox(height: 16),
|
||||
if (!isConnected)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _busy ? null : _connectGitHub,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.link),
|
||||
label: const Text('Connect GitHub'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
// Scrollable: this screen carries a five-line explanatory paragraph and
|
||||
// an unbounded `'Error: \$e'`, inside a Column that could not scroll.
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_StatusCard(connected: isConnected),
|
||||
const SizedBox(height: 16),
|
||||
if (!isConnected)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _busy ? null : _connectGitHub,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.link),
|
||||
label: const Text('Connect GitHub'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.link_off),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.link_off),
|
||||
label: const Text('Disconnect'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_status.isNotEmpty)
|
||||
const SizedBox(height: 16),
|
||||
if (_status.isNotEmpty)
|
||||
Text(
|
||||
_status,
|
||||
style: TextStyle(
|
||||
color: _status.contains('Error')
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.success,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
_status,
|
||||
'PC config — create this file:\n'
|
||||
'~/.config/wake_alarm/sync.json\n\n'
|
||||
'{ "token": "<same token>" }\n\n'
|
||||
'The token needs "repo" scope so the PC can read '
|
||||
'kuhyx/syncs at shutdown.',
|
||||
// Was 12px/Colors.grey — below the 16px reading floor (rule
|
||||
// 20) and off-palette (rule 4); this is real setup
|
||||
// instructions, not decorative chrome.
|
||||
style: TextStyle(
|
||||
color: _status.contains('Error')
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(
|
||||
context,
|
||||
).extension<AppStatusColors>()!.success,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'PC config — create this file:\n'
|
||||
'~/.config/wake_alarm/sync.json\n\n'
|
||||
'{ "token": "<same token>" }\n\n'
|
||||
'The token needs "repo" scope so the PC can read '
|
||||
'kuhyx/syncs at shutdown.',
|
||||
// Was 12px/Colors.grey — below the 16px reading floor (rule
|
||||
// 20) and off-palette (rule 4); this is real setup
|
||||
// instructions, not decorative chrome.
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -170,14 +172,13 @@ class _StatusCard extends StatelessWidget {
|
||||
Icon(
|
||||
connected ? Icons.check_circle : Icons.cancel,
|
||||
// Dimmed relative to the adjacent label text (rule 28).
|
||||
color: (connected ? success : colorScheme.error)
|
||||
.withValues(alpha: 0.8),
|
||||
color: (connected ? success : colorScheme.error).withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
connected ? 'Connected to GitHub' : 'Not connected',
|
||||
),
|
||||
child: Text(connected ? 'Connected to GitHub' : 'Not connected'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -216,9 +217,7 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
}
|
||||
|
||||
Future<void> _openPage() async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: widget.device.userCode),
|
||||
);
|
||||
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
|
||||
await launchUrl(
|
||||
Uri.parse(widget.device.verificationUri),
|
||||
mode: LaunchMode.externalApplication,
|
||||
@ -249,9 +248,7 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('Waiting for authorization…'),
|
||||
),
|
||||
Expanded(child: Text('Waiting for authorization…')),
|
||||
],
|
||||
)
|
||||
else
|
||||
|
||||
@ -8,7 +8,7 @@ version = "1.0.0"
|
||||
description = "Phone-driven wake alarm with fan ramp and smart-plug control"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.1#subdirectory=gatelock",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock",
|
||||
"python-kasa>=0.7",
|
||||
]
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.2.1#subdirectory=gatelock
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.3.0#subdirectory=gatelock
|
||||
mypy>=1.8.0
|
||||
pre-commit>=3.6.0
|
||||
pylint>=3.0.0
|
||||
|
||||
@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import bind_activate
|
||||
|
||||
from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -238,6 +240,11 @@ def _build_challenge(
|
||||
justify="center",
|
||||
width=12,
|
||||
textvariable=entry_var,
|
||||
# Tk's default focus ring is 1px BLACK, invisible on bg (#211D1B). This
|
||||
# field is already focused and Return-bound, so the ring is about
|
||||
# confirming *where* keystrokes are going -- on a screen with no other
|
||||
# window and a global grab, that is the only feedback available.
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
entry.pack(pady=10)
|
||||
entry.focus_set()
|
||||
@ -303,7 +310,12 @@ def _build_alarm_view(
|
||||
font=(config.font_family, 14),
|
||||
bg=config.field_bg,
|
||||
fg=config.muted,
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
# Tk binds only <space> on Button on X11. The escape hatch is deliberately
|
||||
# hard to reach (hidden until earned, budgeted) but must not additionally be
|
||||
# hard to *press* once shown.
|
||||
bind_activate(escape_button)
|
||||
|
||||
return _AlarmView(
|
||||
container=container,
|
||||
|
||||
@ -25,7 +25,14 @@ import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import EscapeDraft, EscapePolicy, EscapeTracker
|
||||
from gatelock import (
|
||||
EscapeDraft,
|
||||
EscapePolicy,
|
||||
EscapeTracker,
|
||||
bind_activate,
|
||||
bind_cancel,
|
||||
escape_text_tab_trap,
|
||||
)
|
||||
|
||||
from wake_alarm._constants import ESCAPE_HISTORY_FILE, ESCAPE_OFFER_AFTER_SECONDS
|
||||
|
||||
@ -132,7 +139,15 @@ class EscapeDialog:
|
||||
bg=config.field_bg,
|
||||
fg=config.fg,
|
||||
insertbackground=config.fg,
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
# Tk traps Tab inside a Text (inserts a literal tab and refocuses the
|
||||
# widget) and binds Shift-Tab to nothing, so the only exits are the
|
||||
# undocumented Ctrl+Tab / Ctrl+Shift+Tab. Unlike diet_guard's equivalent
|
||||
# box, nothing here bound Return either -- so a keyboard-only user who
|
||||
# tabbed into this justification field at 6am could not reach Submit,
|
||||
# inside an alarm whose whole point is that it resists dismissal.
|
||||
escape_text_tab_trap(description)
|
||||
self._fields = _FormFields(
|
||||
reason=self._field("What is wrong", config),
|
||||
onset=self._field("When did it start", config),
|
||||
@ -157,17 +172,30 @@ class EscapeDialog:
|
||||
bg=config.warning,
|
||||
fg=config.on_fill,
|
||||
command=self.submit,
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
self._submit.pack(pady=6)
|
||||
|
||||
tk.Button(
|
||||
back = tk.Button(
|
||||
self.frame,
|
||||
text="Back to the alarm",
|
||||
font=(config.font_family, 14),
|
||||
bg=config.field_bg,
|
||||
fg=config.fg,
|
||||
command=callbacks.on_cancel,
|
||||
).pack(pady=4)
|
||||
**config.focus_kwargs(),
|
||||
)
|
||||
back.pack(pady=4)
|
||||
|
||||
# Tk binds only <space> on Button on X11, never <Return>, so Enter --
|
||||
# the key a user reaches for to submit a form -- did nothing at all.
|
||||
for button in (self._submit, back):
|
||||
bind_activate(button)
|
||||
# Escape returns to the alarm. This cancels the *escape-hatch form*, not
|
||||
# the alarm: the alarm still has to be dismissed by solving a challenge
|
||||
# or by spending the budgeted hatch. Making the sanctioned way back
|
||||
# keyboard-reachable does not weaken that gating.
|
||||
bind_cancel(self.frame, callbacks.on_cancel)
|
||||
|
||||
def _field(self, label: str, config: LockConfig) -> tk.Entry:
|
||||
"""Add a labelled single-line field and return its entry."""
|
||||
@ -178,7 +206,7 @@ class EscapeDialog:
|
||||
fg=config.muted,
|
||||
bg=config.bg,
|
||||
).pack()
|
||||
entry = tk.Entry(self.frame, width=40)
|
||||
entry = tk.Entry(self.frame, width=40, **config.focus_kwargs())
|
||||
entry.pack(pady=2)
|
||||
return entry
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user