xkcdkit 0.1.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xkcdkit
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations.
5
5
  Keywords: xkcd,comics,cli,tui,textual
6
6
  Author: Eivind Teig
@@ -113,7 +113,8 @@ xkcd random # a random comic
113
113
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
114
114
  xkcd explain 614 # just the explanation, as plain text
115
115
  xkcd sync # refresh the archive index and the latest comic
116
- xkcd sync --full # download every comic and explanation (about an hour, see below)
116
+ xkcd sync --full # download every comic and explanation (under an hour, see below)
117
+ xkcd sync --full --no-explain # comics only, a minute or two; explanations come later
117
118
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
118
119
  xkcd tui # open the browser
119
120
  ```
@@ -130,6 +131,40 @@ xkcd search "sandwich" --json | jq '.[].num'
130
131
  `--no-image` skips the picture. Images are also skipped automatically when
131
132
  stdout is not a terminal, so pipes always get clean text.
132
133
 
134
+ Images are drawn at the size the terminal reports for them, which can be
135
+ small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
136
+ them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
137
+
138
+ ```sh
139
+ xkcd show 614 --scale 2
140
+ xkcd latest --fit
141
+ ```
142
+
143
+ ### Settings file
144
+
145
+ Defaults for these live in a small TOML file. `xkcd config` prints its
146
+ location and the current values; `xkcd config --init` writes a commented
147
+ file with every key:
148
+
149
+ | Platform | Location |
150
+ | -------- | -------- |
151
+ | Linux | `~/.config/xkcdkit/config.toml` |
152
+ | macOS | `~/Library/Application Support/xkcdkit/config.toml` |
153
+ | Windows | `%APPDATA%\xkcdkit\config.toml` |
154
+
155
+ Set `XKCDKIT_CONFIG` to use another path.
156
+
157
+ ```toml
158
+ [image]
159
+ scale = 2.0 # CLI image size multiplier
160
+ fit = false # CLI: fill the terminal instead
161
+
162
+ [tui]
163
+ image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
164
+ ```
165
+
166
+ Command line flags always win over the file.
167
+
133
168
  ### The browser
134
169
 
135
170
  `xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
@@ -139,15 +174,20 @@ explanation.
139
174
  | Key | Action |
140
175
  | ------- | -------------------------------- |
141
176
  | `j`/`k` | move through the archive |
142
- | `/` | filter the archive by title |
177
+ | `/` | filter the archive by title; up/down still move the list, Esc returns to it |
143
178
  | `Enter` | open the selected comic |
144
179
  | `r` | random comic |
145
180
  | `l` | latest comic |
146
181
  | `i` | show or hide the image pane |
182
+ | `+`/`-` | grow or shrink the image pane |
147
183
  | `s` | sync the archive index |
148
184
  | `?` | key help |
149
185
  | `q` | quit |
150
186
 
187
+ The image always fills its pane while keeping its aspect ratio, so the pane
188
+ height is the zoom. The starting height comes from `image_pane` in the
189
+ settings file.
190
+
151
191
  ## Library
152
192
 
153
193
  The async client is the primary API. It is built for I/O bound programs such
@@ -232,10 +272,23 @@ async def xkcd(ctx, num: int):
232
272
  ### Being polite to explainxkcd
233
273
 
234
274
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
235
- per second, so a full sync fetches one explanation per second and sends a
236
- User-Agent that names this project and its repository. A complete first
237
- `sync --full` therefore takes about an hour. On-demand lookups are not
238
- delayed.
275
+ per second, so a full sync sends at most one explainxkcd request per second
276
+ and a User-Agent that names this project and its repository. xkcd.com has no
277
+ such limit and is fetched by several workers at once, so explanations are the
278
+ pacing item: a complete first `sync --full` takes a little under an hour for
279
+ the whole archive.
280
+
281
+ Two ways to shorten the wait:
282
+
283
+ - `xkcd sync --full --no-explain` fetches only the comics, which takes a
284
+ minute or two. Explanations are then filled in on demand when you open a
285
+ comic, or in bulk with `xkcd resync`, which is paced the same way.
286
+ - Ctrl-C at any point is safe. Every comic is committed to the database the
287
+ moment it is fetched, and the next `sync --full` skips everything already
288
+ cached, so you can sync in several sittings.
289
+
290
+ The sync walks every number from 1 to the latest comic, so it does not depend
291
+ on the archive page. On-demand lookups are never delayed.
239
292
 
240
293
  ## Terminal image support
241
294
 
@@ -81,7 +81,8 @@ xkcd random # a random comic
81
81
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
82
82
  xkcd explain 614 # just the explanation, as plain text
83
83
  xkcd sync # refresh the archive index and the latest comic
84
- xkcd sync --full # download every comic and explanation (about an hour, see below)
84
+ xkcd sync --full # download every comic and explanation (under an hour, see below)
85
+ xkcd sync --full --no-explain # comics only, a minute or two; explanations come later
85
86
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
86
87
  xkcd tui # open the browser
87
88
  ```
@@ -98,6 +99,40 @@ xkcd search "sandwich" --json | jq '.[].num'
98
99
  `--no-image` skips the picture. Images are also skipped automatically when
99
100
  stdout is not a terminal, so pipes always get clean text.
100
101
 
102
+ Images are drawn at the size the terminal reports for them, which can be
103
+ small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
104
+ them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
105
+
106
+ ```sh
107
+ xkcd show 614 --scale 2
108
+ xkcd latest --fit
109
+ ```
110
+
111
+ ### Settings file
112
+
113
+ Defaults for these live in a small TOML file. `xkcd config` prints its
114
+ location and the current values; `xkcd config --init` writes a commented
115
+ file with every key:
116
+
117
+ | Platform | Location |
118
+ | -------- | -------- |
119
+ | Linux | `~/.config/xkcdkit/config.toml` |
120
+ | macOS | `~/Library/Application Support/xkcdkit/config.toml` |
121
+ | Windows | `%APPDATA%\xkcdkit\config.toml` |
122
+
123
+ Set `XKCDKIT_CONFIG` to use another path.
124
+
125
+ ```toml
126
+ [image]
127
+ scale = 2.0 # CLI image size multiplier
128
+ fit = false # CLI: fill the terminal instead
129
+
130
+ [tui]
131
+ image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
132
+ ```
133
+
134
+ Command line flags always win over the file.
135
+
101
136
  ### The browser
102
137
 
103
138
  `xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
@@ -107,15 +142,20 @@ explanation.
107
142
  | Key | Action |
108
143
  | ------- | -------------------------------- |
109
144
  | `j`/`k` | move through the archive |
110
- | `/` | filter the archive by title |
145
+ | `/` | filter the archive by title; up/down still move the list, Esc returns to it |
111
146
  | `Enter` | open the selected comic |
112
147
  | `r` | random comic |
113
148
  | `l` | latest comic |
114
149
  | `i` | show or hide the image pane |
150
+ | `+`/`-` | grow or shrink the image pane |
115
151
  | `s` | sync the archive index |
116
152
  | `?` | key help |
117
153
  | `q` | quit |
118
154
 
155
+ The image always fills its pane while keeping its aspect ratio, so the pane
156
+ height is the zoom. The starting height comes from `image_pane` in the
157
+ settings file.
158
+
119
159
  ## Library
120
160
 
121
161
  The async client is the primary API. It is built for I/O bound programs such
@@ -200,10 +240,23 @@ async def xkcd(ctx, num: int):
200
240
  ### Being polite to explainxkcd
201
241
 
202
242
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
203
- per second, so a full sync fetches one explanation per second and sends a
204
- User-Agent that names this project and its repository. A complete first
205
- `sync --full` therefore takes about an hour. On-demand lookups are not
206
- delayed.
243
+ per second, so a full sync sends at most one explainxkcd request per second
244
+ and a User-Agent that names this project and its repository. xkcd.com has no
245
+ such limit and is fetched by several workers at once, so explanations are the
246
+ pacing item: a complete first `sync --full` takes a little under an hour for
247
+ the whole archive.
248
+
249
+ Two ways to shorten the wait:
250
+
251
+ - `xkcd sync --full --no-explain` fetches only the comics, which takes a
252
+ minute or two. Explanations are then filled in on demand when you open a
253
+ comic, or in bulk with `xkcd resync`, which is paced the same way.
254
+ - Ctrl-C at any point is safe. Every comic is committed to the database the
255
+ moment it is fetched, and the next `sync --full` skips everything already
256
+ cached, so you can sync in several sittings.
257
+
258
+ The sync walks every number from 1 to the latest comic, so it does not depend
259
+ on the archive page. On-demand lookups are never delayed.
207
260
 
208
261
  ## Terminal image support
209
262
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -12,6 +12,7 @@ from rich.text import Text
12
12
 
13
13
  from xkcdkit import XKCDError, __version__
14
14
  from xkcdkit.client import AsyncXKCD
15
+ from xkcdkit.config import config_path, load_settings, write_default
15
16
  from xkcdkit.models import Comic
16
17
  from xkcdkit.render import can_draw, comic_image, comic_text
17
18
  from xkcdkit.sources import fetch_image
@@ -21,6 +22,8 @@ console = Console()
21
22
  errors = Console(stderr=True)
22
23
  Num = Annotated[int, typer.Argument(help="Comic number.")]
23
24
  Json = Annotated[bool, typer.Option("--json", help="Print JSON for piping.")]
25
+ Scale = Annotated[float | None, typer.Option("--scale", "-s", help="Image size multiplier.")]
26
+ Fit = Annotated[bool, typer.Option("--fit", help="Fill the terminal with the image.")]
24
27
  NoImage = Annotated[bool, typer.Option("--no-image", help="Do not draw the image.")]
25
28
  Explain = Annotated[bool, typer.Option("--explain", "-e", help="Include the explanation.")]
26
29
 
@@ -66,6 +69,12 @@ def run(ctx: typer.Context, action: Callable[[AsyncXKCD], Awaitable[Any]]) -> An
66
69
  except XKCDError as exc:
67
70
  errors.print(f"error: {exc}", markup=False, highlight=False)
68
71
  raise typer.Exit(1) from exc
72
+ except KeyboardInterrupt:
73
+ errors.print(
74
+ "Interrupted. Everything fetched so far is saved; run the command again to continue.",
75
+ soft_wrap=True,
76
+ )
77
+ raise typer.Exit(130) from None
69
78
 
70
79
 
71
80
  @dataclass
@@ -75,6 +84,14 @@ class View:
75
84
  json: bool = False
76
85
  image: bool = True
77
86
  explain: bool = False
87
+ scale: float = 1.0
88
+ fit: bool = False
89
+
90
+
91
+ def view(json: bool, no_image: bool, explain: bool, scale: float | None, fit: bool) -> View:
92
+ """Combine command flags with the settings file; flags win."""
93
+ image = load_settings().image
94
+ return View(json, not no_image, explain, scale or image.scale, fit or image.fit)
78
95
 
79
96
 
80
97
  async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
@@ -84,32 +101,48 @@ async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
84
101
  console.print_json(comic.model_dump_json())
85
102
  return
86
103
  if view.image and can_draw():
87
- console.print(comic_image(await fetch_image(xk.http, comic)))
104
+ console.print(comic_image(await fetch_image(xk.http, comic), view.scale, view.fit))
88
105
  console.print(comic_text(comic, explain=view.explain))
89
106
 
90
107
 
91
108
  @app.command()
92
109
  def show(
93
- ctx: typer.Context, num: Num, no_image: NoImage = False, explain: Explain = False, json: Json = False
110
+ ctx: typer.Context,
111
+ num: Num,
112
+ no_image: NoImage = False,
113
+ explain: Explain = False,
114
+ json: Json = False,
115
+ scale: Scale = None,
116
+ fit: Fit = False,
94
117
  ) -> None:
95
118
  """Show one comic by number."""
96
- run(ctx, lambda xk: present(xk, xk.get(num), View(json, not no_image, explain)))
119
+ run(ctx, lambda xk: present(xk, xk.get(num), view(json, no_image, explain, scale, fit)))
97
120
 
98
121
 
99
122
  @app.command()
100
123
  def latest(
101
- ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
124
+ ctx: typer.Context,
125
+ no_image: NoImage = False,
126
+ explain: Explain = False,
127
+ json: Json = False,
128
+ scale: Scale = None,
129
+ fit: Fit = False,
102
130
  ) -> None:
103
131
  """Show the newest comic."""
104
- run(ctx, lambda xk: present(xk, xk.latest(), View(json, not no_image, explain)))
132
+ run(ctx, lambda xk: present(xk, xk.latest(), view(json, no_image, explain, scale, fit)))
105
133
 
106
134
 
107
135
  @app.command()
108
136
  def random(
109
- ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
137
+ ctx: typer.Context,
138
+ no_image: NoImage = False,
139
+ explain: Explain = False,
140
+ json: Json = False,
141
+ scale: Scale = None,
142
+ fit: Fit = False,
110
143
  ) -> None:
111
144
  """Show a random comic."""
112
- run(ctx, lambda xk: present(xk, xk.random(), View(json, not no_image, explain)))
145
+ run(ctx, lambda xk: present(xk, xk.random(), view(json, no_image, explain, scale, fit)))
113
146
 
114
147
 
115
148
  @app.command()
@@ -146,11 +179,17 @@ def explain(ctx: typer.Context, num: Num, json: Json = False) -> None:
146
179
  def sync(
147
180
  ctx: typer.Context,
148
181
  full: Annotated[bool, typer.Option("--full", help="Fetch every comic and explanation.")] = False,
182
+ no_explain: Annotated[
183
+ bool, typer.Option("--no-explain", help="Skip explainxkcd for now (much faster); resync fills it in.")
184
+ ] = False,
149
185
  ) -> None:
150
186
  """Refresh the archive index and latest comic; with --full, download everything."""
151
187
  with console.status("Syncing") as status:
152
188
  added: int = run(
153
- ctx, lambda xk: xk.sync(full, lambda done, total: status.update(f"Syncing {done}/{total}"))
189
+ ctx,
190
+ lambda xk: xk.sync(
191
+ full, lambda done, total: status.update(f"Syncing {done}/{total}"), explain=not no_explain
192
+ ),
154
193
  )
155
194
  console.print(f"{added} new comic{'s' if added != 1 else ''} cached.")
156
195
 
@@ -176,4 +215,19 @@ def tui(ctx: typer.Context) -> None:
176
215
  """Open the interactive browser."""
177
216
  from xkcdkit.tui import run_tui
178
217
 
179
- run_tui(open_client(ctx.obj.db))
218
+ run_tui(open_client(ctx.obj.db), load_settings())
219
+
220
+
221
+ @app.command()
222
+ def config(
223
+ init: Annotated[bool, typer.Option("--init", help="Create the file with defaults.")] = False,
224
+ ) -> None:
225
+ """Show where settings live and what they are; --init writes a commented default file."""
226
+ path = config_path()
227
+ if init:
228
+ console.print(
229
+ f"{'created' if write_default(path) else 'exists'}: {path}", markup=False, soft_wrap=True
230
+ )
231
+ return
232
+ console.print(f"settings file: {path}", markup=False, soft_wrap=True)
233
+ console.print_json(load_settings(path).model_dump_json())
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import random
5
+ import time
5
6
  from collections.abc import Callable
6
7
  from datetime import timedelta
7
8
  from pathlib import Path
@@ -17,6 +18,7 @@ from xkcdkit.store import Store, utc_now
17
18
 
18
19
  Progress = Callable[[int, int], None]
19
20
  RETRY_MISSING_AFTER = timedelta(days=1)
21
+ WORKERS = 8
20
22
 
21
23
 
22
24
  class AsyncXKCD:
@@ -32,7 +34,7 @@ class AsyncXKCD:
32
34
  db: Database path. Defaults to the platform user data directory or ``$XKCDKIT_DB``.
33
35
  client: An ``httpx2.AsyncClient`` to use instead of the built-in one.
34
36
  timeout: Request timeout in seconds for the built-in client.
35
- explain_delay: Seconds to wait between explainxkcd requests during bulk syncs.
37
+ explain_delay: Minimum seconds between explainxkcd requests during bulk syncs.
36
38
  """
37
39
 
38
40
  def __init__(
@@ -48,6 +50,8 @@ class AsyncXKCD:
48
50
  timeout=timeout, headers={"User-Agent": user_agent()}, follow_redirects=True
49
51
  )
50
52
  self.explain_delay = explain_delay
53
+ self.explain_lock = asyncio.Lock()
54
+ self.last_explain = 0.0
51
55
 
52
56
  async def __aenter__(self) -> Self:
53
57
  """Enter the async context manager."""
@@ -67,6 +71,7 @@ class AsyncXKCD:
67
71
  async def complete(self, comic: Comic) -> Comic:
68
72
  """Attach the explainxkcd page when it exists and stamp the sync time."""
69
73
  found = await fetch_explanation(self.http, comic.num)
74
+ self.last_explain = time.monotonic()
70
75
  wikitext, revid = found if found else (None, None)
71
76
  return comic.model_copy(
72
77
  update={
@@ -126,39 +131,66 @@ class AsyncXKCD:
126
131
  self.store.put_archive(entries)
127
132
  return entries
128
133
 
129
- async def sync(self, full: bool = False, progress: Progress | None = None) -> int:
134
+ async def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
130
135
  """Refresh the archive index and the latest comic; with ``full`` fetch every comic.
131
136
 
132
137
  A full sync walks every number from 1 to the latest, so it does not depend
133
- on the archive page. Returns the number of comics newly added to the cache.
138
+ on the archive page. With ``explain=False`` it skips explainxkcd, which is
139
+ much faster; ``resync`` or a later ``get`` fills the explanations in.
140
+ Returns the number of comics newly added to the cache.
134
141
  """
135
142
  before = self.store.count()
136
143
  await self.refresh_archive()
137
144
  latest = await self.latest()
138
145
  if full:
139
146
  cached = set(self.store.nums())
140
- await self.fetch_many([n for n in range(1, latest.num + 1) if n not in cached], progress)
147
+ nums = [n for n in range(1, latest.num + 1) if n not in cached]
148
+ await self.fetch_many(nums, progress, explain)
141
149
  self.store.set_meta("last_full_sync_at", utc_now().isoformat())
142
150
  self.store.set_meta("last_sync_at", utc_now().isoformat())
143
151
  return self.store.count() - before
144
152
 
145
153
  async def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
146
- """Re-fetch comics older than ``max_age`` or missing an explanation. Returns the count."""
154
+ """Re-fetch comics older than ``max_age`` or without explanation data. Returns the count."""
147
155
  return await self.fetch_many(self.store.stale(max_age), progress)
148
156
 
149
- async def fetch_many(self, nums: list[int], progress: Progress | None) -> int:
150
- """Fetch and store each comic in turn, politely paced, skipping numbers xkcd lacks."""
151
- done = 0
152
- for index, num in enumerate(nums, start=1):
153
- try:
154
- self.store.replace(await self.complete(await fetch_comic(self.http, num)))
157
+ async def fetch_many(self, nums: list[int], progress: Progress | None, explain: bool = True) -> int:
158
+ """Fetch and store comics with several workers; explainxkcd is paced to one request at a time.
159
+
160
+ xkcd.com requests overlap freely. Each stored comic is committed at once, so an
161
+ interrupted run keeps everything finished so far. Numbers xkcd lacks are skipped.
162
+ """
163
+ pending, done, fetched = list(reversed(nums)), 0, 0
164
+
165
+ async def worker() -> None:
166
+ nonlocal done, fetched
167
+ while pending:
168
+ num = pending.pop()
169
+ try:
170
+ comic = await fetch_comic(self.http, num)
171
+ if explain:
172
+ await self.pace()
173
+ comic = await self.complete(comic)
174
+ self.store.replace(comic)
175
+ fetched += 1
176
+ except ComicNotFound:
177
+ pass
155
178
  done += 1
156
- except ComicNotFound:
157
- pass
158
- if progress:
159
- progress(index, len(nums))
160
- await asyncio.sleep(self.explain_delay)
161
- return done
179
+ if progress:
180
+ progress(done, len(nums))
181
+
182
+ async with asyncio.TaskGroup() as group:
183
+ for _ in range(min(WORKERS, len(nums))):
184
+ group.create_task(worker())
185
+ return fetched
186
+
187
+ async def pace(self) -> None:
188
+ """Wait until ``explain_delay`` has passed since the previous explainxkcd request."""
189
+ async with self.explain_lock:
190
+ wait = self.last_explain + self.explain_delay - time.monotonic()
191
+ if wait > 0:
192
+ await asyncio.sleep(wait)
193
+ self.last_explain = time.monotonic()
162
194
 
163
195
 
164
196
  class XKCD:
@@ -224,9 +256,9 @@ class XKCD:
224
256
  """See ``AsyncXKCD.explain``."""
225
257
  return self.run("explain", num) # type: ignore[no-any-return]
226
258
 
227
- def sync(self, full: bool = False, progress: Progress | None = None) -> int:
259
+ def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
228
260
  """See ``AsyncXKCD.sync``."""
229
- return self.run("sync", full, progress) # type: ignore[no-any-return]
261
+ return self.run("sync", full, progress, explain) # type: ignore[no-any-return]
230
262
 
231
263
  def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
232
264
  """See ``AsyncXKCD.resync``."""
@@ -0,0 +1,76 @@
1
+ """User settings from a small TOML file in the platform config directory."""
2
+
3
+ import os
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ from platformdirs import user_config_path
8
+ from pydantic import BaseModel, Field, ValidationError
9
+
10
+ from xkcdkit import XKCDError
11
+
12
+ DEFAULT_TOML = """# xkcdkit settings. Every key is optional; these are the defaults.
13
+
14
+ [image]
15
+ # CLI: multiply the image's natural terminal size (1 = as the terminal sizes it, 2 = twice as big).
16
+ scale = 1.0
17
+ # CLI: ignore scale and fill the terminal, keeping the aspect ratio.
18
+ fit = false
19
+
20
+ [tui]
21
+ # Height of the image pane relative to the text pane (1 to 8). Change live with + and -.
22
+ image_pane = 2
23
+ """
24
+
25
+
26
+ class ImageSettings(BaseModel):
27
+ """How the CLI draws images."""
28
+
29
+ scale: float = Field(default=1.0, gt=0, le=16)
30
+ fit: bool = False
31
+
32
+
33
+ class TuiSettings(BaseModel):
34
+ """How the browser lays itself out."""
35
+
36
+ image_pane: int = Field(default=2, ge=1, le=8)
37
+
38
+
39
+ class Settings(BaseModel):
40
+ """All settings, mirroring the sections of the TOML file."""
41
+
42
+ image: ImageSettings = ImageSettings()
43
+ tui: TuiSettings = TuiSettings()
44
+
45
+
46
+ def config_path() -> Path:
47
+ """Settings file location: ``$XKCDKIT_CONFIG`` or the platform user config directory."""
48
+ override = os.environ.get("XKCDKIT_CONFIG")
49
+ if override:
50
+ return Path(override)
51
+ return user_config_path("xkcdkit") / "config.toml"
52
+
53
+
54
+ def load_settings(path: Path | None = None) -> Settings:
55
+ """Read the settings file, falling back to defaults when it does not exist.
56
+
57
+ Raises:
58
+ XKCDError: when the file is not valid TOML or holds an invalid value.
59
+ """
60
+ path = path or config_path()
61
+ if not path.exists():
62
+ return Settings()
63
+ try:
64
+ return Settings.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
65
+ except (tomllib.TOMLDecodeError, ValidationError) as exc:
66
+ raise XKCDError(f"{path}: {exc}") from exc
67
+
68
+
69
+ def write_default(path: Path | None = None) -> bool:
70
+ """Create the settings file with documented defaults. Returns False if it already exists."""
71
+ path = path or config_path()
72
+ if path.exists():
73
+ return False
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ path.write_text(DEFAULT_TOML, encoding="utf-8")
76
+ return True
@@ -3,10 +3,12 @@
3
3
  import io
4
4
  import sys
5
5
 
6
+ from PIL import Image as PILImage
6
7
  from rich.console import Group, RenderableType
7
8
  from rich.panel import Panel
8
9
  from rich.text import Text
9
10
 
11
+ from xkcdkit import XKCDError
10
12
  from xkcdkit.models import Comic
11
13
 
12
14
 
@@ -26,15 +28,29 @@ def explanation_text(comic: Comic) -> Text:
26
28
  return Text(comic.explanation_text)
27
29
 
28
30
 
29
- def comic_image(data: bytes) -> RenderableType:
31
+ def comic_image(data: bytes, scale: float = 1.0, fit: bool = False) -> RenderableType:
30
32
  """A Rich renderable for the image bytes using the best protocol the terminal supports.
31
33
 
32
- textual-image probes the terminal on import, so it is imported here rather than
33
- at module load.
34
+ ``scale`` multiplies the image's natural terminal size; ``fit`` instead fills the
35
+ terminal while keeping the aspect ratio. textual-image probes the terminal on
36
+ import, so it is imported here rather than at module load.
37
+
38
+ Raises:
39
+ XKCDError: when the bytes are not an image.
34
40
  """
35
41
  from textual_image.renderable import Image
36
42
 
37
- renderable: RenderableType = Image(io.BytesIO(data))
43
+ try:
44
+ picture: PILImage.Image = PILImage.open(io.BytesIO(data))
45
+ picture.load()
46
+ except OSError as exc:
47
+ raise XKCDError(f"not an image: {exc}") from exc
48
+ if fit:
49
+ renderable: RenderableType = Image(picture, width="auto", height="auto")
50
+ return renderable
51
+ if scale != 1:
52
+ picture = picture.resize((round(picture.width * scale), round(picture.height * scale)))
53
+ renderable = Image(picture)
38
54
  return renderable
39
55
 
40
56
 
@@ -129,10 +129,10 @@ class Store:
129
129
  return str(row[0]) if row else None
130
130
 
131
131
  def stale(self, max_age: timedelta) -> list[int]:
132
- """Numbers of comics older than ``max_age`` or still missing an explanation."""
132
+ """Numbers of comics older than ``max_age`` or without explanation data yet."""
133
133
  cutoff = (utc_now() - max_age).isoformat()
134
134
  rows = self.db.execute(
135
- "SELECT num FROM comics WHERE synced_at < ? OR explanation_missing ORDER BY num",
135
+ "SELECT num FROM comics WHERE synced_at < ? OR explanation IS NULL ORDER BY num",
136
136
  (cutoff,),
137
137
  )
138
138
  return [row[0] for row in rows]
@@ -16,10 +16,12 @@ from textual_image.widget import Image
16
16
 
17
17
  from xkcdkit import XKCDError
18
18
  from xkcdkit.client import AsyncXKCD
19
+ from xkcdkit.config import Settings
19
20
  from xkcdkit.models import ArchiveEntry, Comic
20
21
  from xkcdkit.sources import fetch_image
21
22
 
22
- HELP = "j/k move / filter enter open r random l latest i image s sync q quit"
23
+ HELP = "j/k move / filter enter open r random l latest i image +/- zoom s sync q quit"
24
+ MAX_PANE = 8
23
25
 
24
26
 
25
27
  def option(entry: ArchiveEntry) -> Option:
@@ -35,6 +37,7 @@ class XKCDApp(App[None]):
35
37
  #archive-pane { width: 40; border: round $primary; }
36
38
  #archive { height: 1fr; }
37
39
  #image-pane { height: 2fr; border: round $secondary; align: center middle; }
40
+ #image { width: auto; height: auto; }
38
41
  TabbedContent { height: 1fr; }
39
42
  .text { padding: 0 1; }
40
43
  """
@@ -45,15 +48,21 @@ class XKCDApp(App[None]):
45
48
  Binding("r", "random", "Random"),
46
49
  Binding("l", "latest", "Latest"),
47
50
  Binding("i", "toggle_image", "Image"),
51
+ Binding("plus,equals_sign", "zoom(1)", "Zoom", key_display="+/-"),
52
+ Binding("minus", "zoom(-1)", "Zoom out", show=False),
48
53
  Binding("s", "sync", "Sync"),
54
+ Binding("down", "cursor('down')", "Down", show=False),
55
+ Binding("up", "cursor('up')", "Up", show=False),
56
+ Binding("escape", "focus_list", "List", show=False),
49
57
  Binding("question_mark", "help", "Help", key_display="?"),
50
58
  Binding("q", "quit", "Quit"),
51
59
  ]
52
60
 
53
- def __init__(self, client: AsyncXKCD) -> None:
54
- """Keep the client; the store it holds feeds the archive list."""
61
+ def __init__(self, client: AsyncXKCD, settings: Settings | None = None) -> None:
62
+ """Keep the client (its store feeds the archive list) and the user's settings."""
55
63
  super().__init__()
56
64
  self.client = client
65
+ self.settings = settings or Settings()
57
66
  self.entries: list[ArchiveEntry] = []
58
67
 
59
68
  def compose(self) -> ComposeResult:
@@ -77,6 +86,7 @@ class XKCDApp(App[None]):
77
86
  def on_mount(self) -> None:
78
87
  """Show the cached archive (syncing it on first run) and open the latest comic."""
79
88
  self.query_one(OptionList).focus()
89
+ self.query_one("#image-pane").styles.height = f"{self.settings.tui.image_pane}fr"
80
90
  self.entries = self.client.store.archive()
81
91
  self.show_entries(self.entries)
82
92
  if not self.entries:
@@ -100,10 +110,10 @@ class XKCDApp(App[None]):
100
110
  self.show_entries([e for score, e in scored if score >= 70])
101
111
 
102
112
  def on_input_submitted(self, event: Input.Submitted) -> None:
103
- """Enter in the filter opens the first match."""
113
+ """Enter in the filter opens the highlighted match (the first unless moved)."""
104
114
  archive = self.query_one(OptionList)
105
115
  if archive.option_count:
106
- self.open(int(str(archive.get_option_at_index(0).id)))
116
+ self.open(int(str(archive.get_option_at_index(archive.highlighted or 0).id)))
107
117
 
108
118
  def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
109
119
  """Enter on a row opens that comic."""
@@ -150,6 +160,16 @@ class XKCDApp(App[None]):
150
160
  """Jump to the filter box."""
151
161
  self.query_one("#filter", Input).focus()
152
162
 
163
+ def action_focus_list(self) -> None:
164
+ """Return to the archive list."""
165
+ self.query_one(OptionList).focus()
166
+
167
+ def action_zoom(self, step: int) -> None:
168
+ """Grow or shrink the image pane relative to the text pane."""
169
+ pane = self.query_one("#image-pane")
170
+ self.settings.tui.image_pane = min(MAX_PANE, max(1, self.settings.tui.image_pane + step))
171
+ pane.styles.height = f"{self.settings.tui.image_pane}fr"
172
+
153
173
  def action_random(self) -> None:
154
174
  """Open a random comic."""
155
175
  self.open(-1)
@@ -180,9 +200,9 @@ class XKCDApp(App[None]):
180
200
  self.notify(HELP, title="Keys", timeout=8)
181
201
 
182
202
 
183
- def run_tui(client: AsyncXKCD) -> None:
203
+ def run_tui(client: AsyncXKCD, settings: Settings | None = None) -> None:
184
204
  """Start the browser and close the client when it exits."""
185
205
  try:
186
- XKCDApp(client).run()
206
+ XKCDApp(client, settings).run()
187
207
  finally:
188
208
  client.store.close()
File without changes
File without changes
File without changes
File without changes