zecret 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.
- zecret/__init__.py +11 -0
- zecret/__main__.py +41 -0
- zecret/app.py +197 -0
- zecret/app.tcss +288 -0
- zecret/config.py +112 -0
- zecret/crypto.py +192 -0
- zecret/models.py +113 -0
- zecret/py.typed +0 -0
- zecret/screens/__init__.py +1 -0
- zecret/screens/base.py +173 -0
- zecret/screens/confirm.py +46 -0
- zecret/screens/date_prompt.py +85 -0
- zecret/screens/editor.py +162 -0
- zecret/screens/entry_list.py +423 -0
- zecret/screens/header.py +69 -0
- zecret/screens/help.py +169 -0
- zecret/screens/search.py +140 -0
- zecret/screens/settings.py +251 -0
- zecret/screens/unlock.py +147 -0
- zecret/storage.py +610 -0
- zecret-0.1.0.dist-info/METADATA +242 -0
- zecret-0.1.0.dist-info/RECORD +25 -0
- zecret-0.1.0.dist-info/WHEEL +4 -0
- zecret-0.1.0.dist-info/entry_points.txt +2 -0
- zecret-0.1.0.dist-info/licenses/LICENSE +21 -0
zecret/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Zecret: a modern, encrypted terminal diary."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Read from the installed distribution, so pyproject.toml stays the one
|
|
7
|
+
# place the version is written. A second copy here would eventually
|
|
8
|
+
# disagree with it, and the help screen shows this one to the user.
|
|
9
|
+
__version__ = version("zecret")
|
|
10
|
+
except PackageNotFoundError: # pragma: no cover - only when run uninstalled
|
|
11
|
+
__version__ = "0.0.0+unknown"
|
zecret/__main__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CLI entry point: `zecret` launches the Textual app.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
zecret # use default diary path
|
|
5
|
+
zecret --path /custom/diary.enc # override diary location
|
|
6
|
+
(or env var ZECRET_DIARY_PATH)
|
|
7
|
+
|
|
8
|
+
Only the diary is configurable here. Preferences live at a fixed per-user
|
|
9
|
+
path (see config.py): they are settings for the person, not for the file,
|
|
10
|
+
so a diary opened with --path still uses the same theme.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from zecret.app import ZecretApp
|
|
20
|
+
from zecret.storage import DEFAULT_DIARY_PATH
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""Parse args/env, construct ZecretApp, and run it."""
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="zecret", description="A modern, encrypted terminal diary."
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--path",
|
|
30
|
+
type=Path,
|
|
31
|
+
default=Path(os.environ.get("ZECRET_DIARY_PATH", DEFAULT_DIARY_PATH)),
|
|
32
|
+
help="Path to the encrypted diary file (default: ~/.zecret/diary.enc).",
|
|
33
|
+
)
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
|
|
36
|
+
app = ZecretApp(diary_path=args.path)
|
|
37
|
+
app.run()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
zecret/app.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The Zecret Textual application: screen management and app-level state.
|
|
2
|
+
|
|
3
|
+
Screen flow:
|
|
4
|
+
UnlockScreen -> EntryListScreen <-> EditorScreen (one day's entry)
|
|
5
|
+
-> DatePromptScreen -> EditorScreen
|
|
6
|
+
-> SearchScreen
|
|
7
|
+
-> SettingsScreen (theme, password)
|
|
8
|
+
-> HelpScreen
|
|
9
|
+
|
|
10
|
+
The unlocked DiaryFile and derived key live on the App instance for the
|
|
11
|
+
duration of the session and are never persisted in plaintext. Preferences
|
|
12
|
+
(the theme, and how long the diary may sit idle) are the one thing that
|
|
13
|
+
outlives the session, in a separate plaintext file -- see config.py for why
|
|
14
|
+
they are not in the diary.
|
|
15
|
+
|
|
16
|
+
They do not live there indefinitely, though. A diary left open is a diary
|
|
17
|
+
anyone at that terminal can read, so this module watches for a spell of
|
|
18
|
+
quiet and puts it away again: lock() forgets both, tears the screens down
|
|
19
|
+
and goes back to UnlockScreen. Everything to do with that is here rather
|
|
20
|
+
than in a screen, for the same reason routing is -- it is about the session
|
|
21
|
+
as a whole, not about anything on show.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from textual import events
|
|
30
|
+
from textual.app import App
|
|
31
|
+
|
|
32
|
+
from zecret.config import DEFAULT_CONFIG_PATH, DEFAULT_THEME, Config
|
|
33
|
+
from zecret.screens.base import ZecretScreen
|
|
34
|
+
from zecret.screens.entry_list import EntryListScreen
|
|
35
|
+
from zecret.screens.unlock import UnlockScreen
|
|
36
|
+
from zecret.storage import DEFAULT_DIARY_PATH, DiaryFile
|
|
37
|
+
|
|
38
|
+
#: Said when the diary locks itself, so a screen that suddenly wants a
|
|
39
|
+
#: password is not a mystery.
|
|
40
|
+
LOCKED_BY_TIMEOUT = "Locked after a spell of quiet."
|
|
41
|
+
LOCKED_BY_HAND = "Locked."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ZecretApp(App[None]):
|
|
45
|
+
"""Root Textual application for Zecret."""
|
|
46
|
+
|
|
47
|
+
CSS_PATH = "app.tcss"
|
|
48
|
+
TITLE = "Zecret"
|
|
49
|
+
|
|
50
|
+
# No command palette. It offers a search over commands this app does
|
|
51
|
+
# not have, and its one useful entry -- the theme picker -- now lives
|
|
52
|
+
# on the settings screen where it can be saved. Switching it off also
|
|
53
|
+
# drops the ctrl+p binding and the footer's "^p palette" entry.
|
|
54
|
+
ENABLE_COMMAND_PALETTE = False
|
|
55
|
+
|
|
56
|
+
#: How often to ask whether the diary has been left alone long enough
|
|
57
|
+
#: to lock. Coarse on purpose: the check costs a subtraction, and
|
|
58
|
+
#: locking within half a minute of the deadline is close enough for a
|
|
59
|
+
#: timeout measured in minutes. Tests turn it right down.
|
|
60
|
+
IDLE_CHECK_SECONDS = 30.0
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
diary_path: Path = DEFAULT_DIARY_PATH,
|
|
65
|
+
config_path: Path | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Store the paths; the diary itself is unlocked via UnlockScreen.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
diary_path: Path to the encrypted diary file. Defaults to
|
|
71
|
+
~/.zecret/diary.enc, overridable via --path / ZECRET_DIARY_PATH.
|
|
72
|
+
config_path: Path to the preferences file, defaulting to
|
|
73
|
+
DEFAULT_CONFIG_PATH. Deliberately a separate argument
|
|
74
|
+
rather than derived from diary_path: preferences belong to
|
|
75
|
+
the person, not the file, so pointing --path at a scratch
|
|
76
|
+
diary must not give it a theme of its own. Resolved here
|
|
77
|
+
rather than as a default value so the fallback can be
|
|
78
|
+
redirected in tests, which must never read or write the
|
|
79
|
+
real one.
|
|
80
|
+
"""
|
|
81
|
+
super().__init__()
|
|
82
|
+
self.diary_path = diary_path
|
|
83
|
+
self.config = Config.load(DEFAULT_CONFIG_PATH if config_path is None else config_path)
|
|
84
|
+
# Both are None until UnlockScreen succeeds, and live only in memory
|
|
85
|
+
# for the session. Screens reach the diary exclusively through these.
|
|
86
|
+
self.diary: DiaryFile | None = None
|
|
87
|
+
self.key: bytes | None = None
|
|
88
|
+
# When something last happened. Monotonic, so the diary does not
|
|
89
|
+
# stay open an hour longer because a clock went backwards.
|
|
90
|
+
self.last_activity = time.monotonic()
|
|
91
|
+
|
|
92
|
+
def on_mount(self) -> None:
|
|
93
|
+
"""Apply the saved theme, then push UnlockScreen, which prompts to
|
|
94
|
+
unlock or to create a diary depending on whether diary_path exists.
|
|
95
|
+
|
|
96
|
+
Routing lives here rather than in the screens: UnlockScreen reports
|
|
97
|
+
success by dismissing, and this decides what comes next.
|
|
98
|
+
"""
|
|
99
|
+
self.apply_theme(self.config.theme)
|
|
100
|
+
creating = not self.diary_path.exists()
|
|
101
|
+
self.push_screen(UnlockScreen(creating=creating), self._on_unlocked)
|
|
102
|
+
self.set_interval(self.IDLE_CHECK_SECONDS, self.lock_if_idle)
|
|
103
|
+
|
|
104
|
+
async def on_event(self, event: events.Event) -> None:
|
|
105
|
+
"""Every event the app sees, which is where activity is noticed.
|
|
106
|
+
|
|
107
|
+
Key events reach here even when a widget goes on to consume them --
|
|
108
|
+
typing into the editor's TextArea counts, and has to, or writing a
|
|
109
|
+
long entry would be the one thing that looks like being away.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(event, events.Key | events.MouseDown):
|
|
112
|
+
self.last_activity = time.monotonic()
|
|
113
|
+
await super().on_event(event)
|
|
114
|
+
|
|
115
|
+
def apply_theme(self, theme: str) -> None:
|
|
116
|
+
"""Switch to `theme`, or to the default if it is not a real theme.
|
|
117
|
+
|
|
118
|
+
Config holds whatever string was in the file, which may name a
|
|
119
|
+
theme this Textual no longer ships; setting it directly would raise
|
|
120
|
+
and take the app down over a preference.
|
|
121
|
+
"""
|
|
122
|
+
self.theme = theme if theme in self.available_themes else DEFAULT_THEME
|
|
123
|
+
|
|
124
|
+
def _on_unlocked(self, _result: None) -> None:
|
|
125
|
+
"""Called once UnlockScreen dismisses, i.e. the diary is open."""
|
|
126
|
+
self.push_screen(EntryListScreen())
|
|
127
|
+
|
|
128
|
+
def lock_if_idle(self) -> None:
|
|
129
|
+
"""Lock the diary if it has been left alone long enough.
|
|
130
|
+
|
|
131
|
+
Runs on a timer whether or not anything is unlocked, since the
|
|
132
|
+
setting can be changed mid-session and the lock screen itself has
|
|
133
|
+
nothing to lock.
|
|
134
|
+
|
|
135
|
+
Not named check_idle: Textual's MessagePump already has one, it
|
|
136
|
+
means "the message queue has drained" rather than "nobody is
|
|
137
|
+
there", and it is called from refresh() -- so taking that name
|
|
138
|
+
replaces a framework method with one that wants attributes the app
|
|
139
|
+
has not set yet, and every test dies in App.__init__.
|
|
140
|
+
"""
|
|
141
|
+
minutes = self.config.lock_after_minutes
|
|
142
|
+
if minutes <= 0 or not self.is_unlocked:
|
|
143
|
+
return
|
|
144
|
+
if self.locking_would_lose_work():
|
|
145
|
+
# Half-written entry on the screen. Treat waiting on the writer
|
|
146
|
+
# as activity rather than merely postponing: locking the moment
|
|
147
|
+
# they saved would be the same ambush a beat later.
|
|
148
|
+
self.last_activity = time.monotonic()
|
|
149
|
+
return
|
|
150
|
+
if time.monotonic() - self.last_activity >= minutes * 60:
|
|
151
|
+
self.lock(LOCKED_BY_TIMEOUT)
|
|
152
|
+
|
|
153
|
+
def locking_would_lose_work(self) -> bool:
|
|
154
|
+
"""Whether any screen on the stack is holding something unsaved.
|
|
155
|
+
|
|
156
|
+
The whole stack, not just the top one: a confirmation modal over a
|
|
157
|
+
half-written entry is still a half-written entry.
|
|
158
|
+
"""
|
|
159
|
+
return any(
|
|
160
|
+
isinstance(screen, ZecretScreen) and screen.blocks_lock for screen in self.screen_stack
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def lock(self, message: str = LOCKED_BY_HAND) -> None:
|
|
164
|
+
"""Forget the diary and the key, and go back to the lock screen.
|
|
165
|
+
|
|
166
|
+
Everything above the lock screen is torn down first, while the
|
|
167
|
+
diary is still open: screens rebuild themselves as they resume, and
|
|
168
|
+
one resuming into a locked app has nothing to draw from.
|
|
169
|
+
"""
|
|
170
|
+
if not self.is_unlocked:
|
|
171
|
+
return
|
|
172
|
+
while len(self.screen_stack) > 1:
|
|
173
|
+
self.pop_screen()
|
|
174
|
+
self.diary = None
|
|
175
|
+
self.key = None
|
|
176
|
+
self.push_screen(UnlockScreen(creating=False), self._on_unlocked)
|
|
177
|
+
self.notify(message)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def is_unlocked(self) -> bool:
|
|
181
|
+
"""Whether there is an open diary to be working in."""
|
|
182
|
+
return self.diary is not None and self.key is not None
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def unlocked(self) -> tuple[DiaryFile, bytes]:
|
|
186
|
+
"""The open diary and the key it was opened with.
|
|
187
|
+
|
|
188
|
+
Every screen after UnlockScreen needs both, and needs them non-None.
|
|
189
|
+
Reaching here before unlocking is a routing bug, not a user error,
|
|
190
|
+
so it raises rather than returning a half-open state.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
RuntimeError: if the diary has not been unlocked yet.
|
|
194
|
+
"""
|
|
195
|
+
if self.diary is None or self.key is None:
|
|
196
|
+
raise RuntimeError("the diary is not unlocked")
|
|
197
|
+
return self.diary, self.key
|
zecret/app.tcss
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/* Zecret Textual stylesheet.
|
|
2
|
+
|
|
3
|
+
Conventions, so the screens stay consistent as they grow:
|
|
4
|
+
|
|
5
|
+
- Colors come from Textual's theme variables ($surface, $accent, $error,
|
|
6
|
+
$text-muted, ...), never hard-coded. The app then follows whichever
|
|
7
|
+
theme the user picks, light or dark, with no second palette to keep in
|
|
8
|
+
sync.
|
|
9
|
+
- $accent frames anything you are working in; $error is reserved for
|
|
10
|
+
errors and destructive actions and is used nowhere else.
|
|
11
|
+
- Spacing is 1 cell vertically, 2 horizontally. Terminal cells are about
|
|
12
|
+
twice as tall as they are wide, so that pair reads as even.
|
|
13
|
+
- Rules are grouped by the role a widget plays (panel, title, hint,
|
|
14
|
+
error, list), not by the screen it happens to live on -- which is why
|
|
15
|
+
selectors here list several ids at once.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/* --- Title bar ---------------------------------------------------------- */
|
|
19
|
+
|
|
20
|
+
/* Zecret's own header, not Textual's: one line, no icon, nothing to click.
|
|
21
|
+
$panel keeps it distinct from the screen behind it without competing with
|
|
22
|
+
$accent, which is reserved for what you are working in. */
|
|
23
|
+
DiaryHeader {
|
|
24
|
+
dock: top;
|
|
25
|
+
width: 100%;
|
|
26
|
+
height: 1;
|
|
27
|
+
background: $panel;
|
|
28
|
+
color: $foreground;
|
|
29
|
+
content-align: center middle;
|
|
30
|
+
text-wrap: nowrap;
|
|
31
|
+
text-overflow: ellipsis;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* --- Panels: the centred card used by unlock and settings --------------- */
|
|
35
|
+
|
|
36
|
+
UnlockScreen,
|
|
37
|
+
SettingsScreen {
|
|
38
|
+
align: center middle;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#unlock-box,
|
|
42
|
+
#settings-box {
|
|
43
|
+
width: 62;
|
|
44
|
+
height: auto;
|
|
45
|
+
/* Never taller than the terminal: settings scrolls inside its border
|
|
46
|
+
rather than running off the bottom of a short window. */
|
|
47
|
+
max-height: 100%;
|
|
48
|
+
padding: 1 2;
|
|
49
|
+
border: round $accent;
|
|
50
|
+
background: $surface;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#unlock-title,
|
|
54
|
+
.section-title {
|
|
55
|
+
width: 100%;
|
|
56
|
+
content-align: center middle;
|
|
57
|
+
text-style: bold;
|
|
58
|
+
color: $text-accent;
|
|
59
|
+
padding-bottom: 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#unlock-hint,
|
|
63
|
+
.section-hint {
|
|
64
|
+
width: 100%;
|
|
65
|
+
color: $text-muted;
|
|
66
|
+
padding-bottom: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#unlock-box Input,
|
|
70
|
+
#settings-box Input,
|
|
71
|
+
/* The gap under the theme picker is what separates the two sections of
|
|
72
|
+
the settings screen; they need no rule of their own. */
|
|
73
|
+
#settings-box Select {
|
|
74
|
+
margin-bottom: 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* --- Error lines -------------------------------------------------------- */
|
|
78
|
+
|
|
79
|
+
/* Reserve the row whether or not there is a message, so showing one never
|
|
80
|
+
shifts the fields under the user's cursor. */
|
|
81
|
+
#unlock-error,
|
|
82
|
+
#settings-error,
|
|
83
|
+
#editor-error,
|
|
84
|
+
#date-error {
|
|
85
|
+
width: 100%;
|
|
86
|
+
height: 1;
|
|
87
|
+
color: $text-error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* --- Lists: entry list and search results ------------------------------- */
|
|
91
|
+
|
|
92
|
+
#entries,
|
|
93
|
+
#results {
|
|
94
|
+
height: 1fr;
|
|
95
|
+
/* Same 2-cell gutter as the search box above it and the editor's
|
|
96
|
+
fields, so text lines up down the left edge of every screen. */
|
|
97
|
+
margin: 0 2;
|
|
98
|
+
background: $surface;
|
|
99
|
+
border: none;
|
|
100
|
+
|
|
101
|
+
& > ListItem {
|
|
102
|
+
padding: 0 2;
|
|
103
|
+
background: transparent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* The selected row is the one thing the eye should find instantly. */
|
|
107
|
+
& > ListItem.-highlight {
|
|
108
|
+
background: $accent-muted;
|
|
109
|
+
text-style: bold;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&:focus > ListItem.-highlight {
|
|
113
|
+
background: $accent;
|
|
114
|
+
color: $block-cursor-foreground;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Month headings. Disabled so the cursor skips them, which is a
|
|
118
|
+
navigation decision, not a visual one -- they read as headings, not
|
|
119
|
+
as something unavailable. */
|
|
120
|
+
& > ListItem.group-heading {
|
|
121
|
+
padding: 1 2 0 2;
|
|
122
|
+
color: $text-accent;
|
|
123
|
+
text-style: bold;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* --- Empty states ------------------------------------------------------- */
|
|
128
|
+
|
|
129
|
+
#entries-empty,
|
|
130
|
+
#search-empty {
|
|
131
|
+
height: 1fr;
|
|
132
|
+
width: 100%;
|
|
133
|
+
content-align: center middle;
|
|
134
|
+
color: $text-muted;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* --- SearchScreen ------------------------------------------------------- */
|
|
138
|
+
|
|
139
|
+
#query {
|
|
140
|
+
margin: 0 2;
|
|
141
|
+
border: round $border-blurred;
|
|
142
|
+
|
|
143
|
+
&:focus {
|
|
144
|
+
border: round $accent;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* --- EditorScreen ------------------------------------------------------- */
|
|
149
|
+
|
|
150
|
+
#editor-box {
|
|
151
|
+
height: 1fr;
|
|
152
|
+
padding: 1 2;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#editor-box #body {
|
|
156
|
+
height: 1fr;
|
|
157
|
+
margin-bottom: 1;
|
|
158
|
+
border: round $border-blurred;
|
|
159
|
+
|
|
160
|
+
&:focus {
|
|
161
|
+
border: round $accent;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* --- HelpScreen (modal) ------------------------------------------------- */
|
|
166
|
+
|
|
167
|
+
/* Wide enough for the logo, and never taller than the terminal: the help
|
|
168
|
+
scrolls inside its own border rather than off the bottom of the screen. */
|
|
169
|
+
#help-box {
|
|
170
|
+
width: 62;
|
|
171
|
+
max-width: 100%;
|
|
172
|
+
height: auto;
|
|
173
|
+
max-height: 100%;
|
|
174
|
+
padding: 1 2;
|
|
175
|
+
border: round $accent;
|
|
176
|
+
background: $surface;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#help-logo {
|
|
180
|
+
width: auto;
|
|
181
|
+
color: $text-accent;
|
|
182
|
+
/* The art is drawn, not written: wrapping it would make nonsense of
|
|
183
|
+
it. Narrow terminals hide it instead -- see HelpScreen.fit_logo. */
|
|
184
|
+
text-wrap: nowrap;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#help-tagline {
|
|
188
|
+
width: 100%;
|
|
189
|
+
color: $text-muted;
|
|
190
|
+
padding-bottom: 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Section titles are centred where they head a narrow card; here they head
|
|
194
|
+
a list, so they read as headings only if they sit at the left edge of
|
|
195
|
+
what they introduce, with air above rather than below. */
|
|
196
|
+
#help-box .section-title {
|
|
197
|
+
content-align: left middle;
|
|
198
|
+
text-align: left;
|
|
199
|
+
padding: 1 0 0 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* The key rows are aligned into columns by the screen itself, since only
|
|
203
|
+
it knows how wide the widest key in a section is. */
|
|
204
|
+
.help-key {
|
|
205
|
+
width: 100%;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* A tight list, like the key rows above: each note is one line, and the
|
|
209
|
+
section heading already gives the group its air. */
|
|
210
|
+
.help-note {
|
|
211
|
+
width: 100%;
|
|
212
|
+
color: $text-muted;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* No padding above it: this is the one line someone who feels stuck needs
|
|
216
|
+
to see, so it earns its place inside the fold rather than a row of air. */
|
|
217
|
+
#help-close-hint {
|
|
218
|
+
width: 100%;
|
|
219
|
+
content-align: right middle;
|
|
220
|
+
text-align: right;
|
|
221
|
+
color: $text-muted;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* --- Modals: confirm, date prompt and help ------------------------------ */
|
|
225
|
+
|
|
226
|
+
/* Dim what is behind them: the question is the only thing to answer, and
|
|
227
|
+
help sits over the diary rather than replacing it. */
|
|
228
|
+
ConfirmScreen,
|
|
229
|
+
DatePromptScreen,
|
|
230
|
+
HelpScreen {
|
|
231
|
+
align: center middle;
|
|
232
|
+
background: $background 60%;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#confirm-box {
|
|
236
|
+
width: 60;
|
|
237
|
+
height: auto;
|
|
238
|
+
padding: 1 2;
|
|
239
|
+
/* Red framing, because every use of this modal is destructive. */
|
|
240
|
+
border: round $error;
|
|
241
|
+
background: $surface;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#confirm-question {
|
|
245
|
+
width: 100%;
|
|
246
|
+
padding-bottom: 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#confirm-buttons {
|
|
250
|
+
height: auto;
|
|
251
|
+
align-horizontal: right;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#confirm-buttons Button {
|
|
255
|
+
margin-left: 2;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* --- DatePromptScreen (modal) ------------------------------------------- */
|
|
259
|
+
|
|
260
|
+
/* $accent, not $error: picking a day is where you start writing, not a
|
|
261
|
+
destructive step. */
|
|
262
|
+
#date-box {
|
|
263
|
+
width: 44;
|
|
264
|
+
height: auto;
|
|
265
|
+
padding: 1 2;
|
|
266
|
+
border: round $accent;
|
|
267
|
+
background: $surface;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#date-title {
|
|
271
|
+
width: 100%;
|
|
272
|
+
text-style: bold;
|
|
273
|
+
color: $text-accent;
|
|
274
|
+
padding-bottom: 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#date-hint {
|
|
278
|
+
width: 100%;
|
|
279
|
+
color: $text-muted;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#date-box MaskedInput {
|
|
283
|
+
border: round $border-blurred;
|
|
284
|
+
|
|
285
|
+
&:focus {
|
|
286
|
+
border: round $accent;
|
|
287
|
+
}
|
|
288
|
+
}
|
zecret/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""User preferences: small, non-secret, and kept out of the diary.
|
|
2
|
+
|
|
3
|
+
The diary file is for what you wrote. A theme is neither secret nor
|
|
4
|
+
writing, and it has to be known *before* the diary is unlocked -- the lock
|
|
5
|
+
screen is themed too -- so it cannot live inside a file that needs a
|
|
6
|
+
password to read. The same goes for how long the diary waits before
|
|
7
|
+
locking itself: the timer has to be running before anything is unlocked.
|
|
8
|
+
Both get their own plaintext JSON file instead:
|
|
9
|
+
|
|
10
|
+
{"theme": "gruvbox", "lock_after_minutes": 15}
|
|
11
|
+
|
|
12
|
+
This is the one place besides storage.py that touches the filesystem, and
|
|
13
|
+
the one file Zecret writes in the clear. It may hold preferences and
|
|
14
|
+
nothing else: no entry text, no dates, no password, nothing derived from
|
|
15
|
+
the key. Anything that would tell a reader of this file something about
|
|
16
|
+
the diary's contents belongs in the diary.
|
|
17
|
+
|
|
18
|
+
Preferences are regenerable, so this module is forgiving where storage.py
|
|
19
|
+
is strict. A missing, unreadable, or malformed config falls back to
|
|
20
|
+
defaults rather than raising: a mangled preference must never be the
|
|
21
|
+
reason you cannot get to your diary. For the same reason the write is a
|
|
22
|
+
plain write rather than the atomic dance storage.py performs -- the worst
|
|
23
|
+
outcome is a file that fails to parse next launch and is replaced by
|
|
24
|
+
defaults.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Self
|
|
33
|
+
|
|
34
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".zecret" / "config.json"
|
|
35
|
+
|
|
36
|
+
#: What the app uses until told otherwise, and the fallback for anything
|
|
37
|
+
#: unreadable. Matches Textual's own default theme.
|
|
38
|
+
DEFAULT_THEME = "textual-dark"
|
|
39
|
+
|
|
40
|
+
#: Minutes of no typing before the diary locks itself. Long enough not to
|
|
41
|
+
#: interrupt someone thinking about what to write, short enough that a
|
|
42
|
+
#: walked-away-from terminal does not stay open all afternoon. Zero turns
|
|
43
|
+
#: it off, which is a choice the settings screen offers rather than the
|
|
44
|
+
#: default it ships.
|
|
45
|
+
DEFAULT_LOCK_AFTER_MINUTES = 15
|
|
46
|
+
|
|
47
|
+
DIR_MODE = 0o700
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class Config:
|
|
52
|
+
"""The user's preferences, and where they came from.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
path: The file these were read from, and where save() writes back.
|
|
56
|
+
theme: Name of the Textual theme to use. Not validated here --
|
|
57
|
+
config.py has no opinion on which themes exist; the app checks
|
|
58
|
+
the name against the themes actually registered and falls back
|
|
59
|
+
if it does not know it.
|
|
60
|
+
lock_after_minutes: Minutes of inactivity before the diary locks
|
|
61
|
+
itself, or 0 to never lock. How long a minute is and what
|
|
62
|
+
counts as inactivity are the app's business; this only
|
|
63
|
+
remembers the number, and only a whole non-negative one.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
path: Path
|
|
67
|
+
theme: str = DEFAULT_THEME
|
|
68
|
+
lock_after_minutes: int = DEFAULT_LOCK_AFTER_MINUTES
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def load(cls, path: Path) -> Self:
|
|
72
|
+
"""Read preferences from `path`, falling back to defaults.
|
|
73
|
+
|
|
74
|
+
Never raises: a missing file is the normal first-run case, and a
|
|
75
|
+
corrupt or unreadable one is treated the same way, since the diary
|
|
76
|
+
must stay reachable regardless.
|
|
77
|
+
"""
|
|
78
|
+
config = cls(path=path)
|
|
79
|
+
try:
|
|
80
|
+
document = json.loads(path.read_bytes().decode("utf-8"))
|
|
81
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
82
|
+
return config
|
|
83
|
+
if not isinstance(document, dict):
|
|
84
|
+
return config
|
|
85
|
+
|
|
86
|
+
theme = document.get("theme")
|
|
87
|
+
if isinstance(theme, str) and theme:
|
|
88
|
+
config.theme = theme
|
|
89
|
+
|
|
90
|
+
lock_after = document.get("lock_after_minutes")
|
|
91
|
+
# bool is an int in Python, and `true` in the file is not a number
|
|
92
|
+
# of minutes. A negative one would lock the diary the instant it
|
|
93
|
+
# opened, so it falls back rather than being clamped -- the file is
|
|
94
|
+
# not saying anything we can act on.
|
|
95
|
+
if isinstance(lock_after, int) and not isinstance(lock_after, bool) and lock_after >= 0:
|
|
96
|
+
config.lock_after_minutes = lock_after
|
|
97
|
+
return config
|
|
98
|
+
|
|
99
|
+
def save(self) -> None:
|
|
100
|
+
"""Write preferences back to self.path, creating the directory.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
OSError: if the file cannot be written. The caller decides what
|
|
104
|
+
to say about it -- the setting still applies to this
|
|
105
|
+
session, it just will not outlive it.
|
|
106
|
+
"""
|
|
107
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=DIR_MODE)
|
|
108
|
+
self.path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
"""The JSON shape written to disk."""
|
|
112
|
+
return {"theme": self.theme, "lock_after_minutes": self.lock_after_minutes}
|