AI-powered Meme Generator

 """

AI-powered Meme Generator

- Pick a local image or image URL

- Generate captions with OpenAI (optional)

- Render meme-style text (top/bottom) on the image and save


Dependencies:

  pip install openai pillow requests python-dotenv

"""


import os

import textwrap

import requests

from io import BytesIO

from typing import List, Optional


from PIL import Image, ImageDraw, ImageFont, ImageOps


# Optional: OpenAI

try:

    import openai

    OPENAI_OK = True

except Exception:

    OPENAI_OK = False


# Optional .env loader

try:

    from dotenv import load_dotenv

    load_dotenv()

except Exception:

    pass


# If OPENAI_API_KEY in env, set key for openai package

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

if OPENAI_API_KEY and OPENAI_OK:

    openai.api_key = OPENAI_API_KEY


# ---------------------------

# Config

# ---------------------------

DEFAULT_FONT = None  # if None, use PIL default (or set path to .ttf)

FONT_SIZE_RATIO = 0.07  # fraction of image height to use as font size for main caption

STROKE_WIDTH_RATIO = 0.008  # stroke width relative to image height

TOP_BOTTOM_MARGIN_RATIO = 0.03  # vertical margin as fraction of image height

MAX_LINES = 3  # maximum lines of text for top/bottom each


# Some offline caption templates (fallback)

OFFLINE_TEMPLATES = [

    "When you finally fix the bug and the build passes",

    "Me: I'll sleep early tonight\nAlso me at 3 AM:",

    "That feeling when coffee kicks in",

    "POV: You open the fridge and forget what you wanted",

    "When your code works on the first run",

    "Expectation vs Reality",

    "When someone says 'just restart it'",

    "When the meeting could have been an email",

    "I don't always test my code, but when I do, I do it in production",

    "When you say you'll 'quickly' refactor"

]


# ---------------------------

# Utilities: Image loading

# ---------------------------

def load_image_from_path_or_url(path_or_url: str) -> Image.Image:

    """Load image from filesystem path or HTTP(S) URL."""

    if path_or_url.startswith("http://") or path_or_url.startswith("https://"):

        resp = requests.get(path_or_url, timeout=15)

        resp.raise_for_status()

        return Image.open(BytesIO(resp.content)).convert("RGBA")

    else:

        return Image.open(path_or_url).convert("RGBA")


# ---------------------------

# AI caption generation

# ---------------------------

def generate_captions_ai(prompt_context: str, n: int = 6, engine: str = "gpt-4") -> List[str]:

    """

    Use OpenAI to generate meme caption suggestions.

    - prompt_context: short description of the image or theme

    - n: number of suggestions

    """

    if not OPENAI_API_KEY or not OPENAI_OK:

        raise RuntimeError("OpenAI SDK not available or OPENAI_API_KEY not set.")

    # Compose a concise system/user prompt to ask for short, meme-style captions.

    system = (

        "You are a creative meme caption generator. "

        "Given a short description of an image or theme, produce short, humorous, internet-style captions. "

        "Return an array of captions without numbering. Keep each caption to 1-2 lines. Avoid offensive content."

    )

    user = (

        f"Image description / theme: {prompt_context}\n\n"

        "Produce exactly {} short caption suggestions (1-2 lines each). Use casual, meme-friendly tone."

        .format(n)

    )

    # Use Chat Completions API (ChatCompletion) if available

    try:

        resp = openai.ChatCompletion.create(

            model="gpt-4",

            messages=[

                {"role": "system", "content": system},

                {"role": "user", "content": user}

            ],

            max_tokens=400,

            temperature=0.8,

            n=1,

        )

        content = resp["choices"][0]["message"]["content"].strip()

        # Try to split into lines or bullets

        captions = []

        for line in content.splitlines():

            line = line.strip()

            if not line:

                continue

            # strip bullet numbers

            if line[0].isdigit() and (line[1:3] == "." or line[1:2] == ")"):

                line = line.split(".", 1)[-1].strip()

            if line.startswith("-") or line.startswith("•"):

                line = line[1:].strip()

            captions.append(line)

        # If we have fewer than requested, split by ';' or ' / '

        if len(captions) < n:

            parts = [p.strip() for p in content.replace("\n", ";").split(";") if p.strip()]

            captions = parts[:n]

        return captions[:n]

    except Exception as e:

        # fallback: raise to caller

        raise RuntimeError(f"OpenAI request failed: {e}")


# ---------------------------

# Text rendering on image (classic meme style)

# ---------------------------

def select_font(size_px: int) -> ImageFont.FreeTypeFont:

    """Try to load Impact-like font or fallback to default PIL font."""

    # Common Impact paths (Windows). You can ship an included ttf with your blog repo.

    paths = [

        "Impact.ttf",  # local copy

        "/usr/share/fonts/truetype/impact/Impact.ttf",

        "/usr/share/fonts/truetype/msttcorefonts/Impact.ttf",

        "/Library/Fonts/Impact.ttf",

        "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",

    ]

    if DEFAULT_FONT:

        paths.insert(0, DEFAULT_FONT)

    for p in paths:

        try:

            if os.path.exists(p):

                return ImageFont.truetype(p, size_px)

        except Exception:

            continue

    # Fallback

    return ImageFont.load_default()


def draw_text_with_stroke(draw: ImageDraw.Draw, position, text, font, fill="white", stroke_fill="black", stroke_width=2, align="center"):

    """Draw text with stroke (outline) for good readability."""

    x, y = position

    # Pillow >=8 supports stroke parameters; but to be robust we do manual strokes around offsets

    try:

        draw.text((x, y), text, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill, anchor="ms", align=align)

    except TypeError:

        # manual stroke (8 neighbors)

        for dx in range(-stroke_width, stroke_width+1):

            for dy in range(-stroke_width, stroke_width+1):

                if dx == 0 and dy == 0:

                    continue

                draw.text((x+dx, y+dy), text, font=font, fill=stroke_fill, anchor="ms", align=align)

        draw.text((x, y), text, font=font, fill=fill, anchor="ms", align=align)


def wrap_text_for_width(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:

    """Wrap text into lines that fit into max_width using the provided font."""

    lines = []

    # try naive wrap

    wrapper = textwrap.TextWrapper(width=60)

    words = text.split()

    if not words:

        return []

    # greedy algorithm: keep adding words until width exceeded

    cur = words[0]

    for w in words[1:]:

        test = cur + " " + w

        if font.getsize(test)[0] <= max_width:

            cur = test

        else:

            lines.append(cur)

            cur = w

    lines.append(cur)

    # If there are still too many lines, try to compress line breaks (reduce number of lines)

    return lines


def render_meme(image: Image.Image, top_text: Optional[str], bottom_text: Optional[str], out_path: str):

    """

    Render top and/or bottom text on the image in meme style and save to out_path.

    - top_text and bottom_text can be multi-line; function will wrap to fit.

    """

    img = image.convert("RGBA")

    w, h = img.size

    draw = ImageDraw.Draw(img)


    # Decide font sizes relative to image height

    font_size = max(14, int(h * FONT_SIZE_RATIO))

    stroke_w = max(1, int(h * STROKE_WIDTH_RATIO))

    font = select_font(font_size)


    # available text width is image width minus margins

    max_text_width = int(w * 0.92)


    def draw_block(text, y_anchor):

        if not text:

            return

        # Wrap into lines

        lines = []

        # naive wrapping tries smaller font if too many lines

        words = text.splitlines() if "\n" in text else [text]

        # flatten multiple lines and re-wrap each

        combined = " ".join(l.strip() for l in words if l.strip())

        # iterative approach: try to wrap and if too many lines, reduce font

        curr_font = font

        for attempt in range(4):

            # greedily wrap to fit

            lines = wrap_text_for_width(combined, curr_font, max_text_width)

            if len(lines) <= MAX_LINES:

                break

            # reduce font size and retry

            fs = max(12, int(curr_font.size * 0.9))

            curr_font = select_font(fs)

        # compute total height for block

        line_heights = [curr_font.getsize(line)[1] for line in lines]

        block_h = sum(line_heights) + (len(lines)-1) * 4


        # starting y depending on anchor ("top" or "bottom")

        if y_anchor == "top":

            y = int(h * TOP_BOTTOM_MARGIN_RATIO) + curr_font.getsize(lines[0])[1]//2

        else:  # bottom

            y = h - int(h * TOP_BOTTOM_MARGIN_RATIO) - block_h + curr_font.getsize(lines[0])[1]//2


        # draw each line centered

        for line in lines:

            x = w // 2

            draw_text_with_stroke(draw, (x, y), line, font=curr_font, fill="white", stroke_fill="black", stroke_width=stroke_w, align="center")

            y += curr_font.getsize(line)[1] + 4


    draw_block(top_text, "top")

    draw_block(bottom_text, "bottom")


    # Convert back and save

    final = img.convert("RGB")

    final.save(out_path, quality=95)

    return out_path


# ---------------------------

# Small CLI / Interactive prompt

# ---------------------------

def choose_from_list(prompt: str, options: List[str]) -> int:

    """Let user pick an index from options on the console."""

    print(prompt)

    for i, o in enumerate(options, start=1):

        print(f"{i}. {o}")

    while True:

        try:

            sel = input("Choose number (or 'c' to cancel): ").strip()

            if sel.lower() == "c":

                return -1

            idx = int(sel)-1

            if 0 <= idx < len(options):

                return idx

        except Exception:

            pass

        print("Invalid choice. Try again.")


def main_interactive():

    print("=== AI-Powered Meme Generator ===")

    print("Enter local image path, or an image URL (http/https).")

    path = input("Image path or URL: ").strip()

    if not path:

        print("No image provided — exiting.")

        return


    try:

        img = load_image_from_path_or_url(path)

    except Exception as e:

        print("Failed to load image:", e)

        return


    use_ai = False

    if OPENAI_API_KEY and OPENAI_OK:

        ans = input("Generate AI captions? (y/N): ").strip().lower()

        use_ai = ans == "y"

    else:

        print("OpenAI not configured — using offline caption templates.")


    captions = []

    if use_ai:

        desc = input("Describe the image/theme briefly (or press Enter to auto-describe): ").strip()

        if not desc:

            desc = "A funny photo suitable for memes (describe notable objects / people / mood)."

        try:

            captions = generate_captions_ai(desc, n=8)

        except Exception as e:

            print("AI caption generation failed:", e)

            print("Falling back to offline templates.")

            captions = OFFLINE_TEMPLATES.copy()

    else:

        captions = OFFLINE_TEMPLATES.copy()


    # Show captions and pick or let user type

    idx = choose_from_list("Choose a caption or pick 0 to enter your own:", ["[Type custom caption]"] + captions)

    if idx == -1:

        print("Cancelled.")

        return

    if idx == 0:

        custom = input("Enter custom caption (use '\\n' for line break): ")

        chosen = custom

    else:

        chosen = captions[idx-1]


    # Choose top/bottom or both

    pos_choice = input("Place caption at (1) Top, (2) Bottom, (3) Both? [2]: ").strip() or "2"

    if pos_choice not in ("1","2","3"):

        pos_choice = "2"

    if pos_choice == "1":

        top_text = chosen

        bottom_text = None

    elif pos_choice == "2":

        top_text = None

        bottom_text = chosen

    else:

        # Ask for a second caption or reuse

        second_idx = choose_from_list("Pick another caption for the other position or type custom:", ["[Type custom]"] + captions)

        if second_idx == -1:

            print("Cancelled.")

            return

        if second_idx == 0:

            second = input("Second caption: ")

        else:

            second = captions[second_idx-1]

        top_text = chosen

        bottom_text = second


    # Save file

    out_name = input("Output filename (default: meme_output.jpg): ").strip() or "meme_output.jpg"

    try:

        out_path = render_meme(img, top_text, bottom_text, out_name)

        print("Saved meme to", out_path)

    except Exception as e:

        print("Failed to render meme:", e)


if __name__ == "__main__":

    main_interactive()


Virtual Plant Care Simulator

import pygame

import sys

import json

import math

import time

from datetime import datetime


# -------------------------

# Config

# -------------------------

SAVE_FILE = "plant_save.json"

WIDTH, HEIGHT = 720, 560

FPS = 60


# In-game time scale: how many in-game minutes pass per real second

TIME_SCALE_MIN_PER_SEC = 5.0            # 1 real sec = 5 in-game minutes

TURBO_MULTIPLIER = 10.0                 # Turbo mode speed-up


# Decay & growth tuning (per in-game minute)

MOISTURE_DECAY = 0.10                   # moisture decreases per minute

NUTRIENT_DECAY = 0.02                   # nutrients decreases per minute

HEALTH_INC_RATE = 0.02                  # health regen per minute (good care)

HEALTH_DEC_RATE = 0.05                  # health loss per minute (bad care)

HEALTH_SEVERE_PENALTY = 0.20            # extra loss if moisture is 0


WATER_AMOUNT = 35                       # moisture gained per watering

FERTILIZER_AMOUNT = 25                  # nutrients gained per fertilize


GOOD_MOISTURE = 50

GOOD_NUTRIENTS = 50

LOW_MOISTURE = 30

LOW_NUTRIENTS = 30


# Growth stages by age (in-game minutes)

STAGES = [

    ("Seed", 0),

    ("Sprout", 12 * 60),         # 12 hours

    ("Young", 36 * 60),          # 1.5 days

    ("Mature", 3 * 24 * 60),     # 3 days

    ("Blooming", 7 * 24 * 60),   # 1 week

]


# Colors

WHITE = (255, 255, 255)

BG = (238, 245, 239)

TEXT = (34, 40, 49)

CARD = (245, 250, 245)

GREEN = (72, 157, 77)

YELLOW = (240, 190, 40)

RED = (220, 76, 70)

BLUE = (66, 135, 245)

BROWN = (99, 68, 48)

SOIL = (84, 59, 42)

GREY = (200, 205, 210)


pygame.init()

pygame.display.set_caption("Virtual Plant Care Simulator 🌱")

screen = pygame.display.set_mode((WIDTH, HEIGHT))

clock = pygame.time.Clock()

FONT = pygame.font.SysFont("consolas", 18)

FONT_BIG = pygame.font.SysFont("consolas", 24, bold=True)


# -------------------------

# Helpers

# -------------------------

def clamp(v, lo=0.0, hi=100.0):

    return max(lo, min(hi, v))


def now_iso():

    return datetime.now().isoformat()


def load_state():

    try:

        with open(SAVE_FILE, "r", encoding="utf-8") as f:

            data = json.load(f)

        # basic sanity defaults

        data.setdefault("name", "Leafy")

        data.setdefault("age_minutes", 0.0)

        data.setdefault("moisture", 70.0)

        data.setdefault("nutrients", 60.0)

        data.setdefault("health", 80.0)

        data.setdefault("alive", True)

        data.setdefault("last_update_iso", now_iso())

        data.setdefault("turbo", False)

        return data

    except FileNotFoundError:

        return {

            "name": "Leafy",

            "age_minutes": 0.0,

            "moisture": 70.0,

            "nutrients": 60.0,

            "health": 80.0,

            "alive": True,

            "last_update_iso": now_iso(),

            "turbo": False

        }


def save_state(state):

    with open(SAVE_FILE, "w", encoding="utf-8") as f:

        json.dump(state, f, indent=2)


def stage_from_age(age_min):

    current = STAGES[0][0]

    for name, threshold in STAGES:

        if age_min >= threshold:

            current = name

        else:

            break

    return current


def draw_bar(surface, x, y, w, h, pct, color, label):

    pygame.draw.rect(surface, GREY, (x, y, w, h), border_radius=6)

    inner_w = int(w * clamp(pct/100.0, 0, 1))

    pygame.draw.rect(surface, color, (x, y, inner_w, h), border_radius=6)

    txt = FONT.render(f"{label}: {int(pct)}%", True, TEXT)

    surface.blit(txt, (x + 8, y + h//2 - txt.get_height()//2))


def draw_button(surface, rect, text, active=True):

    col = CARD if active else (230,230,230)

    pygame.draw.rect(surface, col, rect, border_radius=8)

    pygame.draw.rect(surface, GREY, rect, width=1, border_radius=8)

    t = FONT.render(text, True, TEXT if active else (140,140,140))

    surface.blit(t, (rect.x + rect.w//2 - t.get_width()//2,

                     rect.y + rect.h//2 - t.get_height()//2))


def nice_time(minutes):

    d = int(minutes // (24*60))

    h = int((minutes % (24*60)) // 60)

    m = int(minutes % 60)

    parts = []

    if d: parts.append(f"{d}d")

    if h: parts.append(f"{h}h")

    parts.append(f"{m}m")

    return " ".join(parts)


# -------------------------

# Plant rendering

# -------------------------

def draw_pot(surface, cx, base_y):

    pot_w = 160

    pot_h = 70

    rim_h = 18

    # pot body

    pygame.draw.rect(surface, BROWN, (cx - pot_w//2, base_y - pot_h, pot_w, pot_h), border_radius=10)

    # soil

    pygame.draw.rect(surface, SOIL, (cx - pot_w//2 + 10, base_y - pot_h + rim_h, pot_w - 20, pot_h - rim_h - 6), border_radius=8)

    # rim

    pygame.draw.rect(surface, (120, 84, 60), (cx - pot_w//2, base_y - pot_h - 6, pot_w, rim_h), border_radius=8)


def draw_plant(surface, cx, base_y, age_min, health, moisture, nutrients, t):

    """

    Procedural plant:

    - Height scales with age & health

    - Stem sways using sin wave

    - Leaves hue via moisture/nutrients

    """

    # growth factor

    stage = stage_from_age(age_min)

    stage_index = [s[0] for s in STAGES].index(stage)

    height = 40 + stage_index * 35 + (health/100.0) * 30  # 40..~220


    # sway

    sway = math.sin(t * 2.0) * 8  # px

    top_x = cx + sway

    top_y = base_y - height


    # stem

    points = []

    segments = 8

    for i in range(segments+1):

        y = base_y - (height * i / segments)

        x = cx + math.sin(t*2 + i*0.6) * (8 * (i/segments))

        points.append((x, y))

    # draw stem as polyline (thicker at base)

    for i in range(len(points)-1):

        w = int(8 - 6*(i/segments))

        pygame.draw.line(surface, (46, 120, 52), points[i], points[i+1], max(2, w))


    # leaf color based on care

    care_score = (moisture/100.0 + nutrients/100.0 + health/100.0) / 3.0

    leaf_col = (

        int(60 + 120 * care_score),    # R

        int(120 + 120 * care_score),   # G

        int(60 + 80 * care_score)      # B

    )


    # leaves along stem

    for i in range(2, segments):

        px, py = points[i]

        lr = 18 + 6*(i/segments)

        angle = math.sin(t*3 + i) * 0.5 + (1 if i%2==0 else -1)*0.6

        # left leaf

        leaf_poly(surface, px, py, lr, angle, leaf_col)

        # right leaf

        leaf_poly(surface, px, py, lr, -angle, leaf_col)


    # bud/flower at top for late stages

    if stage in ("Mature", "Blooming"):

        r = 10 if stage == "Mature" else 14 + 4*math.sin(t*4)

        pygame.draw.circle(surface, (240, 110, 130), (int(points[-1][0]), int(points[-1][1])-8), int(abs(r)))


def leaf_poly(surface, x, y, r, angle, col):

    # Draw a simple rotated leaf (ellipse-ish polygon)

    pts = []

    steps = 10

    for i in range(steps+1):

        theta = -math.pi/2 + math.pi * (i/steps)

        px = x + r * math.cos(theta) * 0.4

        py = y + r * math.sin(theta)

        # rotate around (x,y)

        rx = x + (px - x) * math.cos(angle) - (py - y) * math.sin(angle)

        ry = y + (px - x) * math.sin(angle) + (py - y) * math.cos(angle)

        pts.append((rx, ry))

    pygame.draw.polygon(surface, col, pts)


# -------------------------

# Update logic

# -------------------------

def update_state(state, dt_minutes):

    if not state["alive"]:

        return


    # age grows faster when healthy

    health_factor = 0.5 + (state["health"]/100.0) * 0.5  # 0.5..1.0

    state["age_minutes"] += dt_minutes * health_factor


    # resource decay

    state["moisture"] = clamp(state["moisture"] - MOISTURE_DECAY * dt_minutes)

    state["nutrients"] = clamp(state["nutrients"] - NUTRIENT_DECAY * dt_minutes)


    # health dynamics

    good_care = (state["moisture"] >= GOOD_MOISTURE) and (state["nutrients"] >= GOOD_NUTRIENTS)

    low_care = (state["moisture"] < LOW_MOISTURE) or (state["nutrients"] < LOW_NUTRIENTS)


    if good_care:

        state["health"] = clamp(state["health"] + HEALTH_INC_RATE * dt_minutes)

    if low_care:

        penalty = HEALTH_DEC_RATE * dt_minutes

        # severe dehydration hurts more

        if state["moisture"] <= 0.1:

            penalty += HEALTH_SEVERE_PENALTY * dt_minutes

        state["health"] = clamp(state["health"] - penalty)


    if state["health"] <= 0.0:

        state["alive"] = False


def water(state):

    if not state["alive"]: return

    state["moisture"] = clamp(state["moisture"] + WATER_AMOUNT)


def fertilize(state):

    if not state["alive"]: return

    state["nutrients"] = clamp(state["nutrients"] + FERTILIZER_AMOUNT)


def revive(state):

    # Soft revive to keep testing

    state["alive"] = True

    state["health"] = max(state["health"], 40.0)

    state["moisture"] = max(state["moisture"], 40.0)


# -------------------------

# UI setup

# -------------------------

BUTTONS = {

    "water": pygame.Rect(40, 460, 140, 36),

    "fertilize": pygame.Rect(200, 460, 140, 36),

    "save": pygame.Rect(360, 460, 120, 36),

    "turbo": pygame.Rect(500, 460, 160, 36),

    "revive": pygame.Rect(40, 510, 120, 34),

    "new": pygame.Rect(180, 510, 120, 34),

    "quit": pygame.Rect(320, 510, 120, 34),

}


def reset_new_plant():

    return {

        "name": "Leafy",

        "age_minutes": 0.0,

        "moisture": 70.0,

        "nutrients": 60.0,

        "health": 80.0,

        "alive": True,

        "last_update_iso": now_iso(),

        "turbo": False

    }


# -------------------------

# Main loop

# -------------------------

def main():

    state = load_state()


    last_real = time.time()

    running = True


    while running:

        real_now = time.time()

        real_dt = real_now - last_real

        last_real = real_now


        # Time scale

        speed = TIME_SCALE_MIN_PER_SEC * (TURBO_MULTIPLIER if state.get("turbo") else 1.0)

        dt_minutes = real_dt * speed


        # Update plant based on dt

        update_state(state, dt_minutes)


        # Draw

        screen.fill(BG)


        # Left info card

        pygame.draw.rect(screen, CARD, (24, 24, 280, 410), border_radius=12)

        pygame.draw.rect(screen, GREY, (24, 24, 280, 410), width=1, border_radius=12)


        title = FONT_BIG.render("Plant Status", True, TEXT)

        screen.blit(title, (40, 36))


        # Bars

        draw_bar(screen, 40, 80, 240, 20, state["health"], GREEN if state["health"] >= 50 else YELLOW if state["health"] >= 25 else RED, "Health")

        draw_bar(screen, 40, 120, 240, 20, state["moisture"], BLUE if state["moisture"] >= 40 else YELLOW if state["moisture"] >= 20 else RED, "Moisture")

        draw_bar(screen, 40, 160, 240, 20, state["nutrients"], GREEN if state["nutrients"] >= 40 else YELLOW if state["nutrients"] >= 20 else RED, "Nutrients")


        # Text stats

        lines = [

            f"Name: {state['name']}",

            f"Stage: {stage_from_age(state['age_minutes'])}",

            f"Age: {nice_time(state['age_minutes'])}",

            f"Alive: {'Yes' if state['alive'] else 'No'}",

            f"Speed: {'Turbo' if state.get('turbo') else 'Normal'} (x{int(TURBO_MULTIPLIER) if state.get('turbo') else 1})",

            "",

            "Tips:",

            "- Keep moisture & nutrients ≥ 50.",

            "- Turbo to fast-forward growth.",

            "- Save to keep progress.",

            "- Revive for testing if it dies."

        ]

        y = 200

        for ln in lines:

            txt = FONT.render(ln, True, TEXT)

            screen.blit(txt, (40, y))

            y += 22


        # Plant area (right)

        pygame.draw.rect(screen, CARD, (324, 24, 372, 410), border_radius=12)

        pygame.draw.rect(screen, GREY, (324, 24, 372, 410), width=1, border_radius=12)

        arena = pygame.Rect(324, 24, 372, 410)

        cx = arena.x + arena.w//2

        base_y = arena.y + arena.h - 40


        # Ground line

        pygame.draw.line(screen, (180, 180, 180), (arena.x + 20, base_y), (arena.right - 20, base_y), 2)


        # Draw pot & plant

        draw_pot(screen, cx, base_y)

        t = pygame.time.get_ticks() / 1000.0

        draw_plant(screen, cx, base_y, state["age_minutes"], state["health"], state["moisture"], state["nutrients"], t)


        # Buttons

        draw_button(screen, BUTTONS["water"], "💧 Water", active=state["alive"])

        draw_button(screen, BUTTONS["fertilize"], "🧪 Fertilize", active=state["alive"])

        draw_button(screen, BUTTONS["save"], "💾 Save")

        draw_button(screen, BUTTONS["turbo"], f"⚡ Turbo: {'ON' if state.get('turbo') else 'OFF'}")

        draw_button(screen, BUTTONS["revive"], "❤️ Revive")

        draw_button(screen, BUTTONS["new"], "🌱 New Plant")

        draw_button(screen, BUTTONS["quit"], "🚪 Quit")


        # Warning labels

        warn_y = 430

        if state["moisture"] < LOW_MOISTURE:

            wtxt = FONT.render("Low Moisture! Water me 💧", True, BLUE)

            screen.blit(wtxt, (40, warn_y)); warn_y += 22

        if state["nutrients"] < LOW_NUTRIENTS:

            wtxt = FONT.render("Low Nutrients! Fertilize 🧪", True, (150, 100, 20))

            screen.blit(wtxt, (40, warn_y)); warn_y += 22

        if not state["alive"]:

            wtxt = FONT_BIG.render("The plant has died. Try Revive or New Plant.", True, RED)

            screen.blit(wtxt, (40, warn_y))


        pygame.display.flip()


        # Events

        for event in pygame.event.get():

            if event.type == pygame.QUIT:

                save_state(state)

                running = False

            elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:

                mx, my = event.pos

                if BUTTONS["water"].collidepoint(mx, my) and state["alive"]:

                    water(state)

                elif BUTTONS["fertilize"].collidepoint(mx, my) and state["alive"]:

                    fertilize(state)

                elif BUTTONS["save"].collidepoint(mx, my):

                    save_state(state)

                elif BUTTONS["turbo"].collidepoint(mx, my):

                    state["turbo"] = not state.get("turbo", False)

                elif BUTTONS["revive"].collidepoint(mx, my):

                    revive(state)

                elif BUTTONS["new"].collidepoint(mx, my):

                    state = reset_new_plant()

                elif BUTTONS["quit"].collidepoint(mx, my):

                    save_state(state)

                    running = False

            elif event.type == pygame.KEYDOWN:

                if event.key == pygame.K_w and state["alive"]:

                    water(state)

                elif event.key == pygame.K_f and state["alive"]:

                    fertilize(state)

                elif event.key == pygame.K_s:

                    save_state(state)

                elif event.key == pygame.K_t:

                    state["turbo"] = not state.get("turbo", False)

                elif event.key == pygame.K_r:

                    revive(state)

                elif event.key == pygame.K_n:

                    state = reset_new_plant()

                elif event.key == pygame.K_ESCAPE:

                    save_state(state)

                    running = False


        clock.tick(FPS)


    pygame.quit()

    sys.exit()


if __name__ == "__main__":

    main()


Handwritten Math Solver

 Install requirements

pip install tensorflow Pillow opencv-python numpy sympy

Train a digit model once (MNIST) — train_mnist_cnn.py

This trains a small CNN on MNIST and saves mnist_cnn.h5.

# train_mnist_cnn.py

import tensorflow as tf

from tensorflow import keras

from tensorflow.keras import layers


def build_model():

    model = keras.Sequential([

        layers.Input(shape=(28, 28, 1)),

        layers.Conv2D(32, 3, activation='relu'),

        layers.Conv2D(32, 3, activation='relu'),

        layers.MaxPooling2D(),

        layers.Dropout(0.25),


        layers.Conv2D(64, 3, activation='relu'),

        layers.Conv2D(64, 3, activation='relu'),

        layers.MaxPooling2D(),

        layers.Dropout(0.25),


        layers.Flatten(),

        layers.Dense(128, activation='relu'),

        layers.Dropout(0.5),

        layers.Dense(10, activation='softmax')

    ])

    model.compile(optimizer='adam',

                  loss='sparse_categorical_crossentropy',

                  metrics=['accuracy'])

    return model


def main():

    (x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()

    x_train = x_train.astype("float32") / 255.0

    x_test = x_test.astype("float32") / 255.0

    x_train = x_train[..., None]

    x_test = x_test[..., None]


    model = build_model()

    model.fit(x_train, y_train, batch_size=128, epochs=5, validation_split=0.1)

    test_loss, test_acc = model.evaluate(x_test, y_test, verbose=0)

    print(f"Test accuracy: {test_acc:.4f}")

    model.save("mnist_cnn.h5")

    print("Saved model to mnist_cnn.h5")


if __name__ == "__main__":

    main()

The GUI solver — handwritten_math_solver.py

# handwritten_math_solver.py
import tkinter as tk
from tkinter import messagebox
from PIL import Image, ImageDraw, ImageOps
import numpy as np
import cv2
import io
from sympy import sympify, simplify
from tensorflow.keras.models import load_model
# ---- Config ----
MODEL_PATH = "mnist_cnn.h5"
CANVAS_SIZE = 400           # drawing canvas (square)
DRAW_WIDTH = 14             # brush thickness (thicker = easier OCR)
MIN_CONTOUR_AREA = 60       # filter noise
PADDING = 8                 # pad per glyph before resize to 28x28
# Heuristics thresholds for operators
MINUS_AR_THRESH = 2.0       # width/height > this → likely '-'
MINUS_HEIGHT_FRAC = 0.45    # symbol height relative to median digit height (shorter → minus)
PLUS_PEAK_FRAC = 0.6        # vertical and horizontal central peaks to consider '+'
class MathSolverApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Handwritten Math Solver (digits + +/−)")
        # Canvas to draw
        self.canvas = tk.Canvas(root, width=CANVAS_SIZE, height=CANVAS_SIZE, bg="white", cursor="cross")
        self.canvas.grid(row=0, column=0, columnspan=3, padx=10, pady=10)
        # PIL image to accumulate strokes (black on white)
        self.image = Image.new("L", (CANVAS_SIZE, CANVAS_SIZE), color=255)
        self.draw = ImageDraw.Draw(self.image)
        # Bind drawing
        self.last_x, self.last_y = None, None
        self.canvas.bind("<ButtonPress-1>", self.pen_down)
        self.canvas.bind("<B1-Motion>", self.paint)
        self.canvas.bind("<ButtonRelease-1>", self.pen_up)
        # Buttons
        tk.Button(root, text="Recognize & Solve", command=self.recognize_and_solve).grid(row=1, column=0, pady=6)
        tk.Button(root, text="Clear", command=self.clear_canvas).grid(row=1, column=1, pady=6)
        tk.Button(root, text="Quit", command=root.quit).grid(row=1, column=2, pady=6)
        # Output
        self.expr_var = tk.StringVar(value="Expression: ")
        self.result_var = tk.StringVar(value="Result: ")
        self.step_text = tk.Text(root, width=60, height=10, wrap="word")
        tk.Label(root, textvariable=self.expr_var, anchor="w").grid(row=2, column=0, columnspan=3, sticky="w", padx=10)
        tk.Label(root, textvariable=self.result_var, anchor="w").grid(row=3, column=0, columnspan=3, sticky="w", padx=10)
        tk.Label(root, text="Steps:").grid(row=4, column=0, sticky="w", padx=10)
        self.step_text.grid(row=5, column=0, columnspan=3, padx=10, pady=4)
        # Load model
        try:
            self.model = load_model(MODEL_PATH)
        except Exception as e:
            messagebox.showerror("Model Error",
                                 f"Could not load {MODEL_PATH}.\nTrain it first with train_mnist_cnn.py.\n\n{e}")
            self.model = None
    # ---------- Drawing handlers ----------
    def pen_down(self, event):
        self.last_x, self.last_y = event.x, event.y
    def paint(self, event):
        if self.last_x is not None and self.last_y is not None:
            # Draw on Tk canvas
            self.canvas.create_line(self.last_x, self.last_y, event.x, event.y,
                                    width=DRAW_WIDTH, fill="black", capstyle=tk.ROUND, smooth=True)
            # Draw on PIL image
            self.draw.line([self.last_x, self.last_y, event.x, event.y],
                           fill=0, width=DRAW_WIDTH)
        self.last_x, self.last_y = event.x, event.y
    def pen_up(self, event):
        self.last_x, self.last_y = None, None
    def clear_canvas(self):
        self.canvas.delete("all")
        self.image = Image.new("L", (CANVAS_SIZE, CANVAS_SIZE), color=255)
        self.draw = ImageDraw.Draw(self.image)
        self.expr_var.set("Expression: ")
        self.result_var.set("Result: ")
        self.step_text.delete("1.0", tk.END)
    # ---------- Core pipeline ----------
    def recognize_and_solve(self):
        if self.model is None:
            messagebox.showwarning("Model", "Model not loaded.")
            return
        # Convert PIL to OpenCV
        img = np.array(self.image)
        expr, tokens_dbg = self.image_to_expression(img)
        if not expr:
            messagebox.showwarning("Parse", "Could not parse any symbols. Try writing bigger/cleaner.")
            return
        self.expr_var.set(f"Expression: {expr}")
        try:
            # Use sympy to evaluate
            sym_expr = sympify(expr)
            simplified = simplify(sym_expr)
            self.result_var.set(f"Result: {simplified}")
            # Show steps (simple for now)
            self.step_text.delete("1.0", tk.END)
            self.step_text.insert(tk.END, "Tokens (left→right):\n")
            self.step_text.insert(tk.END, " ".join(tokens_dbg) + "\n\n")
            self.step_text.insert(tk.END, f"SymPy parsed: {sym_expr}\n")
            if str(sym_expr) != str(simplified):
                self.step_text.insert(tk.END, f"Simplified: {simplified}\n")
            else:
                self.step_text.insert(tk.END, "No further simplification needed.\n")
        except Exception as e:
            messagebox.showerror("Evaluation Error", f"Failed to evaluate expression:\n{e}")
    def image_to_expression(self, gray_img: np.ndarray) -> tuple[str, list]:
        """
        Segment symbols, classify digits with CNN, infer + / - with projection heuristics.
        Returns (expression_string, debug_tokens)
        """
        # 1) Binarize & clean
        # Invert: handwriting is black (0), background white (255) => for OpenCV we want white-on-black for morphology ops.
        inv = 255 - gray_img
        # Threshold
        _, th = cv2.threshold(inv, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
        # Morph open small noise
        kernel = np.ones((3,3), np.uint8)
        th = cv2.morphologyEx(th, cv2.MORPH_OPEN, kernel, iterations=1)
        # 2) Find contours (symbols)
        contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        boxes = []
        for cnt in contours:
            x, y, w, h = cv2.boundingRect(cnt)
            area = w * h
            if area < MIN_CONTOUR_AREA:
                continue
            boxes.append((x, y, w, h))
        if not boxes:
            return "", []
        # Sort left-to-right
        boxes.sort(key=lambda b: b[0])
        # Median height (helps operator heuristics)
        med_h = np.median([h for (_, _, _, h) in boxes])
        tokens = []
        debug_tokens = []
        for (x, y, w, h) in boxes:
            crop = th[y:y+h, x:x+w]  # white ink on black background
            # Operator heuristic first (minus / plus)
            op = self.classify_operator(crop, w, h, med_h)
            if op is not None:
                tokens.append(op)
                debug_tokens.append(f"[{op}]")
                continue
            # Otherwise, digit classification
            digit = self.classify_digit(crop)
            if digit is None:
                # If not digit and not recognized operator, skip (or treat as minus attempt)
                # Safer to skip
                continue
            tokens.append(str(digit))
            debug_tokens.append(str(digit))
        # Merge digits & operators into expression string
        expr = self.tokens_to_expression(tokens)
        return expr, debug_tokens
    def classify_digit(self, crop_bin: np.ndarray) -> int | None:
        """
        Prepare glyph for MNIST CNN (28x28, centered), and predict 0-9.
        crop_bin: white ink on black background (binary)
        """
        # Make sure it's binary (0/255)
        crop = (crop_bin > 0).astype(np.uint8) * 255
        # Add padding
        crop = cv2.copyMakeBorder(crop, PADDING, PADDING, PADDING, PADDING, cv2.BORDER_CONSTANT, value=0)
        # Find tight box again after pad
        ys, xs = np.where(crop > 0)
        if len(xs) == 0 or len(ys) == 0:
            return None
        x0, x1 = xs.min(), xs.max()
        y0, y1 = ys.min(), ys.max()
        crop = crop[y0:y1+1, x0:x1+1]
        # Resize to 20x20 then center in 28x28 (like MNIST preprocessing)
        h, w = crop.shape
        if h > w:
            new_h = 20
            new_w = int(w * (20.0 / h))
        else:
            new_w = 20
            new_h = int(h * (20.0 / w))
        if new_h <= 0: new_h = 1
        if new_w <= 0: new_w = 1
        resized = cv2.resize(crop, (new_w, new_h), interpolation=cv2.INTER_AREA)
        canvas = np.zeros((28, 28), dtype=np.uint8)
        y_off = (28 - new_h) // 2
        x_off = (28 - new_w) // 2
        canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
        # Normalize for model: MNIST is black background (0) with white strokes (1)
        img = canvas.astype("float32") / 255.0
        img = img[..., None]  # (28,28,1)
        pred = self.model.predict(img[None, ...], verbose=0)[0]
        cls = int(np.argmax(pred))
        conf = float(np.max(pred))
        # Optional confidence filtering
        if conf < 0.40:
            return None
        return cls
    def classify_operator(self, crop_bin: np.ndarray, w: int, h: int, med_h: float) -> str | None:
        """
        Very lightweight heuristics:
        - '-' : wide, short, one thick horizontal stroke (width/height large, height << median digit height)
        - '+' : strong central vertical and horizontal projections (peaks)
        """
        # Work on binary with 1s where stroke is present
        b = (crop_bin > 0).astype(np.uint8)
        # Aspect ratio heuristic for '-'
        if h > 0:
            ar = w / float(h)
        else:
            ar = 0
        # height relative to median digit height
        h_frac = h / float(med_h) if med_h > 0 else 1.0
        # Horizontal projection profile (sum along columns) and vertical profile (sum along rows)
        vproj = b.sum(axis=0)  # per column
        hproj = b.sum(axis=1)  # per row
        v_center_peak = vproj[len(vproj)//2] / (b.shape[0] + 1e-6)
        h_center_peak = hproj[len(hproj)//2] / (b.shape[1] + 1e-6)
        # Minus: flat, wide, short
        if ar >= MINUS_AR_THRESH and h_frac <= MINUS_HEIGHT_FRAC:
            return "-"
        # Plus: vertical & horizontal strong central strokes
        if v_center_peak >= PLUS_PEAK_FRAC and h_center_peak >= PLUS_PEAK_FRAC:
            return "+"
        return None
    def tokens_to_expression(self, tokens: list[str]) -> str:
        """
        Combine tokens into a valid expression.
        - Collapse consecutive digits into multi-digit numbers.
        - Keep '+' and '-' as operators.
        - Remove illegal leading/trailing operators.
        """
        # Collapse digits
        out = []
        num_buf = []
        for t in tokens:
            if t.isdigit():
                num_buf.append(t)
            else:
                # flush number
                if num_buf:
                    out.append("".join(num_buf))
                    num_buf = []
                # operator allowed only if last is number
                if len(out) > 0 and out[-1][-1].isdigit() and t in {"+", "-"}:
                    out.append(t)
        # flush at end
        if num_buf:
            out.append("".join(num_buf))
        # Join safely
        expr = ""
        for item in out:
            if item in {"+", "-"}:
                expr += f" {item} "
            else:
                expr += item
        return expr.strip()
if __name__ == "__main__":
    root = tk.Tk()
    app = MathSolverApp(root)
    root.mainloop()


AI Resume Ranker

import os

import re

import argparse

import glob

from typing import List, Tuple, Dict, Optional


import pandas as pd

import numpy as np


from sklearn.feature_extraction.text import TfidfVectorizer, ENGLISH_STOP_WORDS

from sklearn.metrics.pairwise import cosine_similarity


# Optional parsers

import docx2txt

import PyPDF2


# Optional NLP

try:

    import spacy

    SPACY_OK = True

except Exception:

    SPACY_OK = False



# --------------------------- File Readers ---------------------------


def read_txt(path: str) -> str:

    with open(path, "r", encoding="utf-8", errors="ignore") as f:

        return f.read()


def read_docx(path: str) -> str:

    try:

        return docx2txt.process(path) or ""

    except Exception:

        return ""


def read_pdf(path: str) -> str:

    text = []

    try:

        with open(path, "rb") as f:

            reader = PyPDF2.PdfReader(f)

            for page in reader.pages:

                t = page.extract_text() or ""

                text.append(t)

    except Exception:

        pass

    return "\n".join(text)


def load_text_any(path: str) -> str:

    ext = os.path.splitext(path)[1].lower()

    if ext == ".txt":

        return read_txt(path)

    elif ext == ".docx":

        return read_docx(path)

    elif ext == ".pdf":

        return read_pdf(path)

    else:

        return ""



# --------------------------- Skills ---------------------------


DEFAULT_SKILLS = [

    # Generic

    "python","java","c++","javascript","typescript","sql","nosql","git","docker","kubernetes","linux",

    "aws","azure","gcp","bash","shell","rest","graphql","microservices",

    # Data/AI

    "pandas","numpy","scikit-learn","sklearn","tensorflow","pytorch","keras","nltk","spacy",

    "spark","hadoop","airflow","dbt","powerbi","tableau","matplotlib","seaborn",

    # Web/Backend

    "django","flask","fastapi","spring","node","express","react","angular","vue",

    # DevOps/Cloud

    "terraform","ansible","jenkins","ci/cd","prometheus","grafana","elk","rabbitmq","kafka",

    # Testing

    "pytest","unittest","selenium","cypress",

    # Security & Other

    "oauth","jwt","scrum","agile","jira"

]


def load_skills_file(path: Optional[str]) -> List[str]:

    if not path:

        return DEFAULT_SKILLS

    skills = []

    with open(path, "r", encoding="utf-8", errors="ignore") as f:

        for line in f:

            s = line.strip().lower()

            if s:

                skills.append(s)

    return sorted(set(skills))



# --------------------------- NLP Cleaning ---------------------------


def build_spacy_pipeline(use_spacy: bool):

    if use_spacy and SPACY_OK:

        try:

            nlp = spacy.load("en_core_web_sm", disable=["ner","parser","textcat"])

            return nlp

        except Exception:

            return None

    return None


CLEAN_RE = re.compile(r"[^a-z0-9+#./\- ]+")


def normalize_text(text: str) -> str:

    text = text.lower()

    text = text.replace("\n", " ").replace("\t", " ")

    text = CLEAN_RE.sub(" ", text)

    text = re.sub(r"\s+", " ", text).strip()

    return text


def lemmatize_spacy(nlp, text: str) -> str:

    if not nlp:

        return text

    doc = nlp(text)

    return " ".join(tok.lemma_ for tok in doc if not tok.is_space)



# --------------------------- Feature Engineering ---------------------------


def skill_overlap_score(text: str, jd_skills: List[str]) -> float:

    """

    Compute a skill overlap score (0..1) = Jaccard-like:

    |skills_in_resume ∩ skills_in_jd| / |skills_in_jd|

    """

    text_tokens = set(re.findall(r"[a-z0-9+#.\-]+", text.lower()))

    resume_skills = set()

    for skill in jd_skills:

        tokens = skill.split()

        if len(tokens) == 1:

            if skill in text_tokens:

                resume_skills.add(skill)

        else:

            if skill in text:

                resume_skills.add(skill)

    if not jd_skills:

        return 0.0

    return len(resume_skills) / float(len(set(jd_skills)))



# --------------------------- Ranking ---------------------------


def rank_resumes(

    jd_text: str,

    resume_texts: Dict[str, str],

    use_spacy: bool = False,

    weights: Tuple[float, float] = (0.7, 0.3),

    custom_skills: Optional[List[str]] = None

) -> pd.DataFrame:


    w_sem, w_skill = weights

    assert abs((w_sem + w_skill) - 1.0) < 1e-6, "weights must sum to 1"


    # Prepare spaCy if requested

    nlp = build_spacy_pipeline(use_spacy)


    # Normalize & (optionally) lemmatize

    jd_clean = normalize_text(jd_text)

    if nlp:

        jd_clean = lemmatize_spacy(nlp, jd_clean)


    cleaned = {}

    for fname, txt in resume_texts.items():

        t = normalize_text(txt)

        if nlp:

            t = lemmatize_spacy(nlp, t)

        cleaned[fname] = t


    # TF-IDF across JD + Resumes

    vectorizer = TfidfVectorizer(stop_words="english", max_features=40000, ngram_range=(1,2))

    corpus = [jd_clean] + [cleaned[f] for f in cleaned]

    tfidf = vectorizer.fit_transform(corpus)


    # Cosine similarity of resumes against JD (index 0)

    sims = cosine_similarity(tfidf[0:1], tfidf[1:]).flatten()


    # Skills from JD text (intersect default skills + those present in JD)

    base_skills = custom_skills if custom_skills is not None else DEFAULT_SKILLS

    jd_skill_candidates = [s for s in base_skills if s in jd_clean]

    # Fallback: if no skills found in JD, keep base set (less strict)

    jd_skillset = jd_skill_candidates if jd_skill_candidates else base_skills


    # Skill overlap score per resume

    files = list(cleaned.keys())

    skill_scores = []

    for f in files:

        s = skill_overlap_score(cleaned[f], jd_skillset)

        skill_scores.append(s)


    # Final score

    final = w_sem * sims + w_skill * np.array(skill_scores)


    df = pd.DataFrame({

        "resume_file": files,

        "semantic_similarity": np.round(sims, 4),

        "skill_overlap": np.round(skill_scores, 4),

        "final_score": np.round(final, 4)

    }).sort_values("final_score", ascending=False).reset_index(drop=True)


    return df



# --------------------------- CLI ---------------------------


def main():

    parser = argparse.ArgumentParser(description="AI Resume Ranker — rank resumes against a job description.")

    parser.add_argument("--jd", required=True, help="Job description file (.txt/.pdf/.docx)")

    parser.add_argument("--resumes", required=True, help="Folder containing resumes (.txt/.pdf/.docx)")

    parser.add_argument("--export", default="ranked_resumes.csv", help="Path to export CSV results")

    parser.add_argument("--skills", default=None, help="Optional skills file (one skill per line)")

    parser.add_argument("--use-spacy", action="store_true", help="Enable spaCy lemmatization (install en_core_web_sm)")

    parser.add_argument("--weights", nargs=2, type=float, default=[0.7, 0.3],

                        help="Weights for [semantic_similarity skill_overlap], must sum to 1.0 (default 0.7 0.3)")

    args = parser.parse_args()


    # Load JD

    jd_text = load_text_any(args.jd)

    if not jd_text.strip():

        raise SystemExit(f"Could not read job description: {args.jd}")


    # Load resumes

    patterns = ["*.pdf", "*.docx", "*.txt"]

    files = []

    for p in patterns:

        files.extend(glob.glob(os.path.join(args.resumes, p)))

    if not files:

        raise SystemExit(f"No resumes found in: {args.resumes}")


    resume_texts = {}

    for f in files:

        txt = load_text_any(f)

        if txt.strip():

            resume_texts[os.path.basename(f)] = txt


    # Load skills (optional)

    custom_skills = load_skills_file(args.skills) if args.skills else None


    # Rank

    df = rank_resumes(

        jd_text=jd_text,

        resume_texts=resume_texts,

        use_spacy=args.use_spacy,

        weights=(args.weights[0], args.weights[1]),

        custom_skills=custom_skills

    )


    # Save

    df.to_csv(args.export, index=False)

    print("\nTop matches:")

    print(df.head(10).to_string(index=False))

    print(f"\nSaved results to: {args.export}")



if __name__ == "__main__":

    main()


Interactive Periodic Table

import json

import os

import tkinter as tk

from tkinter import ttk, messagebox

import webbrowser

from functools import partial

import threading


import matplotlib.pyplot as plt

from mpl_toolkits.mplot3d import Axes3D  # noqa: F401 unused import (needed by matplotlib 3D)

import numpy as np


# ----------------- Config -----------------

DATA_FILE = "elements.json"

GRID_COLUMNS = 18

GRID_ROWS = 9  # we'll use rows 1-7 + an extra for Lanthanides/Actinides placeholder


# ----------------- Default JSON Creator -----------------

DEFAULT_ELEMENTS = {

    # symbol: data

    "H": {"name": "Hydrogen", "atomic_number": 1, "atomic_mass": 1.008, "group": 1, "period": 1,

          "category": "Nonmetal", "uses": ["Fuel", "Chemical feedstock"], "electron_configuration": "1s1"},

    "He": {"name": "Helium", "atomic_number": 2, "atomic_mass": 4.0026, "group": 18, "period": 1,

           "category": "Noble Gas", "uses": ["Balloons", "Cryogenics"], "electron_configuration": "1s2"},

    "Li": {"name": "Lithium", "atomic_number": 3, "atomic_mass": 6.94, "group": 1, "period": 2,

           "category": "Alkali Metal", "uses": ["Batteries"], "electron_configuration": "[He] 2s1"},

    "Be": {"name": "Beryllium", "atomic_number": 4, "atomic_mass": 9.0122, "group": 2, "period": 2,

           "category": "Alkaline Earth Metal", "uses": ["Aerospace alloys"], "electron_configuration": "[He] 2s2"},

    "B": {"name": "Boron", "atomic_number": 5, "atomic_mass": 10.81, "group": 13, "period": 2,

          "category": "Metalloid", "uses": ["Glass", "Detergents"], "electron_configuration": "[He] 2s2 2p1"},

    "C": {"name": "Carbon", "atomic_number": 6, "atomic_mass": 12.011, "group": 14, "period": 2,

          "category": "Nonmetal", "uses": ["Organic chemistry", "Materials"], "electron_configuration": "[He] 2s2 2p2"},

    "N": {"name": "Nitrogen", "atomic_number": 7, "atomic_mass": 14.007, "group": 15, "period": 2,

          "category": "Nonmetal", "uses": ["Fertilizers", "Industrial gas"], "electron_configuration": "[He] 2s2 2p3"},

    "O": {"name": "Oxygen", "atomic_number": 8, "atomic_mass": 15.999, "group": 16, "period": 2,

          "category": "Nonmetal", "uses": ["Respiration", "Combustion"], "electron_configuration": "[He] 2s2 2p4"},

    "F": {"name": "Fluorine", "atomic_number": 9, "atomic_mass": 18.998, "group": 17, "period": 2,

          "category": "Halogen", "uses": ["Toothpaste (fluoride)"], "electron_configuration": "[He] 2s2 2p5"},

    "Ne": {"name": "Neon", "atomic_number": 10, "atomic_mass": 20.1797, "group": 18, "period": 2,

           "category": "Noble Gas", "uses": ["Neon signs"], "electron_configuration": "[He] 2s2 2p6"},

    "Na": {"name": "Sodium", "atomic_number": 11, "atomic_mass": 22.989, "group": 1, "period": 3,

           "category": "Alkali Metal", "uses": ["Salt", "Chemicals"], "electron_configuration": "[Ne] 3s1"},

    "Mg": {"name": "Magnesium", "atomic_number": 12, "atomic_mass": 24.305, "group": 2, "period": 3,

           "category": "Alkaline Earth Metal", "uses": ["Alloys", "Electronics"], "electron_configuration": "[Ne] 3s2"},

    "Al": {"name": "Aluminium", "atomic_number": 13, "atomic_mass": 26.9815, "group": 13, "period": 3,

           "category": "Post-transition Metal", "uses": ["Packaging", "Aerospace"], "electron_configuration": "[Ne] 3s2 3p1"},

    "Si": {"name": "Silicon", "atomic_number": 14, "atomic_mass": 28.085, "group": 14, "period": 3,

           "category": "Metalloid", "uses": ["Semiconductors"], "electron_configuration": "[Ne] 3s2 3p2"},

    "P": {"name": "Phosphorus", "atomic_number": 15, "atomic_mass": 30.9738, "group": 15, "period": 3,

          "category": "Nonmetal", "uses": ["Fertilizers"], "electron_configuration": "[Ne] 3s2 3p3"},

    "S": {"name": "Sulfur", "atomic_number": 16, "atomic_mass": 32.06, "group": 16, "period": 3,

          "category": "Nonmetal", "uses": ["Sulfuric acid", "Fertilizers"], "electron_configuration": "[Ne] 3s2 3p4"},

    "Cl": {"name": "Chlorine", "atomic_number": 17, "atomic_mass": 35.45, "group": 17, "period": 3,

           "category": "Halogen", "uses": ["Water purification"], "electron_configuration": "[Ne] 3s2 3p5"},

    "Ar": {"name": "Argon", "atomic_number": 18, "atomic_mass": 39.948, "group": 18, "period": 3,

           "category": "Noble Gas", "uses": ["Lighting", "Welding"], "electron_configuration": "[Ne] 3s2 3p6"},

    "K": {"name": "Potassium", "atomic_number": 19, "atomic_mass": 39.0983, "group": 1, "period": 4,

          "category": "Alkali Metal", "uses": ["Fertilizers"], "electron_configuration": "[Ar] 4s1"},

    "Ca": {"name": "Calcium", "atomic_number": 20, "atomic_mass": 40.078, "group": 2, "period": 4,

           "category": "Alkaline Earth Metal", "uses": ["Bones", "Construction"], "electron_configuration": "[Ar] 4s2"}

}


def ensure_data_file():

    if not os.path.exists(DATA_FILE):

        with open(DATA_FILE, "w", encoding="utf-8") as f:

            json.dump(DEFAULT_ELEMENTS, f, indent=2)

        print(f"Created default {DATA_FILE} with first 20 elements. You can edit/expand it.")


# ----------------- Utility: Load Data -----------------

def load_elements():

    ensure_data_file()

    with open(DATA_FILE, "r", encoding="utf-8") as f:

        data = json.load(f)

    # Normalize: attach group/period if missing

    elements = {}

    for sym, info in data.items():

        elements[sym] = info

    return elements


# ----------------- Orbital Visualizer -----------------

def show_orbital_visual(element_data):

    """

    Very simple 3D shells visualization.

    Each shell (n) is drawn as a translucent sphere surface of radius proportional to n.

    Electron counts per shell approximated from electron_configuration (simple parse).

    """

    # parse approximate shells from electron_configuration string by counting numbers in brackets or 's','p','d'

    cfg = element_data.get("electron_configuration", "")

    # crude parsing to get principal quantum numbers occurrences like 1s2 2s2 2p6 etc.

    shells = []

    import re

    matches = re.findall(r'(\d)(?:[spdf])\d*', cfg)

    if matches:

        # get unique shell numbers and sort

        shells = sorted(set(int(m) for m in matches))

    else:

        # fallback: estimate shells from atomic number

        an = element_data.get("atomic_number", 1)

        # very rough heuristic: shell count = ceil(sqrt(Z)/2) — just for visualization

        shells = list(range(1, int(max(1, round((an**0.5)/2))) + 2))


    fig = plt.figure(figsize=(6,6))

    ax = fig.add_subplot(111, projection='3d')

    ax.set_facecolor("white")

    ax._axis3don = False


    # Draw concentric sphere surfaces for shells

    for n in shells:

        # create sphere

        u = np.linspace(0, 2 * np.pi, 60)

        v = np.linspace(0, np.pi, 30)

        r = 0.6 * n  # radius scale

        x = r * np.outer(np.cos(u), np.sin(v))

        y = r * np.outer(np.sin(u), np.sin(v))

        z = r * np.outer(np.ones_like(u), np.cos(v))

        ax.plot_surface(x, y, z, color=plt.cm.viridis(n/ max(1, len(shells))), alpha=0.15, linewidth=0)

        # put some electron points randomly distributed on shell

        np.random.seed(n*10)

        m = min(12, 4 + n*4)

        theta = np.random.uniform(0, 2*np.pi, m)

        phi = np.random.uniform(0, np.pi, m)

        ex = r * np.sin(phi) * np.cos(theta)

        ey = r * np.sin(phi) * np.sin(theta)

        ez = r * np.cos(phi)

        ax.scatter(ex, ey, ez, s=30, color=plt.cm.viridis(n / max(1, len(shells))))


    ax.set_title(f"Shells for {element_data.get('name', '')} ({element_data.get('symbol','')})", pad=20)

    max_r = 0.6 * (max(shells) + 1)

    ax.set_xlim(-max_r, max_r)

    ax.set_ylim(-max_r, max_r)

    ax.set_zlim(-max_r, max_r)

    plt.show()


# ----------------- GUI -----------------

class PeriodicTableApp:

    def __init__(self, root):

        self.root = root

        self.root.title("Interactive Periodic Table")

        self.elements = load_elements()  # key: symbol

        # create a mapping by atomic_number for quick lookup

        self.by_atomic = {info["atomic_number"]: sym for sym, info in self.elements.items() if "atomic_number" in info}

        self.buttons = {}  # symbol -> button

        self.build_ui()


    def build_ui(self):

        top = tk.Frame(self.root)

        top.pack(padx=8, pady=8, anchor="w")


        tk.Label(top, text="Search (symbol / name / atomic #):").pack(side="left")

        self.search_var = tk.StringVar()

        search_entry = tk.Entry(top, textvariable=self.search_var)

        search_entry.pack(side="left", padx=4)

        search_entry.bind("<Return>", lambda e: self.do_search())

        tk.Button(top, text="Search", command=self.do_search).pack(side="left", padx=4)

        tk.Button(top, text="Open elements.json", command=self.open_data_file).pack(side="left", padx=6)


        # Table frame

        table_frame = tk.Frame(self.root)

        table_frame.pack(padx=8, pady=8)


        # create empty grid

        for r in range(1, 8):  # periods 1..7

            for c in range(1, GRID_COLUMNS + 1):

                placeholder = tk.Frame(table_frame, width=46, height=36, bd=0)

                placeholder.grid(row=r, column=c, padx=1, pady=1)


        # Place element buttons based on group/period columns

        for sym, info in self.elements.items():

            group = info.get("group")

            period = info.get("period")

            if group is None or period is None:

                # skip elements without position (lanthanides/actinides not positioned)

                continue

            r = period

            c = group

            btn = tk.Button(table_frame, text=f"{sym}\n{info.get('atomic_number','')}",

                            width=6, height=3, command=partial(self.show_element_popup, sym))

            btn.grid(row=r, column=c, padx=1, pady=1)

            self.buttons[sym] = btn


        # Add legend / info area

        info_frame = tk.Frame(self.root)

        info_frame.pack(fill="x", padx=8, pady=6)

        tk.Label(info_frame, text="Click an element to view details and 3D shell visualization.", fg="gray").pack(side="left")


    def open_data_file(self):

        path = os.path.abspath(DATA_FILE)

        if os.path.exists(path):

            webbrowser.open(f"file://{path}")

        else:

            messagebox.showinfo("Info", f"{DATA_FILE} not found.")


    def do_search(self):

        q = self.search_var.get().strip().lower()

        if not q:

            return

        # search by symbol

        sym_match = None

        for sym, info in self.elements.items():

            if sym.lower() == q:

                sym_match = sym

                break

        if sym_match:

            self.flash_button(sym_match)

            self.show_element_popup(sym_match)

            return

        # search by name

        for sym, info in self.elements.items():

            if info.get("name", "").lower() == q:

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        # search by atomic number

        if q.isdigit():

            an = int(q)

            sym = self.by_atomic.get(an)

            if sym:

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        # partial name match

        for sym, info in self.elements.items():

            if q in info.get("name", "").lower():

                self.flash_button(sym)

                self.show_element_popup(sym)

                return

        messagebox.showinfo("Search", "No matching element found.")


    def flash_button(self, sym):

        btn = self.buttons.get(sym)

        if not btn:

            return

        orig = btn.cget("bg")

        def _flash():

            for _ in range(4):

                btn.config(bg="yellow")

                btn.update()

                self.root.after(200)

                btn.config(bg=orig)

                btn.update()

                self.root.after(200)

        threading.Thread(target=_flash, daemon=True).start()


    def show_element_popup(self, sym):

        info = self.elements.get(sym)

        if not info:

            messagebox.showerror("Error", "Element data not found.")

            return

        popup = tk.Toplevel(self.root)

        popup.title(f"{sym} - {info.get('name','')}")

        popup.geometry("420x320")


        left = tk.Frame(popup)

        left.pack(side="left", fill="both", expand=True, padx=8, pady=8)


        tk.Label(left, text=f"{info.get('name','')} ({sym})", font=("Arial", 16, "bold")).pack(anchor="w")

        tk.Label(left, text=f"Atomic Number: {info.get('atomic_number','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Atomic Mass: {info.get('atomic_mass','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Category: {info.get('category','')}", font=("Arial", 12)).pack(anchor="w")

        tk.Label(left, text=f"Electron Configuration: {info.get('electron_configuration','')}", font=("Arial", 11)).pack(anchor="w", pady=6)

        uses = info.get("uses", [])

        tk.Label(left, text="Uses / Applications:", font=("Arial", 12, "underline")).pack(anchor="w", pady=(6,0))

        uses_text = tk.Text(left, height=6, wrap="word")

        uses_text.pack(fill="both", expand=True)

        uses_text.insert("1.0", "\n".join(uses) if isinstance(uses, list) else str(uses))

        uses_text.config(state="disabled")


        # right side controls

        right = tk.Frame(popup)

        right.pack(side="right", fill="y", padx=6, pady=6)

        tk.Button(right, text="Visualize Shells (3D)", command=lambda: threading.Thread(target=show_orbital_visual, args=(info,), daemon=True).start()).pack(pady=6)

        tk.Button(right, text="Close", command=popup.destroy).pack(pady=6)


# ----------------- Run App -----------------

def main():

    root = tk.Tk()

    app = PeriodicTableApp(root)

    root.mainloop()


if __name__ == "__main__":

    main()