xkcdkit 0.1.0__tar.gz → 0.2.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.2.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
@@ -130,6 +130,40 @@ xkcd search "sandwich" --json | jq '.[].num'
130
130
  `--no-image` skips the picture. Images are also skipped automatically when
131
131
  stdout is not a terminal, so pipes always get clean text.
132
132
 
133
+ Images are drawn at the size the terminal reports for them, which can be
134
+ small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
135
+ them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
136
+
137
+ ```sh
138
+ xkcd show 614 --scale 2
139
+ xkcd latest --fit
140
+ ```
141
+
142
+ ### Settings file
143
+
144
+ Defaults for these live in a small TOML file. `xkcd config` prints its
145
+ location and the current values; `xkcd config --init` writes a commented
146
+ file with every key:
147
+
148
+ | Platform | Location |
149
+ | -------- | -------- |
150
+ | Linux | `~/.config/xkcdkit/config.toml` |
151
+ | macOS | `~/Library/Application Support/xkcdkit/config.toml` |
152
+ | Windows | `%APPDATA%\xkcdkit\config.toml` |
153
+
154
+ Set `XKCDKIT_CONFIG` to use another path.
155
+
156
+ ```toml
157
+ [image]
158
+ scale = 2.0 # CLI image size multiplier
159
+ fit = false # CLI: fill the terminal instead
160
+
161
+ [tui]
162
+ image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
163
+ ```
164
+
165
+ Command line flags always win over the file.
166
+
133
167
  ### The browser
134
168
 
135
169
  `xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
@@ -139,15 +173,20 @@ explanation.
139
173
  | Key | Action |
140
174
  | ------- | -------------------------------- |
141
175
  | `j`/`k` | move through the archive |
142
- | `/` | filter the archive by title |
176
+ | `/` | filter the archive by title; up/down still move the list, Esc returns to it |
143
177
  | `Enter` | open the selected comic |
144
178
  | `r` | random comic |
145
179
  | `l` | latest comic |
146
180
  | `i` | show or hide the image pane |
181
+ | `+`/`-` | grow or shrink the image pane |
147
182
  | `s` | sync the archive index |
148
183
  | `?` | key help |
149
184
  | `q` | quit |
150
185
 
186
+ The image always fills its pane while keeping its aspect ratio, so the pane
187
+ height is the zoom. The starting height comes from `image_pane` in the
188
+ settings file.
189
+
151
190
  ## Library
152
191
 
153
192
  The async client is the primary API. It is built for I/O bound programs such
@@ -98,6 +98,40 @@ xkcd search "sandwich" --json | jq '.[].num'
98
98
  `--no-image` skips the picture. Images are also skipped automatically when
99
99
  stdout is not a terminal, so pipes always get clean text.
100
100
 
101
+ Images are drawn at the size the terminal reports for them, which can be
102
+ small on a high-resolution screen. `--scale 2` (or `-s 3`, any factor) makes
103
+ them bigger, and `--fit` fills the terminal while keeping the aspect ratio:
104
+
105
+ ```sh
106
+ xkcd show 614 --scale 2
107
+ xkcd latest --fit
108
+ ```
109
+
110
+ ### Settings file
111
+
112
+ Defaults for these live in a small TOML file. `xkcd config` prints its
113
+ location and the current values; `xkcd config --init` writes a commented
114
+ file with every key:
115
+
116
+ | Platform | Location |
117
+ | -------- | -------- |
118
+ | Linux | `~/.config/xkcdkit/config.toml` |
119
+ | macOS | `~/Library/Application Support/xkcdkit/config.toml` |
120
+ | Windows | `%APPDATA%\xkcdkit\config.toml` |
121
+
122
+ Set `XKCDKIT_CONFIG` to use another path.
123
+
124
+ ```toml
125
+ [image]
126
+ scale = 2.0 # CLI image size multiplier
127
+ fit = false # CLI: fill the terminal instead
128
+
129
+ [tui]
130
+ image_pane = 3 # browser: image pane height relative to the text pane (1 to 8)
131
+ ```
132
+
133
+ Command line flags always win over the file.
134
+
101
135
  ### The browser
102
136
 
103
137
  `xkcd tui` opens a two-pane browser in the spirit of lazygit: the archive on
@@ -107,15 +141,20 @@ explanation.
107
141
  | Key | Action |
108
142
  | ------- | -------------------------------- |
109
143
  | `j`/`k` | move through the archive |
110
- | `/` | filter the archive by title |
144
+ | `/` | filter the archive by title; up/down still move the list, Esc returns to it |
111
145
  | `Enter` | open the selected comic |
112
146
  | `r` | random comic |
113
147
  | `l` | latest comic |
114
148
  | `i` | show or hide the image pane |
149
+ | `+`/`-` | grow or shrink the image pane |
115
150
  | `s` | sync the archive index |
116
151
  | `?` | key help |
117
152
  | `q` | quit |
118
153
 
154
+ The image always fills its pane while keeping its aspect ratio, so the pane
155
+ height is the zoom. The starting height comes from `image_pane` in the
156
+ settings file.
157
+
119
158
  ## Library
120
159
 
121
160
  The async client is the primary API. It is built for I/O bound programs such
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.1.0"
3
+ version = "0.2.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.2.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
 
@@ -75,6 +78,14 @@ class View:
75
78
  json: bool = False
76
79
  image: bool = True
77
80
  explain: bool = False
81
+ scale: float = 1.0
82
+ fit: bool = False
83
+
84
+
85
+ def view(json: bool, no_image: bool, explain: bool, scale: float | None, fit: bool) -> View:
86
+ """Combine command flags with the settings file; flags win."""
87
+ image = load_settings().image
88
+ return View(json, not no_image, explain, scale or image.scale, fit or image.fit)
78
89
 
79
90
 
80
91
  async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
@@ -84,32 +95,48 @@ async def present(xk: AsyncXKCD, pending: Awaitable[Comic], view: View) -> None:
84
95
  console.print_json(comic.model_dump_json())
85
96
  return
86
97
  if view.image and can_draw():
87
- console.print(comic_image(await fetch_image(xk.http, comic)))
98
+ console.print(comic_image(await fetch_image(xk.http, comic), view.scale, view.fit))
88
99
  console.print(comic_text(comic, explain=view.explain))
89
100
 
90
101
 
91
102
  @app.command()
92
103
  def show(
93
- ctx: typer.Context, num: Num, no_image: NoImage = False, explain: Explain = False, json: Json = False
104
+ ctx: typer.Context,
105
+ num: Num,
106
+ no_image: NoImage = False,
107
+ explain: Explain = False,
108
+ json: Json = False,
109
+ scale: Scale = None,
110
+ fit: Fit = False,
94
111
  ) -> None:
95
112
  """Show one comic by number."""
96
- run(ctx, lambda xk: present(xk, xk.get(num), View(json, not no_image, explain)))
113
+ run(ctx, lambda xk: present(xk, xk.get(num), view(json, no_image, explain, scale, fit)))
97
114
 
98
115
 
99
116
  @app.command()
100
117
  def latest(
101
- ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
118
+ ctx: typer.Context,
119
+ no_image: NoImage = False,
120
+ explain: Explain = False,
121
+ json: Json = False,
122
+ scale: Scale = None,
123
+ fit: Fit = False,
102
124
  ) -> None:
103
125
  """Show the newest comic."""
104
- run(ctx, lambda xk: present(xk, xk.latest(), View(json, not no_image, explain)))
126
+ run(ctx, lambda xk: present(xk, xk.latest(), view(json, no_image, explain, scale, fit)))
105
127
 
106
128
 
107
129
  @app.command()
108
130
  def random(
109
- ctx: typer.Context, no_image: NoImage = False, explain: Explain = False, json: Json = False
131
+ ctx: typer.Context,
132
+ no_image: NoImage = False,
133
+ explain: Explain = False,
134
+ json: Json = False,
135
+ scale: Scale = None,
136
+ fit: Fit = False,
110
137
  ) -> None:
111
138
  """Show a random comic."""
112
- run(ctx, lambda xk: present(xk, xk.random(), View(json, not no_image, explain)))
139
+ run(ctx, lambda xk: present(xk, xk.random(), view(json, no_image, explain, scale, fit)))
113
140
 
114
141
 
115
142
  @app.command()
@@ -176,4 +203,19 @@ def tui(ctx: typer.Context) -> None:
176
203
  """Open the interactive browser."""
177
204
  from xkcdkit.tui import run_tui
178
205
 
179
- run_tui(open_client(ctx.obj.db))
206
+ run_tui(open_client(ctx.obj.db), load_settings())
207
+
208
+
209
+ @app.command()
210
+ def config(
211
+ init: Annotated[bool, typer.Option("--init", help="Create the file with defaults.")] = False,
212
+ ) -> None:
213
+ """Show where settings live and what they are; --init writes a commented default file."""
214
+ path = config_path()
215
+ if init:
216
+ console.print(
217
+ f"{'created' if write_default(path) else 'exists'}: {path}", markup=False, soft_wrap=True
218
+ )
219
+ return
220
+ console.print(f"settings file: {path}", markup=False, soft_wrap=True)
221
+ console.print_json(load_settings(path).model_dump_json())
@@ -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
 
@@ -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
File without changes
File without changes