This commit is contained in:
nose
2025-12-07 20:52:47 -08:00
parent e3747555bf
commit be37eb9d5b
6 changed files with 373 additions and 28 deletions

View File

@@ -8,7 +8,8 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
from rich.console import Console from rich import box
from rich.console import Console, Group
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
@@ -92,32 +93,63 @@ class Renderer:
# Internal renderers ------------------------------------------------- # Internal renderers -------------------------------------------------
def _render_card(self, card: Card) -> None: def _render_card(self, card: Card) -> None:
suit = getattr(card, "suit", None) """Render a card in a rectangular layout with top/middle/bottom bars."""
lines: List[str] = [f"Arcana: {card.arcana}"]
if suit is not None and getattr(suit, "name", None): name = getattr(card, "name", "")
lines.append(f"Suit: {suit.name}") arcana = getattr(card, "arcana", "")
if getattr(card, "pip", 0): suit_obj = getattr(card, "suit", None)
lines.append(f"Pip: {card.pip}") suit = getattr(suit_obj, "name", "") if suit_obj is not None else ""
if getattr(card, "court_rank", ""): pip = getattr(card, "pip", None)
lines.append(f"Court Rank: {card.court_rank}") number = getattr(card, "number", "")
keywords = getattr(card, "keywords", None) or []
meaning = getattr(card, "meaning", None) meaning = getattr(card, "meaning", None)
if meaning is not None:
if getattr(meaning, "upright", None): top = Table.grid(expand=True)
lines.append(f"Upright: {meaning.upright}") for align in ("left", "center", "right"):
if getattr(meaning, "reversed", None): top.add_column(justify=align)
lines.append(f"Reversed: {meaning.reversed}") top_left = suit or arcana
keywords = getattr(card, "keywords", None) top_center = name
top_right = f"#{number}" if number not in (None, "") else ""
top.add_row(top_left, top_center, top_right)
body_lines = [f"Arcana: {arcana or '-'}"]
if suit:
body_lines.append(f"Suit: {suit}")
if pip:
body_lines.append(f"Pip: {pip}")
if getattr(card, "court_rank", ""):
body_lines.append(f"Court Rank: {card.court_rank}")
if keywords: if keywords:
lines.append(f"Keywords: {', '.join(keywords)}") body_lines.append(f"Keywords: {', '.join(keywords)}")
reversed_keywords = getattr(card, "reversed_keywords", None)
if reversed_keywords:
lines.append(f"Reversed Keywords: {', '.join(reversed_keywords)}")
guidance = getattr(card, "guidance", None) guidance = getattr(card, "guidance", None)
if guidance: if guidance:
lines.append(f"Guidance: {guidance}") body_lines.append(f"Guidance: {guidance}")
self.console.print( body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1))
Panel("\n".join(lines), title=f"{card.number}: {card.name}", expand=False)
bottom = Table(
show_header=False,
expand=True,
box=box.SIMPLE_HEAVY,
show_edge=True,
pad_edge=False,
) )
bottom.add_column(justify="left")
bottom.add_column(justify="center")
bottom.add_column(justify="right")
bottom.add_row(
self._label_block("Upright", getattr(meaning, "upright", None)),
self._label_block("Reversed", getattr(meaning, "reversed", None)),
self._label_block("Keywords", ", ".join(keywords) if keywords else None),
)
card_panel = Panel(
Group(top, body, bottom),
box=box.SQUARE,
padding=0,
title=name,
expand=False,
)
self.console.print(card_panel)
def _render_object(self, obj: Any) -> None: def _render_object(self, obj: Any) -> None:
attrs = {k: v for k, v in vars(obj).items() if not k.startswith("_")} attrs = {k: v for k, v in vars(obj).items() if not k.startswith("_")}
@@ -255,6 +287,11 @@ class Renderer:
def _humanize_key(key: str) -> str: def _humanize_key(key: str) -> str:
return key.replace("_", " ") return key.replace("_", " ")
@staticmethod
def _label_block(label: str, value: Optional[str]) -> str:
safe_val = value if value not in (None, "") else ""
return f"{label}:\n{safe_val}"
class PlanetRenderer: class PlanetRenderer:
"""Type-specific table rendering for planets.""" """Type-specific table rendering for planets."""

View File

@@ -12,3 +12,5 @@ cards = Tarot.deck.card.filter(suit="cups",type="ace")
print(cards) print(cards)
# Display using default deck # Display using default deck
display_cards(cards)
)

View File

@@ -242,12 +242,17 @@ def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]:
""" """
Draw cards for all positions in a spread. Draw cards for all positions in a spread.
Ensures all drawn cards are unique (no duplicates in a single spread).
Args: Args:
spread: The Spread object with positions defined spread: The Spread object with positions defined
deck: Optional list of Card objects. If None, uses Tarot.deck.cards deck: Optional list of Card objects. If None, uses Tarot.deck.cards
Returns: Returns:
List of DrawnCard objects (one per position) with random cards and reversals List of DrawnCard objects (one per position) with random cards and reversals
Raises:
ValueError: If spread has more positions than cards in the deck
""" """
import random import random
@@ -257,10 +262,18 @@ def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]:
deck_instance = Deck() deck_instance = Deck()
deck = deck_instance.cards deck = deck_instance.cards
# Validate that we have enough cards to draw from without duplicates
num_positions = len(spread.positions)
if num_positions > len(deck):
raise ValueError(
f"Cannot draw {num_positions} unique cards from deck of {len(deck)} cards"
)
# Draw unique cards using random.sample (no replacements)
drawn_deck = random.sample(deck, num_positions)
drawn_cards = [] drawn_cards = []
for position in spread.positions: for position, card in zip(spread.positions, drawn_deck):
# Draw random card
card = random.choice(deck)
# Random reversal (50% chance) # Random reversal (50% chance)
is_reversed = random.choice([True, False]) is_reversed = random.choice([True, False])
drawn_cards.append(DrawnCard(position, card, is_reversed)) drawn_cards.append(DrawnCard(position, card, is_reversed))

View File

@@ -6,13 +6,14 @@ supporting multiple decks and automatic image resolution.
""" """
import os import os
import io
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk, filedialog
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
try: try:
from PIL import Image, ImageTk from PIL import Image, ImageTk, ImageGrab, ImageDraw, ImageFont
HAS_PILLOW = True HAS_PILLOW = True
except ImportError: except ImportError:
HAS_PILLOW = False HAS_PILLOW = False
@@ -596,6 +597,7 @@ class SpreadDisplay:
ttk.Button(toolbar, text="Zoom Out (-)", command=lambda: self._zoom(0.8)).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Zoom Out (-)", command=lambda: self._zoom(0.8)).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="Reset View", command=self._reset_view).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Reset View", command=self._reset_view).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="Toggle Text", command=self._toggle_text).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Toggle Text", command=self._toggle_text).pack(side=tk.LEFT, padx=2)
ttk.Button(toolbar, text="Export PNG", command=self._export_image).pack(side=tk.LEFT, padx=2)
# Only show toggle top card if relevant # Only show toggle top card if relevant
if self.reading.spread.name == 'Celtic Cross': if self.reading.spread.name == 'Celtic Cross':
@@ -826,6 +828,40 @@ class SpreadDisplay:
self._draw_spread() self._draw_spread()
self._center_view() self._center_view()
def _export_image(self):
if not HAS_PILLOW:
print("Pillow library is not installed. Cannot export images.")
return
region = self.canvas.bbox("all")
if not region:
print("Nothing to export.")
return
x1, y1, x2, y2 = region
padding = 10
x1 -= padding
y1 -= padding
x2 += padding
y2 += padding
try:
default_name = f"{self.reading.spread.name.replace(' ', '_').lower()}_spread.png"
file_path = filedialog.asksaveasfilename(
defaultextension=".png",
initialfile=default_name,
filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")],
title="Save spread as...",
)
if not file_path:
return
img = self._render_spread_to_image(padding=padding)
img.save(file_path, "PNG")
print(f"Exported spread to {file_path}")
except Exception as exc:
print(f"Failed to export spread: {exc}")
def _center_view(self): def _center_view(self):
# Center the scroll region in the window # Center the scroll region in the window
# This is a bit tricky in Tkinter without knowing window size, # This is a bit tricky in Tkinter without knowing window size,
@@ -851,6 +887,131 @@ class SpreadDisplay:
else: else:
self._zoom(0.9) self._zoom(0.9)
def _render_spread_to_image(self, padding: int = 40) -> Image.Image:
"""Render the current spread to a PIL Image (no screen capture)."""
layout = self.LAYOUTS.get(self.reading.spread.name)
if not layout:
layout = {i + 1: {"pos": (i * 1.2, 0)} for i in range(len(self.reading.drawn_cards))}
# Use 4x resolution multiplier for high-quality export
card_width = int(400 * self.zoom_level)
card_height = int(600 * self.zoom_level)
unit_x = card_width
unit_y = card_height
positions = []
min_x, min_y = float("inf"), float("inf")
max_x, max_y = float("-inf"), float("-inf")
cards_to_draw = []
for drawn in self.reading.drawn_cards:
pos_data = layout.get(drawn.position.number)
if not pos_data:
continue
z_index = pos_data.get("z", 0)
if z_index > 0 and not self.show_top_card:
continue
cards_to_draw.append((drawn, pos_data, z_index))
cards_to_draw.sort(key=lambda x: x[2])
for drawn, pos_data, _ in cards_to_draw:
rel_x, rel_y = pos_data["pos"]
rotation = pos_data.get("rotate", 0)
x = rel_x * unit_x
y = rel_y * unit_y
half_w = card_width / 2
half_h = card_height / 2
if abs(rotation % 180) == 90:
half_w, half_h = half_h, half_w
min_x = min(min_x, x - half_w)
max_x = max(max_x, x + half_w)
min_y = min(min_y, y - half_h)
max_y = max(max_y, y + half_h)
positions.append((drawn, x, y, rotation))
if min_x == float("inf"):
min_x = min_y = 0
max_x = max_y = 0
width = int((max_x - min_x) + padding * 2)
height = int((max_y - min_y) + padding * 2)
bg_color = (44, 62, 80)
img = Image.new("RGB", (width, height), bg_color)
draw = ImageDraw.Draw(img)
font = ImageFont.load_default()
cx = -min_x + padding
cy = -min_y + padding
for drawn, rel_x, rel_y, rotation in positions:
x = cx + rel_x
y = cy + rel_y
pil_image = None
if self.image_loader:
img_path = self.image_loader.get_image_path(drawn.card)
if img_path and os.path.exists(img_path):
try:
pil_image = Image.open(img_path)
# Convert to RGB immediately to avoid transparency issues
if pil_image.mode in ("RGBA", "PA"):
# Create white background for transparency
background = Image.new("RGB", pil_image.size, "white")
background.paste(pil_image, mask=pil_image.split()[-1] if pil_image.mode == "RGBA" else None)
pil_image = background
elif pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
except Exception:
pil_image = None
if pil_image is None:
pil_image = Image.new("RGB", (card_width, card_height), "white")
ph_draw = ImageDraw.Draw(pil_image)
ph_draw.rectangle([0, 0, card_width - 1, card_height - 1], outline="black", width=2)
ph_draw.text((4, 4), drawn.card.name, fill="black", font=font)
else:
pil_image = pil_image.resize((card_width, card_height), Image.Resampling.LANCZOS)
if drawn.is_reversed:
rotation += 180
if rotation % 360 != 0:
# Always use RGB mode before rotation to avoid mask issues
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
pil_image = pil_image.rotate(rotation, expand=True, fillcolor="white")
# Final RGB conversion before paste
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
pw, ph = pil_image.size
try:
img.paste(pil_image, (int(x - pw / 2), int(y - ph / 2)))
except Exception as e:
print(f" Debug: Paste failed for {drawn.card.name} at ({x}, {y}): {e}")
print(f" Image mode: {pil_image.mode}, size: {pil_image.size}")
print(f" Canvas size: {img.size}, position: ({int(x - pw / 2)}, {int(y - ph / 2)})")
if self.show_text:
text_font = font
label = f"{drawn.position.number}. {drawn.position.name}"
meaning = drawn.position.meaning
text_h = text_font.getbbox("Ag")[3] - text_font.getbbox("Ag")[1]
block_h = int(text_h * 3.2)
bx1 = int(x - card_width / 2)
by1 = int(y + card_height / 2 - block_h)
bx2 = int(x + card_width / 2)
by2 = int(y + card_height / 2)
draw.rectangle([bx1, by1, bx2, by2], fill=(0, 0, 0, 180))
draw.text((bx1 + 4, by1 + 4), label, fill="white", font=text_font)
draw.text((bx1 + 4, by1 + 4 + text_h * 1.4), meaning, fill="#ecf0f1", font=text_font)
return img.convert("RGB")
def run(self): def run(self):
self.root.mainloop() self.root.mainloop()

38
test_parse.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python
import sys
sys.path.insert(0, 'src')
# Test parsing logic
test_cases = [
('2,11,20', [2, 11, 20]),
('1 5 10', [1, 5, 10]),
('3, 7, 14', [3, 7, 14]),
]
for test_input, expected in test_cases:
# Simulate the parsing logic
parts = ['display-cards'] + test_input.split()
nums = []
tokens = []
for tok in parts[1:]:
if ',' in tok:
tokens.extend(tok.split(','))
else:
tokens.append(tok)
for tok in tokens:
tok = tok.strip()
if tok:
try:
nums.append(int(tok))
except ValueError:
nums.append(-1)
status = '' if nums == expected else ''
print(f'{status} Input: "{test_input}" -> {nums} (expected {expected})')
# Test with actual cards
from tarot.tarot_api import Tarot
print("\n✓ Parsing logic works! Now test in REPL with:")
print(" display-cards 2,11,20")
print(" display-cards 1 5 10")

View File

@@ -37,6 +37,8 @@ if str(SRC_DIR) not in sys.path:
from cli_renderers import Renderer # noqa: E402 from cli_renderers import Renderer # noqa: E402
from tarot import Card, Tarot, Tree, Cube # noqa: E402 from tarot import Card, Tarot, Tree, Cube # noqa: E402
import tarot.ui as ui_mod # noqa: E402
from tarot.ui import SpreadDisplay, display_cards as ui_display_cards # noqa: E402
app = typer.Typer(add_completion=True, help="CLI playground for the Tarot API.") app = typer.Typer(add_completion=True, help="CLI playground for the Tarot API.")
@@ -56,6 +58,7 @@ SAFE_ROOTS: Dict[str, Any] = {
"tree": Tree, "tree": Tree,
"cube": Cube, "cube": Cube,
"letter": letter_ns, "letter": letter_ns,
"ui": ui_mod,
} }
_MISSING_DOCS: Set[str] = set() _MISSING_DOCS: Set[str] = set()
@@ -69,6 +72,8 @@ ROOT_COMMANDS = [
"tree", "tree",
"cube", "cube",
"letter", "letter",
"display-cards",
"display-spread",
"help", "help",
"quit", "quit",
"exit", "exit",
@@ -186,6 +191,10 @@ def _periodic_symbols() -> List[str]:
return list(periodic.keys()) if isinstance(periodic, dict) else [] return list(periodic.keys()) if isinstance(periodic, dict) else []
def _spread_names() -> List[str]:
return list(SpreadDisplay.LAYOUTS.keys())
def _current_fragment(text: str) -> str: def _current_fragment(text: str) -> str:
stripped = text.rstrip() stripped = text.rstrip()
if not stripped: if not stripped:
@@ -269,6 +278,9 @@ def _completion_candidates(text: str) -> List[str]:
if len(tokens) == 3 and tokens[1].lower() == "periodic": if len(tokens) == 3 and tokens[1].lower() == "periodic":
return [s for s in _periodic_symbols() if s.lower().startswith(fragment)] return [s for s in _periodic_symbols() if s.lower().startswith(fragment)]
if cmd == "display-spread":
return [name for name in _spread_names() if name.lower().startswith(fragment)]
if cmd == "search": if cmd == "search":
# Simple heuristics: suggest option flags or option values based on prior flag # Simple heuristics: suggest option flags or option values based on prior flag
if last_token.startswith("-"): if last_token.startswith("-"):
@@ -399,6 +411,9 @@ def _print_structure_table() -> None:
"Draw a spread with random cards", "Draw a spread with random cards",
"tarot.deck.card.spread('Celtic Cross')", "tarot.deck.card.spread('Celtic Cross')",
), ),
("ui.display_cards([card1, card2])", "Open GUI to view cards", "ui.display_cards([tarot.deck.card(1)])"),
("display-spread 'Celtic Cross'", "CLI: draw + show spread", "python typer-test.py display-spread 'Celtic Cross'"),
("display-cards 1 2 3", "CLI: show specific cards", "python typer-test.py display-cards 1 2 3"),
("tarot.planet()", "Planet correspondences as dict", "tarot.planet()['Venus']"), ("tarot.planet()", "Planet correspondences as dict", "tarot.planet()['Venus']"),
("tarot.hexagram()", "I Ching hexagrams as dict", "tarot.hexagram()[1]"), ("tarot.hexagram()", "I Ching hexagrams as dict", "tarot.hexagram()[1]"),
("tree.sephera(n)", "Kabbalistic sephiroth", "tree.sephera(1)"), ("tree.sephera(n)", "Kabbalistic sephiroth", "tree.sephera(1)"),
@@ -784,6 +799,39 @@ def _handle_repl_command(parts: List[str], raw_line: str = "") -> bool:
console.print("No cards matched that filter.") console.print("No cards matched that filter.")
else: else:
_print_cards_table(cards) _print_cards_table(cards)
@app.command("display-cards")
def display_cards_cmd(
numbers: List[int] = typer.Argument(..., help="Card numbers to display"),
deck_name: str = typer.Option("default", help="Deck name for UI display"),
) -> None:
"""Open a GUI window showing the given card numbers."""
_ensure_deck()
cards: List[Card] = []
for num in numbers:
card = Tarot.deck.card(num) # type: ignore[attr-defined]
if card is None:
typer.echo(f"Card {num} not found.")
raise typer.Exit(code=1)
cards.append(card)
ui_display_cards(cards, deck_name)
@app.command("display-spread")
def display_spread_cmd(
spread_name: str = typer.Argument("Celtic Cross", help="Spread name (e.g., 'Celtic Cross')"),
deck_name: str = typer.Option("default", help="Deck name for UI display"),
) -> None:
"""Draw a spread and open the GUI display for it."""
_ensure_deck()
reading = Tarot.deck.card.spread(spread_name) # type: ignore[attr-defined]
if reading is None:
typer.echo(f"Spread '{spread_name}' not found.")
raise typer.Exit(code=1)
ui_mod.display_spread(reading, deck_name)
return True return True
if cmd == "planet": if cmd == "planet":
planet_name = parts[1] if len(parts) > 1 else None planet_name = parts[1] if len(parts) > 1 else None
@@ -800,6 +848,52 @@ def _handle_repl_command(parts: List[str], raw_line: str = "") -> bool:
return True return True
_render_value(planet_obj) _render_value(planet_obj)
return True return True
if cmd == "display-cards":
if len(parts) < 2:
console.print("Usage: display-cards <card numbers...>")
console.print("Examples: display-cards 1 2 3")
console.print(" display-cards 2,11,20")
return True
_ensure_deck()
nums: List[int] = []
# Handle both space-separated and comma-separated numbers
tokens = []
for tok in parts[1:]:
# Split by comma if present
if "," in tok:
tokens.extend(tok.split(","))
else:
tokens.append(tok)
for tok in tokens:
tok = tok.strip()
if not tok:
continue
try:
nums.append(int(tok))
except ValueError:
console.print(f"Invalid card number: {tok}")
return True
cards: List[Card] = []
for num in nums:
card = Tarot.deck.card(num) # type: ignore[attr-defined]
if card is None:
console.print(f"Card {num} not found.")
return True
cards.append(card)
ui_display_cards(cards)
return True
if cmd == "display-spread":
spread_name = " ".join(parts[1:]) if len(parts) > 1 else "Celtic Cross"
_ensure_deck()
reading = Tarot.deck.card.spread(spread_name) # type: ignore[attr-defined]
if reading is None:
console.print(f"Spread '{spread_name}' not found.")
return True
ui_mod.display_spread(reading)
return True
if cmd == "hexagram": if cmd == "hexagram":
hex_num = None hex_num = None
if len(parts) > 1 and parts[1].isdigit(): if len(parts) > 1 and parts[1].isdigit():