reqdeck 0.1.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.
- reqdeck/__init__.py +3 -0
- reqdeck/__main__.py +7 -0
- reqdeck/cli.py +706 -0
- reqdeck/config.py +185 -0
- reqdeck/editor.py +51 -0
- reqdeck/init.py +317 -0
- reqdeck/markers.py +65 -0
- reqdeck/model.py +393 -0
- reqdeck/ops.py +124 -0
- reqdeck/questions.py +62 -0
- reqdeck/render.py +140 -0
- reqdeck/skills/reqdeck-cli/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/references/markers.md +92 -0
- reqdeck/skills/reqdeck-method/references/writing-requirements.md +125 -0
- reqdeck/store.py +186 -0
- reqdeck/templates/OPEN-QUESTIONS.md +23 -0
- reqdeck/themes/README.md +31 -0
- reqdeck/themes/__init__.py +73 -0
- reqdeck/themes/atom-one-dark.toml +27 -0
- reqdeck/themes/atom-one-light.toml +27 -0
- reqdeck/themes/catppuccin-frappe.toml +27 -0
- reqdeck/themes/catppuccin-latte.toml +27 -0
- reqdeck/themes/catppuccin-macchiato.toml +27 -0
- reqdeck/themes/catppuccin-mocha.toml +27 -0
- reqdeck/themes/dracula.toml +27 -0
- reqdeck/themes/flexoki.toml +27 -0
- reqdeck/themes/gruvbox.toml +27 -0
- reqdeck/themes/monokai.toml +27 -0
- reqdeck/themes/nord.toml +27 -0
- reqdeck/themes/rose-pine-dawn.toml +27 -0
- reqdeck/themes/rose-pine-moon.toml +27 -0
- reqdeck/themes/rose-pine.toml +27 -0
- reqdeck/themes/solarized-dark.toml +27 -0
- reqdeck/themes/solarized-light.toml +27 -0
- reqdeck/themes/textual-dark.toml +27 -0
- reqdeck/themes/textual-light.toml +27 -0
- reqdeck/themes/tokyo-night.toml +27 -0
- reqdeck/viewer.py +1452 -0
- reqdeck-0.1.0.dist-info/METADATA +305 -0
- reqdeck-0.1.0.dist-info/RECORD +44 -0
- reqdeck-0.1.0.dist-info/WHEEL +4 -0
- reqdeck-0.1.0.dist-info/entry_points.txt +2 -0
- reqdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Writing requirements
|
|
2
|
+
|
|
3
|
+
## Levels
|
|
4
|
+
|
|
5
|
+
Requirements form a tree. Each level satisfies the one above it, and only that link is
|
|
6
|
+
ever written — the reverse is derived by the tool, so the two directions cannot disagree.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
A-01, B-02 user stories the root everything traces to
|
|
10
|
+
↑ satisfies
|
|
11
|
+
TSR_001 system what the product must do
|
|
12
|
+
↑ satisfies
|
|
13
|
+
ICD_BUS_001 interface behaviour at a boundary between parts
|
|
14
|
+
TSSR_FWN_001 sub-system what one part must do
|
|
15
|
+
↑ @REQ_REF
|
|
16
|
+
code and tests
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The levels are configured in `.reqdeck.toml`; `reqdeck levels` shows them. The setup offers
|
|
20
|
+
three shapes:
|
|
21
|
+
|
|
22
|
+
| Shape | Levels | Fits |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| 1 | Story → System (`TSR`) → Sub-system (`TSSR_<unit>`) + Interface (`ICD_<if>`) | A product of several parts that talk to each other — firmware, hardware, an app |
|
|
25
|
+
| 2 | Story → System → Sub-system | Several parts, no boundary worth specifying on its own |
|
|
26
|
+
| 3 | Story → Requirement (`REQ`) | One app or one component |
|
|
27
|
+
|
|
28
|
+
**Which level a requirement belongs to:**
|
|
29
|
+
|
|
30
|
+
- **System** — what the product must do, independent of how it is built. A user could
|
|
31
|
+
observe it. "The system shall support at least 50 independently controlled LEDs per
|
|
32
|
+
model." No part named.
|
|
33
|
+
- **Interface** — behaviour at a boundary, which both sides must agree on: a protocol
|
|
34
|
+
field, a command, an API endpoint, a connector pinout. Written once, satisfied by the
|
|
35
|
+
sub-system requirements of both sides.
|
|
36
|
+
- **Sub-system** — what one part must do to meet the levels above. Names the part. "The
|
|
37
|
+
Node shall drive each channel with 12-bit PWM at 1 kHz."
|
|
38
|
+
|
|
39
|
+
Each sub-system or interface prefix is one *unit*: one CSV, one `.toml`. A new unit is a
|
|
40
|
+
new part of the design — agree it with the user before `reqdeck unit`.
|
|
41
|
+
|
|
42
|
+
A level with `traced = false` (typically the system level) is never referenced from code.
|
|
43
|
+
It is traced through what satisfies it, and a test against a lower requirement verifies
|
|
44
|
+
everything above it.
|
|
45
|
+
|
|
46
|
+
## The fields
|
|
47
|
+
|
|
48
|
+
| Field | |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `id` | Allocated by `reqdeck add`. Sequential per prefix, append-only, never reused |
|
|
51
|
+
| `title` | A few words |
|
|
52
|
+
| `statement` | One sentence with "shall". The obligation, nothing else |
|
|
53
|
+
| `rationale` | Optional, one line — only where the reason is not obvious |
|
|
54
|
+
| `satisfies` | IDs one level up. Empty is legitimate but rare, and then the rationale says why |
|
|
55
|
+
| `topic` | The OPEN-QUESTIONS topic that decided it, on levels that require one |
|
|
56
|
+
| `source` | What decided it: `OQ-0608.`, `ADR-0007.`, `Chosen 2026-07-26.` Always present |
|
|
57
|
+
| `verification` | How it will be shown to be met — one code, see below |
|
|
58
|
+
| `status` | `Proposed`, `Accepted` or `Reopened` |
|
|
59
|
+
| `security` | `Yes` where it carries a security property; empty otherwise |
|
|
60
|
+
| `section` | Where it is shown in its unit's document, a key from the `.toml` |
|
|
61
|
+
|
|
62
|
+
Multi-line detail — a byte layout, a state table — goes in `<unit>.details.md` under
|
|
63
|
+
`## <ID>`. It is part of the requirement and shown with it.
|
|
64
|
+
|
|
65
|
+
## The statement
|
|
66
|
+
|
|
67
|
+
- **One obligation.** Two "shall"s joined by "and" are usually two requirements. Split
|
|
68
|
+
them unless they cannot be verified separately.
|
|
69
|
+
- **Verifiable.** Someone must be able to say pass or fail. "Fast", "robust" and
|
|
70
|
+
"user-friendly" are not; a number, an observable behaviour or an inspectable property is.
|
|
71
|
+
- **Every number chosen by someone.** If the user did not choose it and no answered
|
|
72
|
+
question fixes it, it is an open question.
|
|
73
|
+
- **What, not how**, at the system level. Sub-system requirements may name a mechanism
|
|
74
|
+
when the mechanism *is* the decision.
|
|
75
|
+
- **No reasoning in the statement.** The why goes in `rationale`, or stays in the answered
|
|
76
|
+
question that `source` points at.
|
|
77
|
+
- **Name the part.** "The Node shall", "The app shall", "The system shall".
|
|
78
|
+
|
|
79
|
+
## Linking
|
|
80
|
+
|
|
81
|
+
`satisfies` names what the requirement exists to fulfil, one level up — the link `check`
|
|
82
|
+
enforces against the levels in `.reqdeck.toml`. Look for the parent before leaving it
|
|
83
|
+
empty; an empty link must not mean the parent was never looked for. A requirement driven
|
|
84
|
+
by a constraint rather than a user need — a risk, a regulation — may genuinely have none,
|
|
85
|
+
and then its rationale says so.
|
|
86
|
+
|
|
87
|
+
## Verification methods
|
|
88
|
+
|
|
89
|
+
Configured per project (`reqdeck verification`). The common set:
|
|
90
|
+
|
|
91
|
+
| | | |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| **A** | Analysis | Calculation, modelling or simulation — a worst-case budget, a timing margin |
|
|
94
|
+
| **I** | Inspection | Examination of the design, drawings or source without operating it |
|
|
95
|
+
| **D** | Demonstration | A person operates the system and observes, with no instrument |
|
|
96
|
+
| **T** | Test | An automated test |
|
|
97
|
+
|
|
98
|
+
Embedded projects often split `T` into **U** unit test (host, hardware mocked), **S**
|
|
99
|
+
integration test (real silicon, driven by a rig) and **M** manual test (instruments, a
|
|
100
|
+
person). The split records *where* a requirement can be signed off, which is what says
|
|
101
|
+
when it can be.
|
|
102
|
+
|
|
103
|
+
A method marked `automated` makes the requirement untraced until a test names it.
|
|
104
|
+
Two distinctions decide most cases: **D against M** — the difference is an instrument;
|
|
105
|
+
**U against S** — logic is U, what a device actually does is S.
|
|
106
|
+
|
|
107
|
+
## Status
|
|
108
|
+
|
|
109
|
+
`Proposed` — written, not yet read by the user. `Accepted` — reviewed and agreed.
|
|
110
|
+
`Reopened` — was accepted, then changed, so it needs reading again. An agent writes
|
|
111
|
+
`Proposed` (the only thing `reqdeck add` can write); `reqdeck edit` on an accepted requirement
|
|
112
|
+
makes it `Reopened`, and the change and its reason go in `source`. Only the user accepts.
|
|
113
|
+
|
|
114
|
+
## Security relevance
|
|
115
|
+
|
|
116
|
+
`security = Yes` marks a requirement carrying a security property, so the set is
|
|
117
|
+
enumerable for a conformance argument (`reqdeck list --security`). Judge by whether an attacker
|
|
118
|
+
is in the story: a CRC against a marginal cable is not a security control, and marking it
|
|
119
|
+
would misdescribe it.
|
|
120
|
+
|
|
121
|
+
## Sections
|
|
122
|
+
|
|
123
|
+
Sections are presentation, not meaning. Group requirements by subject so a unit reads as
|
|
124
|
+
a document; the section key is stable and its number is derived, so restructuring a
|
|
125
|
+
document never touches a requirement's ID or row.
|
reqdeck/store.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The files requirements live in.
|
|
2
|
+
|
|
3
|
+
One *unit* per prefix, as three files side by side in the requirements folder:
|
|
4
|
+
|
|
5
|
+
firmware-node.csv the requirements, one row each, ordered by ID
|
|
6
|
+
firmware-node.toml the unit: level, prefix, title, sections
|
|
7
|
+
firmware-node.details.md optional multi-line detail, one `## <ID>` each
|
|
8
|
+
|
|
9
|
+
Rows are only ever appended, so a row's line number is as permanent as its ID.
|
|
10
|
+
Where a requirement is *shown* is its `section`, a stable key declared in the
|
|
11
|
+
`.toml`; section numbers are derived from declaration order when rendering, so
|
|
12
|
+
inserting a section renumbers the document without touching a single row.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import io
|
|
19
|
+
import re
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: Columns reqdeck understands, in the order a new file gets them. Any other
|
|
25
|
+
#: column is kept, shown, and otherwise left alone.
|
|
26
|
+
STANDARD = ["id", "section", "title", "statement", "rationale", "satisfies", "topic",
|
|
27
|
+
"source", "verification", "status", "security"]
|
|
28
|
+
STORY_COLUMNS = ["id", "section", "statement"]
|
|
29
|
+
|
|
30
|
+
DETAIL_HEADING = re.compile(r"^## (\S+)[ \t]*$", re.M)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Section:
|
|
35
|
+
key: str
|
|
36
|
+
title: str
|
|
37
|
+
description: str = ""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def parent(self) -> str:
|
|
41
|
+
return self.key.rpartition(".")[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Unit:
|
|
46
|
+
stem: str
|
|
47
|
+
level: str
|
|
48
|
+
prefixes: list[str]
|
|
49
|
+
title: str
|
|
50
|
+
description: str
|
|
51
|
+
code: bool
|
|
52
|
+
sections: list[Section]
|
|
53
|
+
csv_path: Path
|
|
54
|
+
toml_path: Path
|
|
55
|
+
details_path: Path
|
|
56
|
+
header: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def section(self, key: str) -> Section | None:
|
|
59
|
+
return next((s for s in self.sections if s.key == key), None)
|
|
60
|
+
|
|
61
|
+
def numbering(self) -> dict[str, str]:
|
|
62
|
+
"""Section key to its displayed number, from declaration order."""
|
|
63
|
+
out: dict[str, str] = {}
|
|
64
|
+
counters: dict[str, int] = {}
|
|
65
|
+
for s in self.sections:
|
|
66
|
+
counters[s.parent] = counters.get(s.parent, 0) + 1
|
|
67
|
+
base = out.get(s.parent, "")
|
|
68
|
+
out[s.key] = f"{base}.{counters[s.parent]}" if base else str(counters[s.parent])
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Row:
|
|
74
|
+
values: dict[str, str]
|
|
75
|
+
line: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StoreError(Exception):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def units(req_dir: Path) -> list[Unit]:
|
|
83
|
+
out = []
|
|
84
|
+
for toml_path in sorted(req_dir.glob("*.toml")):
|
|
85
|
+
stem = toml_path.stem
|
|
86
|
+
try:
|
|
87
|
+
meta = tomllib.loads(toml_path.read_text())
|
|
88
|
+
except tomllib.TOMLDecodeError as e:
|
|
89
|
+
raise StoreError(f"{toml_path}: {e}") from e
|
|
90
|
+
prefixes = meta.get("prefixes") or ([meta["prefix"]] if "prefix" in meta else [])
|
|
91
|
+
sections = [Section(s.get("key", ""), s.get("title", ""), s.get("description", "").strip())
|
|
92
|
+
for s in meta.get("sections", [])]
|
|
93
|
+
out.append(Unit(
|
|
94
|
+
stem=stem, level=meta.get("level", ""), prefixes=list(prefixes),
|
|
95
|
+
title=meta.get("title", stem), description=meta.get("description", "").strip(),
|
|
96
|
+
code=bool(meta.get("code", False)), sections=sections,
|
|
97
|
+
csv_path=req_dir / f"{stem}.csv", toml_path=toml_path,
|
|
98
|
+
details_path=req_dir / f"{stem}.details.md",
|
|
99
|
+
))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_rows(unit: Unit) -> list[Row]:
|
|
104
|
+
if not unit.csv_path.exists():
|
|
105
|
+
raise StoreError(f"{unit.toml_path.name} has no {unit.csv_path.name} beside it")
|
|
106
|
+
with unit.csv_path.open(newline="") as f:
|
|
107
|
+
reader = csv.reader(f)
|
|
108
|
+
try:
|
|
109
|
+
unit.header = next(reader)
|
|
110
|
+
except StopIteration:
|
|
111
|
+
raise StoreError(f"{unit.csv_path.name} is empty — it needs at least a header row")
|
|
112
|
+
rows = []
|
|
113
|
+
for raw in reader:
|
|
114
|
+
if not any(c.strip() for c in raw):
|
|
115
|
+
continue
|
|
116
|
+
if len(raw) != len(unit.header):
|
|
117
|
+
raise StoreError(f"{unit.csv_path.name}:{reader.line_num}: {len(raw)} fields, "
|
|
118
|
+
f"header has {len(unit.header)}")
|
|
119
|
+
rows.append(Row(dict(zip(unit.header, raw)), reader.line_num))
|
|
120
|
+
return rows
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_details(unit: Unit) -> list[tuple[str, str, int]]:
|
|
124
|
+
"""(id, markdown, line) for every `## <ID>` block in the details file."""
|
|
125
|
+
if not unit.details_path.exists():
|
|
126
|
+
return []
|
|
127
|
+
text = unit.details_path.read_text()
|
|
128
|
+
heads = list(DETAIL_HEADING.finditer(text))
|
|
129
|
+
out = []
|
|
130
|
+
for i, h in enumerate(heads):
|
|
131
|
+
end = heads[i + 1].start() if i + 1 < len(heads) else len(text)
|
|
132
|
+
out.append((h.group(1), text[h.end():end].strip("\n"), text[:h.start()].count("\n") + 1))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def render_csv(header: list[str], rows: list[dict[str, str]]) -> str:
|
|
137
|
+
buf = io.StringIO()
|
|
138
|
+
w = csv.writer(buf, lineterminator="\n")
|
|
139
|
+
w.writerow(header)
|
|
140
|
+
for r in rows:
|
|
141
|
+
w.writerow([r.get(c, "") for c in header])
|
|
142
|
+
return buf.getvalue()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_rows(unit: Unit, rows: list[dict[str, str]]) -> None:
|
|
146
|
+
unit.csv_path.write_text(render_csv(unit.header, rows))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def append_row(unit: Unit, row: dict[str, str]) -> int:
|
|
150
|
+
"""Append one row and return its line number."""
|
|
151
|
+
text = unit.csv_path.read_text()
|
|
152
|
+
if text and not text.endswith("\n"):
|
|
153
|
+
text += "\n"
|
|
154
|
+
line = render_csv(unit.header, [row]).split("\n", 1)[1]
|
|
155
|
+
unit.csv_path.write_text(text + line)
|
|
156
|
+
return text.count("\n") + 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_canonical(unit: Unit, rows: list[Row]) -> bool:
|
|
160
|
+
"""Whether the file is byte-for-byte what reqdeck itself would write."""
|
|
161
|
+
return unit.csv_path.read_text() == render_csv(unit.header, [r.values for r in rows])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def toml_str(s: str) -> str:
|
|
165
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_unit(req_dir: Path, stem: str, level: str, prefixes: list[str], title: str,
|
|
169
|
+
code: bool, columns: list[str]) -> Unit:
|
|
170
|
+
csv_path, toml_path = req_dir / f"{stem}.csv", req_dir / f"{stem}.toml"
|
|
171
|
+
for p in (csv_path, toml_path):
|
|
172
|
+
if p.exists():
|
|
173
|
+
raise StoreError(f"{p} already exists")
|
|
174
|
+
req_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
prefix_line = (f"prefix = {toml_str(prefixes[0])}" if len(prefixes) == 1
|
|
176
|
+
else f"prefixes = [{', '.join(toml_str(p) for p in prefixes)}]")
|
|
177
|
+
lines = [f"level = {toml_str(level)}", prefix_line, f"title = {toml_str(title)}"]
|
|
178
|
+
if code:
|
|
179
|
+
lines.append("code = true")
|
|
180
|
+
lines += ['description = """', '"""', "",
|
|
181
|
+
"# Sections, in the order they are shown. A key with a dot nests under",
|
|
182
|
+
"# the key before it: `power.bus` is a subsection of `power`.",
|
|
183
|
+
"#", "# [[sections]]", '# key = "power"', '# title = "Power"', ""]
|
|
184
|
+
toml_path.write_text("\n".join(lines))
|
|
185
|
+
csv_path.write_text(render_csv(columns, []))
|
|
186
|
+
return next(u for u in units(req_dir) if u.stem == stem)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Open Questions
|
|
2
|
+
|
|
3
|
+
Everything not yet decided. One question per `###` heading, fields on single lines.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
### <ID> — <short title>
|
|
7
|
+
**Question:** <what has to be decided>
|
|
8
|
+
**Answer:** <blank until decided>
|
|
9
|
+
**Blocks:** <what is waiting on it, or —>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
IDs are `OQ-TTNN` — `TT` is the topic number, `NN` the question within it. To add a
|
|
13
|
+
question, take the next free `NN` in that topic. **Questions never move.** When one is
|
|
14
|
+
answered, fill in `Answer:` and leave it where it is.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Topic 01 — Product scope
|
|
19
|
+
|
|
20
|
+
### OQ-0101 — Are the user stories real?
|
|
21
|
+
**Question:** Which of the user stories come from a real person's need, which are guesses, and which are missing?
|
|
22
|
+
**Answer:**
|
|
23
|
+
**Blocks:** Everything traces to the stories
|
reqdeck/themes/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Themes
|
|
2
|
+
|
|
3
|
+
One file per theme, named after the [Textual theme](https://textual.textualize.io/guide/design/)
|
|
4
|
+
it extends. Textual supplies the base colours; the file adds every colour reqdeck draws
|
|
5
|
+
with, taken from that theme's own palette, in the roles below. Nothing paints a
|
|
6
|
+
background — the terminal's own shows through — so a role is a text or border colour,
|
|
7
|
+
except `selection`, the one filled row.
|
|
8
|
+
|
|
9
|
+
| Role | Draws |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `foreground` | Ordinary text |
|
|
12
|
+
| `muted` | Field labels, rationale, guides, the key hint line |
|
|
13
|
+
| `border` | Every panel border |
|
|
14
|
+
| `border_active` | The focused panel's border |
|
|
15
|
+
| `border_search` | The tree's border while a search filters it |
|
|
16
|
+
| `selection` | The cursor row in the focused panel |
|
|
17
|
+
| `title` | Panel titles, the unit and level names in the tree |
|
|
18
|
+
| `heading` | Section headings and numbers in a document |
|
|
19
|
+
| `id` | A requirement or question ID |
|
|
20
|
+
| `link` | An ID in running text, which can be followed |
|
|
21
|
+
| `link_active` | The link under the keyboard cursor, and the preview's border |
|
|
22
|
+
| `code` | Inline code — byte values, command codes |
|
|
23
|
+
| `table_header` | A table's header row |
|
|
24
|
+
| `statement_bar` | The bar beside a requirement's statement |
|
|
25
|
+
| `accepted` `proposed` `reopened` | Review status |
|
|
26
|
+
| `security` | The security-relevant marker |
|
|
27
|
+
| `open` `answered` | An open question's state |
|
|
28
|
+
| `keys` | Key names in the hint line |
|
|
29
|
+
|
|
30
|
+
A theme in `~/.config/reqdeck/themes/` is loaded as well, and replaces a bundled one of the
|
|
31
|
+
same name. Its `extends` names the Textual theme to start from.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Themes: Textual's own, each given reqdeck's full colour suite by a file in
|
|
2
|
+
this folder. See README.md for the roles."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from textual.theme import BUILTIN_THEMES, Theme
|
|
14
|
+
|
|
15
|
+
ROLES = ("foreground", "muted", "border", "border_active", "border_search", "selection",
|
|
16
|
+
"title", "heading", "id", "link", "link_active", "code", "table_header",
|
|
17
|
+
"statement_bar", "accepted", "proposed", "reopened", "security", "open",
|
|
18
|
+
"answered", "keys")
|
|
19
|
+
HEX = re.compile(r"#[0-9a-fA-F]{6}")
|
|
20
|
+
DEFAULT = "dracula"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ThemeError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Palette:
|
|
29
|
+
name: str
|
|
30
|
+
title: str
|
|
31
|
+
extends: str
|
|
32
|
+
colors: dict[str, str]
|
|
33
|
+
|
|
34
|
+
def theme(self) -> Theme:
|
|
35
|
+
"""The Textual theme, with every role as a `$rk-<role>` CSS variable."""
|
|
36
|
+
base = BUILTIN_THEMES[self.extends]
|
|
37
|
+
variables = {**base.variables, **{f"rk-{k.replace('_', '-')}": v for k, v in self.colors.items()}}
|
|
38
|
+
return dataclasses.replace(base, name=self.name, variables=variables)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def user_dir() -> Path:
|
|
42
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
43
|
+
return Path(base) / "reqdeck" / "themes"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse(path: Path) -> Palette:
|
|
47
|
+
try:
|
|
48
|
+
data = tomllib.loads(path.read_text())
|
|
49
|
+
except tomllib.TOMLDecodeError as e:
|
|
50
|
+
raise ThemeError(f"{path}: {e}") from e
|
|
51
|
+
name = data.get("name", path.stem)
|
|
52
|
+
extends = data.get("extends", name)
|
|
53
|
+
if extends not in BUILTIN_THEMES:
|
|
54
|
+
raise ThemeError(f"{path}: extends {extends!r}, which is not a Textual theme")
|
|
55
|
+
colors = data.get("colors", {})
|
|
56
|
+
missing = [r for r in ROLES if r not in colors]
|
|
57
|
+
unknown = [r for r in colors if r not in ROLES]
|
|
58
|
+
bad = [r for r, v in colors.items() if not HEX.fullmatch(str(v))]
|
|
59
|
+
for what, items in (("missing", missing), ("unknown", unknown), ("not #rrggbb", bad)):
|
|
60
|
+
if items:
|
|
61
|
+
raise ThemeError(f"{path}: {what}: {', '.join(items)}")
|
|
62
|
+
return Palette(name, data.get("title", name), extends, dict(colors))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_all() -> dict[str, Palette]:
|
|
66
|
+
"""Bundled themes, then the user's, which win on a name clash."""
|
|
67
|
+
out: dict[str, Palette] = {}
|
|
68
|
+
dirs = [Path(__file__).parent, user_dir()]
|
|
69
|
+
for d in dirs:
|
|
70
|
+
for path in sorted(d.glob("*.toml")) if d.is_dir() else []:
|
|
71
|
+
p = parse(path)
|
|
72
|
+
out[p.name] = p
|
|
73
|
+
return out
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# One Dark — https://github.com/atom/one-dark-syntax
|
|
2
|
+
|
|
3
|
+
name = "atom-one-dark"
|
|
4
|
+
title = "One Dark"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#abb2bf" # mono-1
|
|
8
|
+
muted = "#7f848e" # mono-2
|
|
9
|
+
border = "#4b5263" # gutter
|
|
10
|
+
border_active = "#61afef" # blue
|
|
11
|
+
border_search = "#56b6c2" # cyan
|
|
12
|
+
selection = "#3e4451" # selection
|
|
13
|
+
title = "#61afef" # blue
|
|
14
|
+
heading = "#c678dd" # purple
|
|
15
|
+
id = "#56b6c2" # cyan
|
|
16
|
+
link = "#61afef" # blue
|
|
17
|
+
link_active = "#d19a66" # orange
|
|
18
|
+
code = "#98c379" # green
|
|
19
|
+
table_header = "#e5c07b" # yellow
|
|
20
|
+
statement_bar = "#c678dd" # purple
|
|
21
|
+
accepted = "#98c379" # green
|
|
22
|
+
proposed = "#d19a66" # orange
|
|
23
|
+
reopened = "#e06c75" # red
|
|
24
|
+
security = "#c678dd" # purple
|
|
25
|
+
open = "#e06c75" # red
|
|
26
|
+
answered = "#98c379" # green
|
|
27
|
+
keys = "#c678dd" # purple
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# One Light — https://github.com/atom/one-light-syntax
|
|
2
|
+
|
|
3
|
+
name = "atom-one-light"
|
|
4
|
+
title = "One Light"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#383a42" # mono-1
|
|
8
|
+
muted = "#a0a1a7" # mono-3
|
|
9
|
+
border = "#c8c8ca" # border
|
|
10
|
+
border_active = "#4078f2" # blue
|
|
11
|
+
border_search = "#0184bc" # cyan
|
|
12
|
+
selection = "#e5e5e6" # selection
|
|
13
|
+
title = "#4078f2" # blue
|
|
14
|
+
heading = "#a626a4" # purple
|
|
15
|
+
id = "#0184bc" # cyan
|
|
16
|
+
link = "#4078f2" # blue
|
|
17
|
+
link_active = "#986801" # orange
|
|
18
|
+
code = "#50a14f" # green
|
|
19
|
+
table_header = "#c18401" # yellow
|
|
20
|
+
statement_bar = "#a626a4" # purple
|
|
21
|
+
accepted = "#50a14f" # green
|
|
22
|
+
proposed = "#c18401" # yellow
|
|
23
|
+
reopened = "#e45649" # red
|
|
24
|
+
security = "#a626a4" # purple
|
|
25
|
+
open = "#e45649" # red
|
|
26
|
+
answered = "#50a14f" # green
|
|
27
|
+
keys = "#a626a4" # purple
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Catppuccin Frappé — https://catppuccin.com/palette
|
|
2
|
+
|
|
3
|
+
name = "catppuccin-frappe"
|
|
4
|
+
title = "Catppuccin Frappé"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#c6d0f5" # text
|
|
8
|
+
muted = "#838ba7" # overlay1
|
|
9
|
+
border = "#626880" # surface2
|
|
10
|
+
border_active = "#ca9ee6" # mauve
|
|
11
|
+
border_search = "#e5c890" # yellow
|
|
12
|
+
selection = "#414559" # surface0
|
|
13
|
+
title = "#ca9ee6" # mauve
|
|
14
|
+
heading = "#8caaee" # blue
|
|
15
|
+
id = "#85c1dc" # sapphire
|
|
16
|
+
link = "#99d1db" # sky
|
|
17
|
+
link_active = "#ef9f76" # peach
|
|
18
|
+
code = "#ef9f76" # peach
|
|
19
|
+
table_header = "#babbf1" # lavender
|
|
20
|
+
statement_bar = "#ca9ee6" # mauve
|
|
21
|
+
accepted = "#a6d189" # green
|
|
22
|
+
proposed = "#e5c890" # yellow
|
|
23
|
+
reopened = "#e78284" # red
|
|
24
|
+
security = "#f4b8e4" # pink
|
|
25
|
+
open = "#ea999c" # maroon
|
|
26
|
+
answered = "#a6d189" # green
|
|
27
|
+
keys = "#ca9ee6" # mauve
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Catppuccin Latte — https://catppuccin.com/palette
|
|
2
|
+
|
|
3
|
+
name = "catppuccin-latte"
|
|
4
|
+
title = "Catppuccin Latte"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#4c4f69" # text
|
|
8
|
+
muted = "#8c8fa1" # overlay1
|
|
9
|
+
border = "#acb0be" # surface2
|
|
10
|
+
border_active = "#8839ef" # mauve
|
|
11
|
+
border_search = "#df8e1d" # yellow
|
|
12
|
+
selection = "#ccd0da" # surface0
|
|
13
|
+
title = "#8839ef" # mauve
|
|
14
|
+
heading = "#1e66f5" # blue
|
|
15
|
+
id = "#209fb5" # sapphire
|
|
16
|
+
link = "#04a5e5" # sky
|
|
17
|
+
link_active = "#fe640b" # peach
|
|
18
|
+
code = "#fe640b" # peach
|
|
19
|
+
table_header = "#7287fd" # lavender
|
|
20
|
+
statement_bar = "#8839ef" # mauve
|
|
21
|
+
accepted = "#40a02b" # green
|
|
22
|
+
proposed = "#df8e1d" # yellow
|
|
23
|
+
reopened = "#d20f39" # red
|
|
24
|
+
security = "#ea76cb" # pink
|
|
25
|
+
open = "#e64553" # maroon
|
|
26
|
+
answered = "#40a02b" # green
|
|
27
|
+
keys = "#8839ef" # mauve
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Catppuccin Macchiato — https://catppuccin.com/palette
|
|
2
|
+
|
|
3
|
+
name = "catppuccin-macchiato"
|
|
4
|
+
title = "Catppuccin Macchiato"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#cad3f5" # text
|
|
8
|
+
muted = "#8087a2" # overlay1
|
|
9
|
+
border = "#5b6078" # surface2
|
|
10
|
+
border_active = "#c6a0f6" # mauve
|
|
11
|
+
border_search = "#eed49f" # yellow
|
|
12
|
+
selection = "#363a4f" # surface0
|
|
13
|
+
title = "#c6a0f6" # mauve
|
|
14
|
+
heading = "#8aadf4" # blue
|
|
15
|
+
id = "#7dc4e4" # sapphire
|
|
16
|
+
link = "#91d7e3" # sky
|
|
17
|
+
link_active = "#f5a97f" # peach
|
|
18
|
+
code = "#f5a97f" # peach
|
|
19
|
+
table_header = "#b7bdf8" # lavender
|
|
20
|
+
statement_bar = "#c6a0f6" # mauve
|
|
21
|
+
accepted = "#a6da95" # green
|
|
22
|
+
proposed = "#eed49f" # yellow
|
|
23
|
+
reopened = "#ed8796" # red
|
|
24
|
+
security = "#f5bde6" # pink
|
|
25
|
+
open = "#ee99a0" # maroon
|
|
26
|
+
answered = "#a6da95" # green
|
|
27
|
+
keys = "#c6a0f6" # mauve
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Catppuccin Mocha — https://catppuccin.com/palette
|
|
2
|
+
|
|
3
|
+
name = "catppuccin-mocha"
|
|
4
|
+
title = "Catppuccin Mocha"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#cdd6f4" # text
|
|
8
|
+
muted = "#7f849c" # overlay1
|
|
9
|
+
border = "#585b70" # surface2
|
|
10
|
+
border_active = "#cba6f7" # mauve
|
|
11
|
+
border_search = "#f9e2af" # yellow
|
|
12
|
+
selection = "#313244" # surface0
|
|
13
|
+
title = "#cba6f7" # mauve
|
|
14
|
+
heading = "#89b4fa" # blue
|
|
15
|
+
id = "#74c7ec" # sapphire
|
|
16
|
+
link = "#89dceb" # sky
|
|
17
|
+
link_active = "#fab387" # peach
|
|
18
|
+
code = "#fab387" # peach
|
|
19
|
+
table_header = "#b4befe" # lavender
|
|
20
|
+
statement_bar = "#cba6f7" # mauve
|
|
21
|
+
accepted = "#a6e3a1" # green
|
|
22
|
+
proposed = "#f9e2af" # yellow
|
|
23
|
+
reopened = "#f38ba8" # red
|
|
24
|
+
security = "#f5c2e7" # pink
|
|
25
|
+
open = "#eba0ac" # maroon
|
|
26
|
+
answered = "#a6e3a1" # green
|
|
27
|
+
keys = "#cba6f7" # mauve
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Dracula — https://draculatheme.com/contribute
|
|
2
|
+
|
|
3
|
+
name = "dracula"
|
|
4
|
+
title = "Dracula"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#f8f8f2" # foreground
|
|
8
|
+
muted = "#6272a4" # comment
|
|
9
|
+
border = "#bd93f9" # purple
|
|
10
|
+
border_active = "#ff79c6" # pink
|
|
11
|
+
border_search = "#8be9fd" # cyan
|
|
12
|
+
selection = "#44475a" # current-line
|
|
13
|
+
title = "#ff79c6" # pink
|
|
14
|
+
heading = "#bd93f9" # purple
|
|
15
|
+
id = "#8be9fd" # cyan
|
|
16
|
+
link = "#8be9fd" # cyan
|
|
17
|
+
link_active = "#ff79c6" # pink
|
|
18
|
+
code = "#f1fa8c" # yellow
|
|
19
|
+
table_header = "#bd93f9" # purple
|
|
20
|
+
statement_bar = "#ff79c6" # pink
|
|
21
|
+
accepted = "#50fa7b" # green
|
|
22
|
+
proposed = "#ffb86c" # orange
|
|
23
|
+
reopened = "#ff5555" # red
|
|
24
|
+
security = "#ff79c6" # pink
|
|
25
|
+
open = "#ff5555" # red
|
|
26
|
+
answered = "#50fa7b" # green
|
|
27
|
+
keys = "#ff79c6" # pink
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Flexoki — https://stephango.com/flexoki
|
|
2
|
+
|
|
3
|
+
name = "flexoki"
|
|
4
|
+
title = "Flexoki"
|
|
5
|
+
|
|
6
|
+
[colors]
|
|
7
|
+
foreground = "#cecdc3" # tx
|
|
8
|
+
muted = "#878580" # tx-2
|
|
9
|
+
border = "#403e3c" # ui-3
|
|
10
|
+
border_active = "#da702c" # orange
|
|
11
|
+
border_search = "#3aa99f" # cyan
|
|
12
|
+
selection = "#343331" # ui-2
|
|
13
|
+
title = "#da702c" # orange
|
|
14
|
+
heading = "#8b7ec8" # purple
|
|
15
|
+
id = "#3aa99f" # cyan
|
|
16
|
+
link = "#4385be" # blue
|
|
17
|
+
link_active = "#da702c" # orange
|
|
18
|
+
code = "#d0a215" # yellow
|
|
19
|
+
table_header = "#8b7ec8" # purple
|
|
20
|
+
statement_bar = "#ce5d97" # magenta
|
|
21
|
+
accepted = "#879a39" # green
|
|
22
|
+
proposed = "#d0a215" # yellow
|
|
23
|
+
reopened = "#d14d41" # red
|
|
24
|
+
security = "#ce5d97" # magenta
|
|
25
|
+
open = "#d14d41" # red
|
|
26
|
+
answered = "#879a39" # green
|
|
27
|
+
keys = "#da702c" # orange
|