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 rich.console import Console
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
@@ -92,32 +93,63 @@ class Renderer:
# Internal renderers -------------------------------------------------
def _render_card(self, card: Card) -> None:
suit = getattr(card, "suit", None)
lines: List[str] = [f"Arcana: {card.arcana}"]
if suit is not None and getattr(suit, "name", None):
lines.append(f"Suit: {suit.name}")
if getattr(card, "pip", 0):
lines.append(f"Pip: {card.pip}")
if getattr(card, "court_rank", ""):
lines.append(f"Court Rank: {card.court_rank}")
"""Render a card in a rectangular layout with top/middle/bottom bars."""
name = getattr(card, "name", "")
arcana = getattr(card, "arcana", "")
suit_obj = getattr(card, "suit", None)
suit = getattr(suit_obj, "name", "") if suit_obj is not None else ""
pip = getattr(card, "pip", None)
number = getattr(card, "number", "")
keywords = getattr(card, "keywords", None) or []
meaning = getattr(card, "meaning", None)
if meaning is not None:
if getattr(meaning, "upright", None):
lines.append(f"Upright: {meaning.upright}")
if getattr(meaning, "reversed", None):
lines.append(f"Reversed: {meaning.reversed}")
keywords = getattr(card, "keywords", None)
top = Table.grid(expand=True)
for align in ("left", "center", "right"):
top.add_column(justify=align)
top_left = suit or arcana
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:
lines.append(f"Keywords: {', '.join(keywords)}")
reversed_keywords = getattr(card, "reversed_keywords", None)
if reversed_keywords:
lines.append(f"Reversed Keywords: {', '.join(reversed_keywords)}")
body_lines.append(f"Keywords: {', '.join(keywords)}")
guidance = getattr(card, "guidance", None)
if guidance:
lines.append(f"Guidance: {guidance}")
self.console.print(
Panel("\n".join(lines), title=f"{card.number}: {card.name}", expand=False)
body_lines.append(f"Guidance: {guidance}")
body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1))
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:
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:
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:
"""Type-specific table rendering for planets."""

View File

@@ -12,3 +12,5 @@ cards = Tarot.deck.card.filter(suit="cups",type="ace")
print(cards)
# 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.
Ensures all drawn cards are unique (no duplicates in a single spread).
Args:
spread: The Spread object with positions defined
deck: Optional list of Card objects. If None, uses Tarot.deck.cards
Returns:
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
@@ -257,10 +262,18 @@ def draw_spread(spread: Spread, deck: Optional[List] = None) -> List[DrawnCard]:
deck_instance = Deck()
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 = []
for position in spread.positions:
# Draw random card
card = random.choice(deck)
for position, card in zip(spread.positions, drawn_deck):
# Random reversal (50% chance)
is_reversed = random.choice([True, False])
drawn_cards.append(DrawnCard(position, card, is_reversed))

View File

@@ -6,13 +6,14 @@ supporting multiple decks and automatic image resolution.
"""
import os
import io
import tkinter as tk
from tkinter import ttk
from tkinter import ttk, filedialog
from pathlib import Path
from typing import List, Optional
try:
from PIL import Image, ImageTk
from PIL import Image, ImageTk, ImageGrab, ImageDraw, ImageFont
HAS_PILLOW = True
except ImportError:
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="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="Export PNG", command=self._export_image).pack(side=tk.LEFT, padx=2)
# Only show toggle top card if relevant
if self.reading.spread.name == 'Celtic Cross':
@@ -826,6 +828,40 @@ class SpreadDisplay:
self._draw_spread()
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):
# Center the scroll region in the window
# This is a bit tricky in Tkinter without knowing window size,
@@ -851,6 +887,131 @@ class SpreadDisplay:
else:
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):
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 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.")
@@ -56,6 +58,7 @@ SAFE_ROOTS: Dict[str, Any] = {
"tree": Tree,
"cube": Cube,
"letter": letter_ns,
"ui": ui_mod,
}
_MISSING_DOCS: Set[str] = set()
@@ -69,6 +72,8 @@ ROOT_COMMANDS = [
"tree",
"cube",
"letter",
"display-cards",
"display-spread",
"help",
"quit",
"exit",
@@ -186,6 +191,10 @@ def _periodic_symbols() -> List[str]:
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:
stripped = text.rstrip()
if not stripped:
@@ -269,6 +278,9 @@ def _completion_candidates(text: str) -> List[str]:
if len(tokens) == 3 and tokens[1].lower() == "periodic":
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":
# Simple heuristics: suggest option flags or option values based on prior flag
if last_token.startswith("-"):
@@ -399,6 +411,9 @@ def _print_structure_table() -> None:
"Draw a spread with random cards",
"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.hexagram()", "I Ching hexagrams as dict", "tarot.hexagram()[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.")
else:
_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
if cmd == "planet":
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
_render_value(planet_obj)
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":
hex_num = None
if len(parts) > 1 and parts[1].isdigit():