python-lucide 0.4.0__tar.gz → 0.5.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.
Files changed (23) hide show
  1. {python_lucide-0.4.0 → python_lucide-0.5.0}/PKG-INFO +57 -1
  2. {python_lucide-0.4.0 → python_lucide-0.5.0}/README.md +55 -0
  3. {python_lucide-0.4.0 → python_lucide-0.5.0}/pyproject.toml +2 -2
  4. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/cli.py +134 -29
  5. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/config.py +10 -11
  6. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/search.py +1 -1
  7. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/cli_test.py +105 -0
  8. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/__init__.py +0 -0
  9. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/build_clusters.py +0 -0
  10. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/build_search.py +0 -0
  11. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/core.py +0 -0
  12. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/__init__.py +0 -0
  13. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/gemini-icon-descriptions.jsonl +0 -0
  14. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icon-clusters.json +0 -0
  15. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icons.db +0 -0
  16. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/db.py +0 -0
  17. {python_lucide-0.4.0 → python_lucide-0.5.0}/src/lucide/dev_utils.py +0 -0
  18. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/__init__.py +0 -0
  19. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/build_clusters_test.py +0 -0
  20. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/build_search_test.py +0 -0
  21. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/conftest.py +0 -0
  22. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/core_test.py +0 -0
  23. {python_lucide-0.4.0 → python_lucide-0.5.0}/tests/search_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-lucide
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A Python package for working with Lucide icons
5
5
  Keywords: lucide,icons,svg,sqlite
6
6
  Author: Mike Macpherson
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Dist: cairosvg>=2.7.0 ; extra == 'search'
18
19
  Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
19
20
  Requires-Python: >=3.10
20
21
  Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
@@ -43,6 +44,7 @@ in your Python projects, with no javascript in sight.
43
44
 
44
45
  ## Features
45
46
  - 🎨 **Access 1600+ Lucide icons** directly from Python
47
+ - 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
46
48
  - 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
47
49
  - 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
48
50
  - 📦 **Lightweight** with minimal dependencies
@@ -83,6 +85,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
83
85
  lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
84
86
  ```
85
87
 
88
+ ## Semantic Search
89
+
90
+ Find icons by describing what you mean, not what they're called — in English
91
+ or [50+ languages](https://mmacpherson.github.io/python-lucide/):
92
+
93
+ <p align="center">
94
+ <a href="https://mmacpherson.github.io/python-lucide/">
95
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching &quot;celebrate a big achievement&quot; returns trophy, award, medal, party-popper">
96
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
97
+ </a>
98
+ </p>
99
+
100
+ ```bash
101
+ # One-off, no install (requires uv)
102
+ uvx --from "python-lucide[search]" lucide search "waiting for a download"
103
+
104
+ # Or install the search extra
105
+ pip install "python-lucide[search]"
106
+ lucide search "celebrate a big achievement"
107
+ ```
108
+
109
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
110
+
111
+ In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
112
+ WezTerm), the icons render inline as above — inside tmux too, with
113
+ `tmux set -g allow-passthrough on`. Elsewhere you get the plain text
114
+ list. (Rendering uses the cairo system library; if it's missing, the
115
+ CLI prints a hint.)
116
+
117
+ Icons are matched against AI-generated descriptions using the same embedding
118
+ model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
119
+ so descriptive phrases ("an idea just occurred to me") work better than
120
+ keywords. Options:
121
+
122
+ - `--model multilingual` — search in 50+ languages (default `bge-small` ranks
123
+ best for English)
124
+ - `--limit N` — number of results; `--verbose` — include each icon's description
125
+
126
+ The first search downloads the embedding model (~67 MB) and the pre-built
127
+ search index (11 MB); both are cached, and every search after that runs
128
+ locally in well under a second.
129
+
130
+ The same search is available as a Python API:
131
+
132
+ ```python
133
+ from lucide.search import search_icons
134
+
135
+ for r in search_icons("secure login", limit=5):
136
+ print(r.name, r.score)
137
+ ```
138
+
139
+ Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
140
+ runs the whole pipeline client-side.
141
+
86
142
  ## Framework Integration Examples
87
143
 
88
144
  ### Starlette
@@ -18,6 +18,7 @@ in your Python projects, with no javascript in sight.
18
18
 
19
19
  ## Features
20
20
  - 🎨 **Access 1600+ Lucide icons** directly from Python
21
+ - 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
21
22
  - 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
22
23
  - 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
23
24
  - 📦 **Lightweight** with minimal dependencies
@@ -58,6 +59,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
58
59
  lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
59
60
  ```
60
61
 
62
+ ## Semantic Search
63
+
64
+ Find icons by describing what you mean, not what they're called — in English
65
+ or [50+ languages](https://mmacpherson.github.io/python-lucide/):
66
+
67
+ <p align="center">
68
+ <a href="https://mmacpherson.github.io/python-lucide/">
69
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching &quot;celebrate a big achievement&quot; returns trophy, award, medal, party-popper">
70
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
71
+ </a>
72
+ </p>
73
+
74
+ ```bash
75
+ # One-off, no install (requires uv)
76
+ uvx --from "python-lucide[search]" lucide search "waiting for a download"
77
+
78
+ # Or install the search extra
79
+ pip install "python-lucide[search]"
80
+ lucide search "celebrate a big achievement"
81
+ ```
82
+
83
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
84
+
85
+ In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
86
+ WezTerm), the icons render inline as above — inside tmux too, with
87
+ `tmux set -g allow-passthrough on`. Elsewhere you get the plain text
88
+ list. (Rendering uses the cairo system library; if it's missing, the
89
+ CLI prints a hint.)
90
+
91
+ Icons are matched against AI-generated descriptions using the same embedding
92
+ model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
93
+ so descriptive phrases ("an idea just occurred to me") work better than
94
+ keywords. Options:
95
+
96
+ - `--model multilingual` — search in 50+ languages (default `bge-small` ranks
97
+ best for English)
98
+ - `--limit N` — number of results; `--verbose` — include each icon's description
99
+
100
+ The first search downloads the embedding model (~67 MB) and the pre-built
101
+ search index (11 MB); both are cached, and every search after that runs
102
+ locally in well under a second.
103
+
104
+ The same search is available as a Python API:
105
+
106
+ ```python
107
+ from lucide.search import search_icons
108
+
109
+ for r in search_icons("secure login", limit=5):
110
+ print(r.name, r.score)
111
+ ```
112
+
113
+ Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
114
+ runs the whole pipeline client-side.
115
+
61
116
  ## Framework Integration Examples
62
117
 
63
118
  ### Starlette
@@ -41,10 +41,10 @@ license = {text = "MIT"}
41
41
  name = "python-lucide"
42
42
  readme = "README.md"
43
43
  requires-python = ">=3.10"
44
- version = "0.4.0"
44
+ version = "0.5.0"
45
45
 
46
46
  [project.optional-dependencies]
47
- search = ["fastembed>=0.4.0"]
47
+ search = ["cairosvg>=2.7.0", "fastembed>=0.4.0"]
48
48
 
49
49
  [project.scripts]
50
50
  check-lucide-version = "lucide.dev_utils:print_version_status"
@@ -529,6 +529,40 @@ def _cmd_build_search(args: argparse.Namespace) -> int:
529
529
  return 0
530
530
 
531
531
 
532
+ def _first_run_downloads(model: str) -> list[str]:
533
+ """Human-readable one-time downloads the next search will trigger.
534
+
535
+ Best-effort: peeks at fastembed's model cache and the search-DB cache;
536
+ any surprise in their layout just means the notice is skipped.
537
+ """
538
+ from .search import search_available # noqa: PLC0415
539
+
540
+ pending = []
541
+ try:
542
+ from fastembed import TextEmbedding # noqa: PLC0415
543
+ from fastembed.common.utils import define_cache_dir # noqa: PLC0415
544
+
545
+ fastembed_name = EMBEDDING_MODELS[model].fastembed_model
546
+ meta = next(
547
+ m
548
+ for m in TextEmbedding.list_supported_models()
549
+ if m["model"] == fastembed_name
550
+ )
551
+ hf_repo = (meta.get("sources") or {}).get("hf") or ""
552
+ size_mb = round(meta["size_in_GB"] * 1000)
553
+ model_dir = (
554
+ pathlib.Path(define_cache_dir(None))
555
+ / f"models--{hf_repo.replace('/', '--')}"
556
+ )
557
+ if hf_repo and not model_dir.exists():
558
+ pending.append(f"embedding model (~{size_mb} MB)")
559
+ except Exception:
560
+ pass
561
+ if not search_available():
562
+ pending.append("search index (11 MB)")
563
+ return pending
564
+
565
+
532
566
  def _cmd_search(args: argparse.Namespace) -> int:
533
567
  """Handle ``lucide search``."""
534
568
  import os # noqa: PLC0415
@@ -549,6 +583,19 @@ def _cmd_search(args: argparse.Namespace) -> int:
549
583
  if candidate.exists():
550
584
  os.environ["LUCIDE_SEARCH_DB_PATH"] = str(candidate)
551
585
 
586
+ # Downloading without a token is expected here; the hf_xet download
587
+ # backend's HF_TOKEN nudge reads like an error mid-download, so keep
588
+ # warnings from its logger out of the way
589
+ logging.getLogger("hf_xet").setLevel(logging.ERROR)
590
+
591
+ pending = _first_run_downloads(args.model)
592
+ if pending:
593
+ print(
594
+ f"First run: downloading {' and '.join(pending)} — "
595
+ "cached for future searches.",
596
+ file=sys.stderr,
597
+ )
598
+
552
599
  try:
553
600
  results = search_icons(args.query, limit=args.limit, model=args.model)
554
601
  except Exception as e:
@@ -559,7 +606,9 @@ def _cmd_search(args: argparse.Namespace) -> int:
559
606
  print("No results found.")
560
607
  return 0
561
608
 
562
- show_icons = _terminal_supports_graphics()
609
+ show_icons, graphics_tip = _graphics_support()
610
+ if graphics_tip:
611
+ print(graphics_tip, file=sys.stderr)
563
612
  svgs: dict[str, str] = {}
564
613
  if show_icons:
565
614
  svgs = _load_svgs_for_results([r.name for r in results])
@@ -570,36 +619,77 @@ def _cmd_search(args: argparse.Namespace) -> int:
570
619
  cyan = "\033[38;2;100;200;220m"
571
620
 
572
621
  for r in results:
573
- if show_icons and r.name in svgs:
574
- _display_kitty_image(svgs[r.name])
575
- print(f" {bold}{cyan}{r.name}{reset} {dim}{r.score:.3f}{reset}")
622
+ prefix = " "
623
+ if show_icons:
624
+ drawn = r.name in svgs and _display_kitty_image(svgs[r.name])
625
+ # The image sits in columns 1-2 (cursor untouched, C=1), so
626
+ # step over it to keep names aligned whether or not it drew
627
+ prefix = "\033[3C" if drawn else " "
628
+ print(f"{prefix}{bold}{cyan}{r.name}{reset} {dim}{r.score:.0%}{reset}")
576
629
  if args.verbose and r.description:
577
630
  print(f" {dim}{r.description[:100]}{reset}")
578
- if show_icons:
579
- print()
580
631
 
581
632
  return 0
582
633
 
583
634
 
584
- def _terminal_supports_graphics() -> bool:
585
- """Check if the terminal supports the Kitty graphics protocol.
635
+ def _graphics_support() -> tuple[bool, str | None]:
636
+ """Decide whether to render inline icons via the Kitty graphics protocol.
586
637
 
587
- Note: tmux passthrough (allow-passthrough on) theoretically supports
588
- Kitty graphics, but cursor positioning is unreliable. Disabled for
589
- now see python-lucide-TODO (beads).
638
+ Returns:
639
+ A ``(show_icons, tip)`` pair. *tip* is a one-line stderr hint for
640
+ the cases where the terminal could show icons but something the
641
+ user can fix is in the way (tmux passthrough off, cairosvg not
642
+ installed) — silently dropping the feature proved confusing.
590
643
  """
591
644
  import os # noqa: PLC0415
592
645
 
593
- # tmux blocks reliable graphics rendering
594
- if os.environ.get("TMUX") or "screen" in os.environ.get("TERM", ""):
595
- return False
596
-
597
646
  term = os.environ.get("TERM_PROGRAM", "")
598
- if term in ("ghostty", "kitty", "WezTerm"):
599
- return True
600
- if os.environ.get("GHOSTTY_RESOURCES_DIR"):
601
- return True
602
- return "kitty" in os.environ.get("TERM", "")
647
+ host_capable = (
648
+ term in ("ghostty", "kitty", "WezTerm")
649
+ or bool(os.environ.get("GHOSTTY_RESOURCES_DIR"))
650
+ or bool(os.environ.get("KITTY_WINDOW_ID"))
651
+ or "kitty" in os.environ.get("TERM", "")
652
+ )
653
+ if not host_capable:
654
+ return False, None
655
+
656
+ if os.environ.get("TMUX") and not _tmux_allows_passthrough():
657
+ return False, (
658
+ "Tip: your terminal can show icons inline, but tmux blocks "
659
+ "them — run: tmux set -g allow-passthrough on"
660
+ )
661
+
662
+ try:
663
+ import cairosvg # noqa: F401, PLC0415
664
+ except ImportError:
665
+ return False, (
666
+ "Tip: your terminal can show icons inline — install cairosvg "
667
+ "to enable (pip install cairosvg, or uvx --with cairosvg)."
668
+ )
669
+ except OSError:
670
+ # cairosvg is installed but the native cairo library is absent;
671
+ # cairocffi raises OSError (not ImportError) from its dlopen
672
+ return False, (
673
+ "Tip: inline icons need the cairo system library "
674
+ "(e.g. apt install libcairo2, or brew install cairo)."
675
+ )
676
+
677
+ return True, None
678
+
679
+
680
+ def _tmux_allows_passthrough() -> bool:
681
+ """Check whether the current tmux pane permits escape passthrough."""
682
+ try:
683
+ proc = subprocess.run(
684
+ ["tmux", "show", "-Apv", "allow-passthrough"],
685
+ capture_output=True,
686
+ text=True,
687
+ timeout=2,
688
+ check=False,
689
+ )
690
+ except Exception:
691
+ return False
692
+ return proc.stdout.strip() in ("on", "all")
603
693
 
604
694
 
605
695
  def _load_svgs_for_results(names: list[str]) -> dict[str, str]:
@@ -619,30 +709,45 @@ def _load_svgs_for_results(names: list[str]) -> dict[str, str]:
619
709
  return svgs
620
710
 
621
711
 
622
- def _display_kitty_image(svg_content: str) -> None:
623
- """Render an SVG and display it inline via the Kitty graphics protocol."""
712
+ def _display_kitty_image(svg_content: str) -> bool:
713
+ """Render an SVG as a one-row inline image at the cursor position.
714
+
715
+ Emits with ``C=1`` so the cursor stays put and the caller controls
716
+ layout with ordinary cursor movement — which is also what makes tmux
717
+ passthrough safe: tmux never sees the image escape, and everything
718
+ that *does* move the cursor goes through tmux normally.
719
+
720
+ Returns:
721
+ True if an image escape was written.
722
+ """
624
723
  import base64 # noqa: PLC0415
625
- import sys # noqa: PLC0415
724
+ import os # noqa: PLC0415
626
725
 
627
726
  try:
628
727
  import cairosvg # noqa: PLC0415
629
- except ImportError:
630
- return
728
+ except (ImportError, OSError):
729
+ return False
631
730
 
632
731
  try:
633
732
  # Insert a white background rect so icons show on any terminal bg
634
733
  bg_rect = '<rect width="100%" height="100%" fill="white" rx="3"/>'
635
734
  padded_svg = svg_content.replace(">", f">{bg_rect}", 1)
735
+ # Rendered larger than displayed; the terminal downscales, which
736
+ # keeps strokes crisp on hidpi displays
636
737
  png_data: bytes = cairosvg.svg2png(
637
738
  bytestring=padded_svg.encode("utf-8"),
638
- output_width=48,
639
- output_height=48,
739
+ output_width=96,
740
+ output_height=96,
640
741
  )
641
742
  b64 = base64.b64encode(png_data).decode("ascii")
642
- sys.stdout.write(f"\033_Gf=100,t=d,a=T,r=2,c=4,q=2;{b64}\033\\\n")
743
+ seq = f"\033_Gf=100,t=d,a=T,r=1,c=2,C=1,q=2;{b64}\033\\"
744
+ if os.environ.get("TMUX"):
745
+ seq = "\033Ptmux;" + seq.replace("\033", "\033\033") + "\033\\"
746
+ sys.stdout.write(seq)
643
747
  sys.stdout.flush()
644
748
  except Exception:
645
- pass
749
+ return False
750
+ return True
646
751
 
647
752
 
648
753
  def _cmd_cluster(args: argparse.Namespace) -> int:
@@ -35,8 +35,10 @@ class EmbeddingModelConfig:
35
35
  pooling: transformers.js pooling mode ("mean" or "cls").
36
36
  web_dtype: transformers.js quantization. Document vectors always
37
37
  come from fastembed (fp32), so a quantized browser model only
38
- perturbs the query vector — q8 stays ~0.994 cosine to fp32,
39
- which is ranking-equivalent, and roughly quarters the download.
38
+ perturbs the query vector — q8 stays ~0.985-0.996 cosine to
39
+ fp32 across our models, which is ranking-equivalent (top-1
40
+ changes are swaps between near-tied results), and roughly
41
+ quarters the download.
40
42
  query_prefix: Prefix prepended to queries (asymmetric retrieval).
41
43
  document_prefix: Prefix prepended to documents at build time.
42
44
  label: Human-facing label for the web UI model toggle.
@@ -53,23 +55,20 @@ class EmbeddingModelConfig:
53
55
  label: str = ""
54
56
 
55
57
 
58
+ # Two models, not three: at q8 bge-small is 33 MB, close enough to
59
+ # all-MiniLM's 22 MB that a separate "Faster" tier bought nothing —
60
+ # evaluated against bge-base and arctic-embed-m (both ~104 MB q8) too,
61
+ # and neither beat bge-small on this corpus.
56
62
  EMBEDDING_MODELS: dict[str, EmbeddingModelConfig] = {
57
- "minilm": EmbeddingModelConfig(
58
- id="minilm",
59
- fastembed_model="sentence-transformers/all-MiniLM-L6-v2",
60
- web_model="Xenova/all-MiniLM-L6-v2",
61
- dim=384,
62
- pooling="mean",
63
- label="Faster",
64
- ),
65
63
  "bge-small": EmbeddingModelConfig(
66
64
  id="bge-small",
67
65
  fastembed_model="BAAI/bge-small-en-v1.5",
68
66
  web_model="Xenova/bge-small-en-v1.5",
69
67
  dim=384,
70
68
  pooling="cls",
69
+ web_dtype="q8",
71
70
  query_prefix="Represent this sentence for searching relevant passages: ",
72
- label="Better",
71
+ label="English",
73
72
  ),
74
73
  "multilingual": EmbeddingModelConfig(
75
74
  id="multilingual",
@@ -280,7 +280,7 @@ def search_icons(
280
280
  """Search for icons by natural language query.
281
281
 
282
282
  On the first call the search database is downloaded and cached in
283
- ``~/.cache/python-lucide/``. The embedding model (~35-130 MB depending
283
+ ``~/.cache/python-lucide/``. The embedding model (~67-220 MB depending
284
284
  on *model*) is also downloaded once by *fastembed*.
285
285
 
286
286
  Args:
@@ -223,3 +223,108 @@ class TestMainSubcommands:
223
223
  ):
224
224
  result = cli.main()
225
225
  assert result == 1
226
+
227
+
228
+ class TestInlineIcons:
229
+ GRAPHICS_ENV_VARS = (
230
+ "TERM_PROGRAM",
231
+ "GHOSTTY_RESOURCES_DIR",
232
+ "KITTY_WINDOW_ID",
233
+ "TERM",
234
+ "TMUX",
235
+ )
236
+
237
+ @pytest.fixture(autouse=True)
238
+ def clean_env(self, monkeypatch):
239
+ for var in self.GRAPHICS_ENV_VARS:
240
+ monkeypatch.delenv(var, raising=False)
241
+
242
+ def test_plain_terminal_no_icons_no_tip(self, monkeypatch):
243
+ monkeypatch.setenv("TERM", "xterm-256color")
244
+ show, tip = cli._graphics_support()
245
+ assert show is False
246
+ assert tip is None
247
+
248
+ def test_ghostty_enables_icons(self, monkeypatch):
249
+ monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
250
+ show, tip = cli._graphics_support()
251
+ assert show is True
252
+ assert tip is None
253
+
254
+ def test_tmux_without_passthrough_tips_instead(self, monkeypatch):
255
+ monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
256
+ monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
257
+ with mock.patch.object(cli, "_tmux_allows_passthrough", return_value=False):
258
+ show, tip = cli._graphics_support()
259
+ assert show is False
260
+ assert "allow-passthrough" in tip
261
+
262
+ def test_tmux_with_passthrough_enables_icons(self, monkeypatch):
263
+ monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
264
+ monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
265
+ with mock.patch.object(cli, "_tmux_allows_passthrough", return_value=True):
266
+ show, tip = cli._graphics_support()
267
+ assert show is True
268
+ assert tip is None
269
+
270
+ def test_missing_cairosvg_tips_instead(self, monkeypatch):
271
+ monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
272
+ with mock.patch.dict("sys.modules", {"cairosvg": None}):
273
+ show, tip = cli._graphics_support()
274
+ assert show is False
275
+ assert "cairosvg" in tip
276
+
277
+ def test_tmux_passthrough_query_handles_missing_tmux(self):
278
+ with mock.patch.object(cli.subprocess, "run", side_effect=FileNotFoundError):
279
+ assert cli._tmux_allows_passthrough() is False
280
+
281
+ SVG = (
282
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
283
+ '<circle cx="12" cy="12" r="9" stroke="black" fill="none"/></svg>'
284
+ )
285
+
286
+ def test_image_escape_bare(self, capfd):
287
+ pytest.importorskip("cairosvg")
288
+ assert cli._display_kitty_image(self.SVG) is True
289
+ out = capfd.readouterr().out
290
+ assert out.startswith("\033_G")
291
+ assert out.endswith("\033\\")
292
+ assert "C=1" in out and "r=1,c=2" in out
293
+
294
+ def test_image_escape_tmux_passthrough(self, capfd, monkeypatch):
295
+ pytest.importorskip("cairosvg")
296
+ monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,1234,0")
297
+ assert cli._display_kitty_image(self.SVG) is True
298
+ out = capfd.readouterr().out
299
+ assert out.startswith("\033Ptmux;")
300
+ assert out.endswith("\033\\")
301
+ body = out[len("\033Ptmux;") : -2]
302
+ # Inside the passthrough envelope every ESC must be doubled
303
+ i = 0
304
+ while i < len(body):
305
+ if body[i] == "\033":
306
+ assert body[i + 1] == "\033"
307
+ i += 2
308
+ else:
309
+ i += 1
310
+
311
+ def test_missing_cairosvg_returns_false(self):
312
+ with mock.patch.dict("sys.modules", {"cairosvg": None}):
313
+ assert cli._display_kitty_image(self.SVG) is False
314
+
315
+ def test_broken_native_cairo_tips_system_library(self, monkeypatch):
316
+ import builtins # noqa: PLC0415
317
+
318
+ monkeypatch.setenv("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
319
+ real_import = builtins.__import__
320
+
321
+ def fake_import(name, *args, **kwargs):
322
+ # cairocffi raises OSError from dlopen when libcairo is absent
323
+ if name == "cairosvg":
324
+ raise OSError("no library called 'cairo-2' was found")
325
+ return real_import(name, *args, **kwargs)
326
+
327
+ monkeypatch.setattr(builtins, "__import__", fake_import)
328
+ show, tip = cli._graphics_support()
329
+ assert show is False
330
+ assert "cairo system library" in tip