pyworklog 0.3.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.
worklog/render.py ADDED
@@ -0,0 +1,209 @@
1
+ """Rich-based highlighting and node-line rendering for worklog.
2
+
3
+ Holds the mutable `_CONSOLE` state (set by `_init_console` from main()).
4
+ Coloring helpers (`_c`, `_hl`, `out`) read `_CONSOLE` at call time, so the
5
+ rest of the codebase doesn't pass a console object around. The trade-off is
6
+ that tests reading `_CONSOLE` must do so via `wl._CONSOLE` (a live
7
+ attribute lookup) rather than `wl._CONSOLE` (an import-time binding that
8
+ would not follow mutations).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+
15
+ try:
16
+ from rich.console import Console as _RichConsole
17
+ from rich.theme import Theme as _RichTheme
18
+ from rich.markup import escape as _rich_escape
19
+ _RICH_AVAIL = True
20
+ except ImportError:
21
+ _RICH_AVAIL = False
22
+
23
+ # theme = semantic element -> rich style. No "default" theme; default is auto, probes terminal bg and resolves to dark/light/mono.
24
+ _THEME_KEYS = "done doing later wait todo canceled pri_a pri_b pri_c id kind tag hit header meta planned clock".split()
25
+ THEMES = {
26
+ # dark: dark background, use bright_* for contrast
27
+ "dark": {
28
+ "done": "bright_green", "doing": "bright_yellow", "later": "bright_cyan", "wait": "grey50",
29
+ "todo": "default", "canceled": "strike grey50",
30
+ "pri_a": "bold bright_red", "pri_b": "bright_yellow", "pri_c": "grey50",
31
+ "id": "grey50", "kind": "bright_cyan", "tag": "bright_magenta", "hit": "bold black on bright_yellow",
32
+ "header": "bold bright_white", "meta": "grey50", "planned": "bright_blue", "clock": "bright_green",
33
+ },
34
+ # light: light background, use deep saturated colors (avoid bright/white getting lost on white bg)
35
+ "light": {
36
+ "done": "green4", "doing": "dark_orange3", "later": "blue", "wait": "grey42",
37
+ "todo": "default", "canceled": "strike grey42",
38
+ "pri_a": "bold red3", "pri_b": "dark_orange3", "pri_c": "grey42",
39
+ "id": "grey42", "kind": "dark_cyan", "tag": "purple", "hit": "bold black on yellow3",
40
+ "header": "bold grey15", "meta": "grey42", "planned": "blue", "clock": "green4",
41
+ },
42
+ # mono: no color (want rich layout but no color)
43
+ "mono": {k: "default" for k in _THEME_KEYS},
44
+ }
45
+ _STATUS_STYLE = {"DONE": "done", "DOING": "doing", "LATER": "later", "WAIT": "wait",
46
+ "TODO": "todo", "DEFERRED": "later", "CANCELED": "canceled", None: "todo"}
47
+ _PRI_STYLE = {"A": "pri_a", "B": "pri_b", "C": "pri_c"}
48
+
49
+ _CONSOLE = None # initialized by main() based on --color/--theme; None = plain text
50
+
51
+
52
+ def _resolve_color(mode):
53
+ if mode is None:
54
+ mode = os.environ.get("WORKLOG_COLOR", "auto")
55
+ if mode == "never":
56
+ return False
57
+ if mode == "always":
58
+ return True
59
+ return _RICH_AVAIL and sys.stdout.isatty() and not os.environ.get("NO_COLOR")
60
+
61
+
62
+ def _detect_bg_is_dark(): # pragma: no cover -- TTY/escape-seq probe, not unit-tested at integration layer
63
+ """Detect terminal bg: True=dark / False=light / None=unknown.
64
+ First check $COLORFGBG (no I/O), then query OSC 11 (requires TTY, short timeout)."""
65
+ fgbg = os.environ.get("COLORFGBG")
66
+ if fgbg and ";" in fgbg:
67
+ try:
68
+ bg = int(fgbg.split(";")[-1])
69
+ return bg not in (7, 15) # 7/15 = light bg, others treated as dark
70
+ except ValueError:
71
+ pass
72
+ if not (sys.stdout.isatty() and sys.stdin.isatty()):
73
+ return None
74
+ try:
75
+ import termios, tty, select, re
76
+ fd = sys.stdin.fileno()
77
+ old = termios.tcgetattr(fd)
78
+ try:
79
+ tty.setraw(fd)
80
+ sys.stdout.write("\033]11;?\033\\")
81
+ sys.stdout.flush()
82
+ resp = ""
83
+ if select.select([fd], [], [], 0.15)[0]:
84
+ resp = os.read(fd, 64).decode("latin-1", "ignore")
85
+ finally:
86
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
87
+ m = re.search(r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", resp)
88
+ if not m:
89
+ return None
90
+ r, g, b = (int(m.group(i)[:2], 16) for i in (1, 2, 3)) # take top 2 hex digits per channel
91
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5 # perceived brightness < 0.5 = dark
92
+ except (ValueError, AttributeError):
93
+ # int(..., 16) parse failure / m.group out of range -> undetectable, treat as unknown
94
+ return None
95
+
96
+
97
+ def _resolve_theme(name):
98
+ """Resolve theme name to a real palette name. auto (default): probe bg -> dark/light, fallback dark if unknown."""
99
+ if name in THEMES:
100
+ return name # explicit real theme
101
+ # name is None / "auto" / unknown -> auto-detect
102
+ dark = _detect_bg_is_dark()
103
+ if dark is False:
104
+ return "light"
105
+ return "dark" # dark or unknown -> use dark (most terminals have dark bg)
106
+
107
+
108
+ def _init_console(color_mode, theme_name):
109
+ global _CONSOLE
110
+ if not _resolve_color(color_mode) or not _RICH_AVAIL:
111
+ _CONSOLE = None
112
+ return
113
+ name = _resolve_theme(theme_name or os.environ.get("WORKLOG_THEME"))
114
+ force = True if color_mode == "always" else None
115
+ _CONSOLE = _RichConsole(theme=_RichTheme(THEMES[name]), force_terminal=force, highlight=False, soft_wrap=True)
116
+ # terminal without color support (TERM=dumb etc.) -> effectively mono, rich won't emit ANSI
117
+
118
+
119
+ def out(s):
120
+ """Unified output: when highlighting is enabled, use rich (markup rendering); otherwise plain print."""
121
+ if _CONSOLE is not None:
122
+ _CONSOLE.print(s)
123
+ else:
124
+ print(s)
125
+
126
+
127
+ def _c(text, style=None):
128
+ """Color a fragment: returns rich markup when enabled (content escaped to prevent injection), otherwise plain text."""
129
+ t = str(text)
130
+ if _CONSOLE is None:
131
+ return t
132
+ t = _rich_escape(t)
133
+ return f"[{style}]{t}[/{style}]" if style else t
134
+
135
+
136
+ def _hl(text, q):
137
+ """In a string, mark query matches (styled: hit style / plain: *…*). No match -> plain _c."""
138
+ text = str(text)
139
+ if not q:
140
+ return _c(text)
141
+ i = text.lower().find(q.lower())
142
+ if i < 0:
143
+ return _c(text)
144
+ mid = text[i:i + len(q)]
145
+ pre, post = text[:i], text[i + len(q):]
146
+ if _CONSOLE is None:
147
+ return pre + f"*{mid}*" + post
148
+ return _c(pre) + _c(mid, "hit") + _c(post)
149
+
150
+
151
+ # --- node-line rendering (extracted from cli.py) ---
152
+ from .helpers import _status_marker, _sched_display, _fmt_dur
153
+ from .queries import _has_tag, _node_clock_min, _node_tags
154
+
155
+ def _node_line(con, n, *, indent="", done=False, show_kind=True, tags=False, planned=False, clock=True, sched=False, hl=None):
156
+ """Unified node-line rendering (sole source per DESIGN.md §6).
157
+
158
+ Format: <indent><marker> [#pri] #<id> [kind] <title>[ ·planned][ @sched][ [Xh Ym]][ :tags:]
159
+ Everywhere that "lists tasks" goes through this; do not roll your own. hl=query highlights matches in title (used by find).
160
+ clock defaults True: shows total duration [Xh Ym] when there's a CLOCK or log span; 0 hides it.
161
+ """
162
+ mk = "✓" if done else _status_marker(n["status"])
163
+ marker = _c(mk, "done" if done else _STATUS_STYLE.get(n["status"], "todo"))
164
+ if n["priority"]:
165
+ pri = _c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"]))
166
+ else:
167
+ pri = " " # no priority: spaces as placeholder to align with [#A], no collision with marker
168
+ kind = (_c(f"[{n['kind']}]", "kind") + " ") if (show_kind and n["kind"] != "task") else ""
169
+ nid = _c(f"#{n['id']}", "id")
170
+ title = _hl(n["title"], hl) if hl else _c(n["title"])
171
+ s = f"{indent}{marker} {pri} {nid} {kind}{title}"
172
+ if planned and _has_tag(con, n["id"], "planned"):
173
+ s += " " + _c("·planned", "planned")
174
+ if sched and n["scheduled_at"]:
175
+ s += " " + _c("@" + _sched_display(n["scheduled_at"]), "planned")
176
+ if clock:
177
+ cm = _node_clock_min(con, n["id"])
178
+ d = _fmt_dur(cm)
179
+ if d:
180
+ s += " " + _c(d, "clock")
181
+ if tags:
182
+ tl = _node_tags(con, n["id"])
183
+ if tl:
184
+ s += " " + _c(f":{':'.join(tl)}:", "tag")
185
+ return s
186
+
187
+ def _snippet(text, q, ctx=30):
188
+ """Extract a snippet around the query, with the match highlighted (styled) / *…* marked (plain)."""
189
+ i = text.lower().find(q.lower())
190
+ if i < 0:
191
+ return _c(text[:80] + ("…" if len(text) > 80 else ""))
192
+ a, b = max(0, i - ctx), min(len(text), i + len(q) + ctx)
193
+ mid = text[i:i + len(q)]
194
+ pre = ("…" if a > 0 else "") + text[a:i]
195
+ post = text[i + len(q):b] + ("…" if b < len(text) else "")
196
+ if _CONSOLE is None:
197
+ return pre + f"*{mid}*" + post
198
+ return _c(pre) + _c(mid, "hit") + _c(post)
199
+
200
+
201
+
202
+ def _print_truncation_hint(shown, total, extra=""):
203
+ """Print `(showing N/total[, extra])` hint when truncated; print nothing otherwise."""
204
+ if shown < total:
205
+ msg = f"(showing {shown}/{total}"
206
+ if extra:
207
+ msg += f", {extra}"
208
+ msg += ")"
209
+ out(_c(msg, "meta"))
worklog/xdg.py ADDED
@@ -0,0 +1,42 @@
1
+ """XDG Base Directory resolution for worklog's DB and config files.
2
+
3
+ Spec: https://specifications.freedesktop.org/basedir-spec/
4
+
5
+ All four helpers re-read environment variables on every call so test
6
+ fixtures that monkeypatch `$HOME` / `$XDG_*` see the change without
7
+ needing a module reload.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+
15
+ def _xdg_data_home() -> Path:
16
+ """`$XDG_DATA_HOME` (default `~/.local/share`)."""
17
+ return Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
18
+
19
+
20
+ def _xdg_config_home() -> Path:
21
+ """`$XDG_CONFIG_HOME` (default `~/.config`)."""
22
+ return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
23
+
24
+
25
+ def _resolve_db_path(args=None) -> Path:
26
+ """Resolve the SQLite DB path. Priority:
27
+
28
+ 1. ``--db`` flag (per-invocation override, top priority)
29
+ 2. ``$WORKLOG_DB`` env
30
+ 3. ``$XDG_DATA_HOME/worklog/worklog.db`` (default ``~/.local/share/worklog/worklog.db``)
31
+ """
32
+ if args is not None and getattr(args, "db", None):
33
+ return Path(args.db).resolve()
34
+ env = os.environ.get("WORKLOG_DB")
35
+ if env:
36
+ return Path(env).resolve()
37
+ return (_xdg_data_home() / "worklog" / "worklog.db").resolve()
38
+
39
+
40
+ def _resolve_aliases_path() -> Path:
41
+ """``$XDG_CONFIG_HOME/worklog/aliases.ini`` (default ``~/.config/worklog/aliases.ini``)."""
42
+ return _xdg_config_home() / "worklog" / "aliases.ini"