tr
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -12,3 +12,5 @@ cards = Tarot.deck.card.filter(suit="cups",type="ace")
|
||||
|
||||
print(cards)
|
||||
# Display using default deck
|
||||
display_cards(cards)
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
165
src/tarot/ui.py
165
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()
|
||||
|
||||
|
||||
38
test_parse.py
Normal file
38
test_parse.py
Normal 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")
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user