backpack-backbone 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backbone/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """backbone - shared terminal UI: colours, meters, prompt widgets, live views."""
2
+ from .ui import (
3
+ Colors,
4
+ MARGIN_H,
5
+ MARGIN_V,
6
+ SPIN,
7
+ PARTS,
8
+ SPARK,
9
+ spinner,
10
+ content_width,
11
+ rule,
12
+ bar,
13
+ header_box,
14
+ wrap_margins,
15
+ )
16
+
17
+ __all__ = [
18
+ "Colors",
19
+ "MARGIN_H",
20
+ "MARGIN_V",
21
+ "SPIN",
22
+ "PARTS",
23
+ "SPARK",
24
+ "spinner",
25
+ "content_width",
26
+ "rule",
27
+ "bar",
28
+ "header_box",
29
+ "wrap_margins",
30
+ ]
backbone/app.py ADDED
@@ -0,0 +1,26 @@
1
+ """Which program is running, so shared code can find its files: a name and a
2
+ config folder, set once at startup by the host program (its config module)."""
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def _default_dir(name: str) -> Path:
8
+ """The platform's usual config folder for `name`."""
9
+ if os.name == "nt":
10
+ base = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming")
11
+ return Path(base) / name.capitalize()
12
+ xdg = os.getenv("XDG_CONFIG_HOME")
13
+ return (Path(xdg) if xdg else Path.home() / ".config") / name
14
+
15
+
16
+ name = "backbone"
17
+ config_dir = _default_dir(name)
18
+
19
+
20
+ def configure(app_name: str, app_config_dir=None) -> None:
21
+ """Name the running program and its config folder (the platform default for
22
+ that name when not given). The diagnostics log and the saved hints switch
23
+ live there."""
24
+ global name, config_dir
25
+ name = app_name
26
+ config_dir = Path(app_config_dir) if app_config_dir else _default_dir(app_name)
@@ -0,0 +1,224 @@
1
+ """One parser for every hand-typed date: the calendar, the date/time editor,
2
+ the schedule table, filename values.
3
+
4
+ Everything goes through :func:`parse_datetime`. It accepts what a person
5
+ plausibly types, keeps whatever precision was given, and says *why* when it can't
6
+ read something so the caller can show that rather than a bare failure.
7
+
8
+ Accepted, all with ``-``, ``/`` or ``.`` between the parts and zero-padding
9
+ optional::
10
+
11
+ 2008 2008-07 2008-07-02 2008-7-2
12
+ 2008/07/02 2008.7.2 20080702
13
+ 2008-07-02 18:30 2008-07-02T18:30 2008-07-02 18:30:45
14
+
15
+ A time may follow the date after a ``T`` (either case) or a space, as ``HH:MM``
16
+ or ``HH:MM:SS``. A trailing timezone (``Z`` or ``±HH:MM``) is stripped: the
17
+ tags Backtrack writes are local wall-clock timestamps.
18
+
19
+ Day-first vs month-first (``02/07/2008``) is ambiguous and is resolved
20
+ only when the caller says how, via ``dayfirst``. Left unset, an ambiguous date
21
+ is refused rather than guessed, because guessing wrong writes a plausible-looking
22
+ wrong date that nobody notices.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import datetime
27
+ import re
28
+ from typing import NamedTuple, Optional
29
+
30
+ __all__ = ['ParsedDateTime', 'parse_datetime', 'parse_date', 'parse_time',
31
+ 'format_datetime', 'PRECISIONS']
32
+
33
+ # Coarse → fine. A caller can compare precisions with `PRECISIONS.index(...)`
34
+ # to demand at least a given granularity.
35
+ PRECISIONS = ('year', 'month', 'day', 'minute', 'second')
36
+
37
+ # Year-first, any of - / . between parts, zero-padding optional.
38
+ _YEAR_FIRST_RE = re.compile(r'^(\d{4})(?:[-/.\s](\d{1,2})(?:[-/.\s](\d{1,2}))?)?$')
39
+ # ISO basic form, 20080702.
40
+ _COMPACT_RE = re.compile(r'^(\d{4})(\d{2})(\d{2})$')
41
+ # Day- or month-first, e.g. 02/07/2008, order decided by `dayfirst`.
42
+ _YEAR_LAST_RE = re.compile(r'^(\d{1,2})[-/.\s](\d{1,2})[-/.\s](\d{4})$')
43
+ # A trailing timezone we drop rather than try to honour.
44
+ _TZ_RE = re.compile(r'(Z|[+-]\d{2}:?\d{2})$', re.IGNORECASE)
45
+
46
+
47
+ class ParsedDateTime(NamedTuple):
48
+ """The result of reading a date/time a user typed.
49
+
50
+ ``date`` is always a real ``datetime.date`` on success (a year- or
51
+ month-only input is completed to the 1st so callers that just need *a* date
52
+ have one), and ``precision`` records how much was actually given, so a caller
53
+ that needs a real day (a schedule counting in days, say) can insist on it
54
+ instead of silently scheduling from an invented 1 January.
55
+
56
+ ``time`` is ``'HH:MM:SS'`` or None. ``error`` is '' on success and otherwise
57
+ a short phrase naming what was wrong, fit to show the user directly.
58
+ """
59
+ date: Optional[datetime.date]
60
+ time: Optional[str]
61
+ precision: str
62
+ error: str
63
+
64
+ @property
65
+ def ok(self) -> bool:
66
+ """True if the input parsed."""
67
+ return not self.error
68
+
69
+
70
+ def parse_time(raw) -> Optional[str]:
71
+ """Normalise ``HH``/``HH:MM``/``HH:MM:SS`` to ``'HH:MM:SS'``; None if unreadable.
72
+
73
+ Rejects out-of-range parts (``25:00``, ``18:75``) rather than rolling them
74
+ over, so a typo surfaces instead of quietly becoming a different time.
75
+ """
76
+ if raw is None:
77
+ return None
78
+ s = str(raw).strip()
79
+ if not s:
80
+ return None
81
+ parts = s.split(':')
82
+ if len(parts) > 3 or not all(p.strip().isdigit() for p in parts if p.strip() != ''):
83
+ return None
84
+ try:
85
+ h = int(parts[0])
86
+ m = int(parts[1]) if len(parts) > 1 and parts[1].strip() else 0
87
+ sec = int(parts[2]) if len(parts) > 2 and parts[2].strip() else 0
88
+ except (ValueError, IndexError):
89
+ return None
90
+ if 0 <= h < 24 and 0 <= m < 60 and 0 <= sec < 60:
91
+ return f"{h:02d}:{m:02d}:{sec:02d}"
92
+ return None
93
+
94
+
95
+ def _split_date_time(s: str) -> tuple:
96
+ """Split a stamp into its date and time halves.
97
+
98
+ A ``T`` always separates them. A space only does when what follows it looks
99
+ like a clock time (it carries a ``:``) because a space is *also* a legal
100
+ separator inside the date itself: ``2008 07 02`` is a date, while
101
+ ``2008-07-02 18:30`` is a date and a time.
102
+ """
103
+ upper = s.upper()
104
+ if 'T' in upper:
105
+ cut = upper.index('T')
106
+ return s[:cut].strip(), s[cut + 1:].strip()
107
+ if ' ' in s:
108
+ head, _, tail = s.rpartition(' ')
109
+ if ':' in tail:
110
+ return head.strip(), tail.strip()
111
+ return s.strip(), ''
112
+
113
+
114
+ def split_stamp(raw) -> tuple:
115
+ """The date and time halves of a typed stamp, any trailing timezone dropped."""
116
+ return _split_date_time(_TZ_RE.sub('', str(raw or '').strip()).strip())
117
+
118
+
119
+ def _read_date(part: str, dayfirst: Optional[bool]) -> tuple:
120
+ """Parse the date half → ``(date, precision, error)``."""
121
+ m = _COMPACT_RE.match(part)
122
+ if m:
123
+ y, mo, d = (int(g) for g in m.groups())
124
+ return _build(y, mo, d, 'day')
125
+
126
+ m = _YEAR_FIRST_RE.match(part)
127
+ if m:
128
+ year, month, day = m.groups()
129
+ if month is None:
130
+ return _build(int(year), 1, 1, 'year')
131
+ if day is None:
132
+ return _build(int(year), int(month), 1, 'month')
133
+ return _build(int(year), int(month), int(day), 'day')
134
+
135
+ m = _YEAR_LAST_RE.match(part)
136
+ if m:
137
+ a, b, year = (int(g) for g in m.groups())
138
+ # Only one ordering can be right when a part exceeds 12 (13/07/2008 has
139
+ # to be day-first), so try both and see how many survive.
140
+ day_first_ok = 1 <= b <= 12
141
+ month_first_ok = 1 <= a <= 12
142
+ if day_first_ok and month_first_ok:
143
+ # Genuinely ambiguous: 02/07/2008 is 2 July or 2 February depending
144
+ # on where you live. Honour an explicit choice, else refuse rather
145
+ # than pick one and be silently wrong.
146
+ if dayfirst is None:
147
+ return None, '', (f"{part!r} could be day-first or month-first: "
148
+ "write it year-first (2008-07-02)")
149
+ day, month = (a, b) if dayfirst else (b, a)
150
+ elif day_first_ok:
151
+ day, month = a, b
152
+ elif month_first_ok:
153
+ month, day = a, b
154
+ else:
155
+ return None, '', f"{part!r} has no valid month"
156
+ return _build(year, month, day, 'day')
157
+
158
+ return None, '', f"{part!r} is not a date"
159
+
160
+
161
+ def _build(year: int, month: int, day: int, precision: str) -> tuple:
162
+ """Validate y/m/d into a real date, or report why it isn't one."""
163
+ if not 1 <= month <= 12:
164
+ return None, '', f"there is no month {month}"
165
+ try:
166
+ return datetime.date(year, month, day), precision, ''
167
+ except ValueError:
168
+ return None, '', (f"{year:04d}-{month:02d}-{day:02d} is not a real date")
169
+
170
+
171
+ def parse_datetime(raw, *, dayfirst: Optional[bool] = None) -> ParsedDateTime:
172
+ """Read a date, optionally with a time, from something a user typed.
173
+
174
+ ``dayfirst`` resolves ``02/07/2008``: True reads it day-first, False
175
+ month-first, and the default (None) refuses it and says to write the date
176
+ year-first. Year-first input is never ambiguous and never consults it.
177
+ """
178
+ if raw is None:
179
+ return ParsedDateTime(None, None, '', 'no date given')
180
+ date_part, time_part = split_stamp(raw)
181
+ if not date_part:
182
+ return ParsedDateTime(None, None, '', 'no date given')
183
+
184
+ date, precision, err = _read_date(date_part, dayfirst)
185
+ if err:
186
+ return ParsedDateTime(None, None, '', err)
187
+
188
+ if not time_part:
189
+ return ParsedDateTime(date, None, precision, '')
190
+
191
+ tod = parse_time(time_part)
192
+ if tod is None:
193
+ return ParsedDateTime(None, None, '', f"{time_part!r} is not a valid 24-hour time")
194
+ # A time implies a full date; without one we would be timing an invented day.
195
+ if precision != 'day':
196
+ return ParsedDateTime(None, None, '', f"{date_part!r} needs a full year-month-day "
197
+ "to carry a time")
198
+ return ParsedDateTime(date, tod, 'second' if tod[-2:] != '00' else 'minute', '')
199
+
200
+
201
+ def parse_date(raw, *, dayfirst: Optional[bool] = None) -> Optional[datetime.date]:
202
+ """Just the date, or None if it won't parse. Any time given is ignored."""
203
+ return parse_datetime(raw, dayfirst=dayfirst).date
204
+
205
+
206
+ def format_datetime(parsed: ParsedDateTime) -> str:
207
+ """Render a parse back out at the precision it was given."""
208
+ if parsed.date is None:
209
+ return ''
210
+ if parsed.precision == 'year':
211
+ return f"{parsed.date.year:04d}"
212
+ if parsed.precision == 'month':
213
+ return f"{parsed.date.year:04d}-{parsed.date.month:02d}"
214
+ if parsed.time:
215
+ return f"{parsed.date.isoformat()} {parsed.time}"
216
+ return parsed.date.isoformat()
217
+
218
+
219
+ def parse_date_parts(raw, *, dayfirst: Optional[bool] = None) -> Optional[tuple]:
220
+ """``(year, month, day)`` for a typed date, or None: the shape the calendar
221
+ and date/time widgets work in. A year- or month-only input completes to the
222
+ 1st, as those widgets have always done."""
223
+ d = parse_datetime(raw, dayfirst=dayfirst).date
224
+ return (d.year, d.month, d.day) if d else None
backbone/deps.py ADDED
@@ -0,0 +1,98 @@
1
+ """What a tool needs besides Python, checked in one place: at startup for what
2
+ it can't run without (`require`), and as a report for everything (each tool's
3
+ `doctor` command), each with how to install it on the platform it's on.
4
+
5
+ VLC = Dep("VLC", probe=..., needed_for="playback", required=True,
6
+ hints={"macos": "brew install vlc", "debian": "sudo apt install vlc"})
7
+ deps.require([VLC]) # prints the hint and exits when VLC is missing
8
+ """
9
+ from __future__ import annotations
10
+ import os
11
+ import shutil
12
+ import sys
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable
15
+
16
+ from backbone.log import log
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Dep:
21
+ name: str
22
+ probe: Callable[[], str | None] # what was found (a path, a version), or None
23
+ needed_for: str
24
+ required: bool = False
25
+ # Install hint per platform (see platform_key); "other" when none fits.
26
+ hints: dict = field(default_factory=dict)
27
+
28
+
29
+ def which(*commands: str) -> Callable[[], str | None]:
30
+ """A probe for the first of `commands` on the PATH."""
31
+ return lambda: next((p for c in commands if (p := shutil.which(c))), None)
32
+
33
+
34
+ def platform_key() -> str:
35
+ """macos, windows, debian (Ubuntu too), fedora, arch, or linux."""
36
+ if sys.platform == "darwin":
37
+ return "macos"
38
+ if os.name == "nt":
39
+ return "windows"
40
+ ids = ""
41
+ try:
42
+ with open("/etc/os-release", encoding="utf-8") as f:
43
+ for line in f:
44
+ if line.startswith(("ID=", "ID_LIKE=")):
45
+ ids += " " + line.split("=", 1)[1].strip().strip('"').lower()
46
+ except OSError:
47
+ pass
48
+ for key, names in (("debian", ("debian", "ubuntu")), ("fedora", ("fedora", "rhel")),
49
+ ("arch", ("arch",))):
50
+ if any(n in ids.split() for n in names):
51
+ return key
52
+ return "linux"
53
+
54
+
55
+ def hint(dep: Dep) -> str:
56
+ """How to install `dep` here."""
57
+ key = platform_key()
58
+ return (dep.hints.get(key) or (dep.hints.get("linux") if key in ("debian", "fedora", "arch") else None)
59
+ or dep.hints.get("other", ""))
60
+
61
+
62
+ def check(deps: list[Dep]) -> list[tuple[Dep, str | None]]:
63
+ """Each dependency with what was found, or None when it's missing. A probe
64
+ that fails counts as missing (and is logged)."""
65
+ out = []
66
+ for dep in deps:
67
+ try:
68
+ found = dep.probe() or None
69
+ except Exception as exc:
70
+ log.info("dependency %s: probe failed: %s", dep.name, exc)
71
+ found = None
72
+ out.append((dep, found))
73
+ return out
74
+
75
+
76
+ def report_lines(deps: list[Dep]) -> list[str]:
77
+ """One line per dependency, for a plain-text doctor."""
78
+ lines = []
79
+ for dep, found in check(deps):
80
+ if found:
81
+ lines.append(f" ok {dep.name}: {found}")
82
+ else:
83
+ need = "MISSING " if dep.required else "missing "
84
+ lines.append(f" {need} {dep.name} (for {dep.needed_for}): {hint(dep)}")
85
+ return lines
86
+
87
+
88
+ def require(deps: list[Dep], tool: str) -> None:
89
+ """Exit with what to install when a required dependency is missing, before
90
+ the tool starts rather than as a traceback once it's running."""
91
+ missing = [d for d, found in check(deps) if d.required and not found]
92
+ if not missing:
93
+ return
94
+ print(f"{tool} can't start: something it needs isn't installed.", file=sys.stderr)
95
+ for d in missing:
96
+ print(f" {d.name} (for {d.needed_for}): {hint(d)}", file=sys.stderr)
97
+ print(f"`{tool} doctor` lists everything it uses.", file=sys.stderr)
98
+ raise SystemExit(1)
backbone/files.py ADDED
@@ -0,0 +1,61 @@
1
+ """Files: atomic writes and backups, a daemon's timestamped log, free space and folder counts."""
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+
6
+
7
+ def write_text_atomic(path, text: str) -> None:
8
+ """Write `text` to `path` via a temporary file in the same directory that
9
+ then replaces it: the file is either the old version or the new one, never
10
+ half of the new one."""
11
+ path = os.fspath(path)
12
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".tmp_",
13
+ suffix=os.path.splitext(path)[1])
14
+ try:
15
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
16
+ f.write(text)
17
+ f.flush()
18
+ os.fsync(f.fileno())
19
+ os.replace(tmp, path)
20
+ except BaseException:
21
+ try:
22
+ os.unlink(tmp)
23
+ except OSError:
24
+ pass
25
+ raise
26
+
27
+
28
+ def backup_copy(path) -> str | None:
29
+ """Copy `path` to `path.bak` (replacing an older one) before it's
30
+ overwritten; returns the backup's path, or None if there was nothing to copy."""
31
+ path = os.fspath(path)
32
+ if not os.path.isfile(path):
33
+ return None
34
+ dest = path + ".bak"
35
+ shutil.copy2(path, dest)
36
+ return dest
37
+
38
+
39
+ def log_line(path, message: str) -> None:
40
+ """Print `message` with a timestamp and append the same line to the log at
41
+ `path`: a daemon's running record, readable on screen and afterwards."""
42
+ from datetime import datetime
43
+ line = f"{datetime.now():%Y-%m-%d %H:%M:%S} {message}"
44
+ print(line)
45
+ with open(path, "a") as f:
46
+ f.write(line + "\n")
47
+
48
+
49
+ def disk_free(path) -> str:
50
+ """Free space on `path`'s volume as `df -h` shows it, or "?"."""
51
+ import subprocess
52
+ try:
53
+ lines = subprocess.run(["df", "-h", os.fspath(path)], capture_output=True, text=True, timeout=10).stdout.splitlines()
54
+ except (OSError, subprocess.TimeoutExpired):
55
+ return "?"
56
+ return lines[-1].split()[3] if len(lines) > 1 else "?"
57
+
58
+
59
+ def count_entries(d) -> int:
60
+ """How many entries the folder `d` holds, 0 if it doesn't exist."""
61
+ return len(os.listdir(d)) if os.path.isdir(d) else 0
backbone/keyboard.py ADDED
@@ -0,0 +1,148 @@
1
+ """Which keyboard the typist is using, only as far as search needs it.
2
+
3
+ `search._lev` scores a spelling mistake by whether the wrong letter sits next to
4
+ the right one, so the only thing that matters here is where the *letters* are.
5
+ That collapses the world's keyboards into a handful of families: British, US,
6
+ Canadian, Irish, Australian and ABC differ solely in punctuation and dead keys,
7
+ and are all one QWERTY as far as a typo is concerned.
8
+
9
+ Detection is best-effort by design. Every branch is wrapped, and anything
10
+ unreadable, unrecognised or unknown falls back to QWERTY. `BACKTRACK_KEYBOARD` overrides it
11
+ outright, which is the only thing that can be right over SSH: the layout lives
12
+ on the machine in front of the typist, not the one running the process.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+
19
+ # Row-by-row key positions. Punctuation is kept in place even though nothing
20
+ # looks it up: it holds the columns open, so dropping the leading "'," from
21
+ # Dvorak's top row would slide every letter on it one key to the left.
22
+ LAYOUTS: dict[str, tuple[str, ...]] = {
23
+ 'qwerty': ("1234567890-=", "qwertyuiop[]", "asdfghjkl;'", "zxcvbnm,./"),
24
+ 'qwertz': ("1234567890ß'", "qwertzuiopü+", "asdfghjklöä", "yxcvbnm,.-"),
25
+ 'azerty': ("1234567890°+", "azertyuiop^$", "qsdfghjklmù", "wxcvbn,;:!"),
26
+ 'dvorak': ("1234567890[]", "',.pyfgcrl/=", "aoeuidhtns-", ";qjkxbmwvz"),
27
+ 'colemak': ("1234567890-=", "qwfpgjluy;[]", "arstdhneio'", "zxcvbkm,./"),
28
+ }
29
+
30
+ DEFAULT = 'qwerty'
31
+
32
+ # Layout names (macOS), XKB codes (Linux) and language ids (Windows) that move
33
+ # letters around. Matched as substrings against a lowercased name, so "Swiss
34
+ # German" and "German - Standard" both land on QWERTZ.
35
+ _NAME_FAMILIES: tuple[tuple[str, str], ...] = (
36
+ ('dvorak', 'dvorak'), ('colemak', 'colemak'),
37
+ ('azerty', 'azerty'), ('french', 'azerty'), ('belgian', 'azerty'),
38
+ ('qwertz', 'qwertz'), ('german', 'qwertz'), ('swiss', 'qwertz'),
39
+ ('austrian', 'qwertz'), ('czech', 'qwertz'), ('slovak', 'qwertz'),
40
+ ('hungarian', 'qwertz'), ('croatian', 'qwertz'), ('serbian', 'qwertz'),
41
+ ('slovenian', 'qwertz'), ('bosnian', 'qwertz'),
42
+ )
43
+
44
+ _XKB_FAMILIES: dict[str, str] = {
45
+ 'fr': 'azerty', 'be': 'azerty',
46
+ 'de': 'qwertz', 'ch': 'qwertz', 'at': 'qwertz', 'cz': 'qwertz',
47
+ 'sk': 'qwertz', 'hu': 'qwertz', 'hr': 'qwertz', 'rs': 'qwertz',
48
+ 'si': 'qwertz', 'ba': 'qwertz',
49
+ }
50
+
51
+ # Windows primary language ids (the low byte of the low word of the HKL).
52
+ _WIN_FAMILIES: dict[int, str] = {
53
+ 0x0c: 'azerty', # French (incl. Belgian)
54
+ 0x13: 'azerty', # Dutch; Belgian is AZERTY
55
+ 0x07: 'qwertz', 0x05: 'qwertz', 0x0e: 'qwertz', # German, Czech, Hungarian
56
+ 0x1b: 'qwertz', 0x24: 'qwertz', 0x1a: 'qwertz', # Slovak, Slovenian, Croatian
57
+ }
58
+
59
+
60
+ def _family_from_name(name: str) -> str | None:
61
+ """Map a human layout name ("ABC - AZERTY", "Swiss German") to a family."""
62
+ low = name.lower()
63
+ for needle, family in _NAME_FAMILIES:
64
+ if needle in low:
65
+ return family
66
+ return None
67
+
68
+
69
+ def _detect_macos() -> str | None:
70
+ """The selected input source, straight out of the HIToolbox preferences.
71
+
72
+ Read with plistlib rather than shelling out to `defaults`: no subprocess on
73
+ startup, and no dependency on pyobjc for the Carbon TIS API.
74
+ """
75
+ import plistlib
76
+ path = os.path.expanduser("~/Library/Preferences/com.apple.HIToolbox.plist")
77
+ with open(path, 'rb') as fh:
78
+ prefs = plistlib.load(fh)
79
+ for source in prefs.get('AppleSelectedInputSources', []):
80
+ if source.get('InputSourceKind') != 'Keyboard Layout':
81
+ continue
82
+ # A selected source can be one with no letter arrangement of its own:
83
+ # "Unicode Hex Input" is US letters with hex entry bolted on. Unnamed
84
+ # families fall through to QWERTY rather than guessing from whatever
85
+ # else the user happens to have enabled.
86
+ return _family_from_name(str(source.get('KeyboardLayout Name', '')))
87
+ return None
88
+
89
+
90
+ def _detect_linux() -> str | None:
91
+ """XKB layout: the X server first, then systemd, then the Debian default."""
92
+ import subprocess
93
+
94
+ for cmd, key in ((['setxkbmap', '-query'], 'layout:'),
95
+ (['localectl', 'status'], 'x11 layout:')):
96
+ try:
97
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
98
+ except (OSError, subprocess.SubprocessError):
99
+ continue
100
+ for line in out.splitlines():
101
+ low = line.strip().lower()
102
+ if low.startswith(key):
103
+ # "layout: gb,fr": the first is the active one.
104
+ code = low[len(key):].strip().split(',')[0].strip()
105
+ if code:
106
+ return _XKB_FAMILIES.get(code, DEFAULT)
107
+
108
+ try:
109
+ with open('/etc/default/keyboard') as fh:
110
+ for line in fh:
111
+ if line.startswith('XKBLAYOUT='):
112
+ code = line.split('=', 1)[1].strip().strip('"\'').split(',')[0]
113
+ if code:
114
+ return _XKB_FAMILIES.get(code, DEFAULT)
115
+ except OSError:
116
+ pass
117
+ return None
118
+
119
+
120
+ def _detect_windows() -> str | None:
121
+ """Active keyboard layout of the foreground thread, via user32.
122
+
123
+ The HKL's low word is a language id, which is as far as this gets: it names
124
+ French but cannot tell US Dvorak from US QWERTY (that lives in the registry,
125
+ keyed by device). Dvorak and Colemak users on Windows want the env var.
126
+ """
127
+ import ctypes
128
+ hkl = ctypes.windll.user32.GetKeyboardLayout(0) # type: ignore[attr-defined]
129
+ return _WIN_FAMILIES.get(hkl & 0xff, DEFAULT)
130
+
131
+
132
+ def detect() -> str:
133
+ """Name the keyboard family in front of the user. Never raises."""
134
+ override = (os.environ.get('BACKTRACK_KEYBOARD') or '').strip().lower()
135
+ if override in LAYOUTS:
136
+ return override
137
+
138
+ detector = ({'darwin': _detect_macos, 'win32': _detect_windows}
139
+ .get(sys.platform, _detect_linux))
140
+ try:
141
+ return detector() or DEFAULT
142
+ except Exception:
143
+ return DEFAULT
144
+
145
+
146
+ def rows(name: str | None = None) -> tuple[str, ...]:
147
+ """Key rows for a layout family, or for the detected one."""
148
+ return LAYOUTS.get(name or detect(), LAYOUTS[DEFAULT])