diff --git a/cli_renderers.py b/cli_renderers.py index 38db754..be239db 100644 --- a/cli_renderers.py +++ b/cli_renderers.py @@ -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.""" diff --git a/mytest.py b/mytest.py index 14a6f51..e58f1fb 100644 --- a/mytest.py +++ b/mytest.py @@ -11,4 +11,6 @@ deck = Deck() cards = Tarot.deck.card.filter(suit="cups",type="ace") print(cards) -# Display using default deck \ No newline at end of file +# Display using default deck +display_cards(cards) +) \ No newline at end of file diff --git a/src/tarot/card/spread.py b/src/tarot/card/spread.py index ccdc689..8665ca5 100644 --- a/src/tarot/card/spread.py +++ b/src/tarot/card/spread.py @@ -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)) diff --git a/src/tarot/ui.py b/src/tarot/ui.py index 04e9cab..8dbcd46 100644 --- a/src/tarot/ui.py +++ b/src/tarot/ui.py @@ -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() diff --git a/test_parse.py b/test_parse.py new file mode 100644 index 0000000..9a4b46e --- /dev/null +++ b/test_parse.py @@ -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") diff --git a/typer-test.py b/typer-test.py index 613a8d1..b12efbe 100644 --- a/typer-test.py +++ b/typer-test.py @@ -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 ") + 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():