[
  {
    "path": ".gitignore",
    "content": "# Python\n__pycache__/\n*.py[cod]\n*$py.class\n\n# Virtual environment\nvenv/\n*.venv/\nenv/\nENV/\n.env\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# IDE / Editor specific\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# MyPy cache\n.mypy_cache/\n\n# Pytest cache\n.pytest_cache/\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Terminal Rain\n\nA Python script that creates a mesmerizing rain and lightning animation directly in your terminal using the `curses` library.\n\n## Calm Rain\n![Calm Rain](calmrain.gif)\n\n## Thunderstorm\n![Thunderstorm](thunderstorm.gif)\n\n## Disclaimer\n\nI'm a hobby coder and write most of my scripts with Cursor's help, apologies if anything is broken or especially wonky in the source code.\n\nI'm relatively new to Linux and wanted to make something like this for fun after seeing some of the other projects like bash-pipes, asciiquarium, etc.\n\n## Features\n\n*   Smooth ASCII rain effect with varying drop characters.\n*   Toggleable \"Thunderstorm\" mode for more intense rain and lightning.\n*   Customizable rain and lightning colors via command-line arguments.\n*   Responsive to terminal resizing (clears and redraws).\n*   Lightweight and runs in most modern terminals.\n\n## Requirements\n\n*   Python 3.6+\n*   A terminal that supports `curses` and color attributes (most modern terminals)\n\n## Installation\n\nThe recommended way to install `terminal-rain-lightning` is using `pipx`. This will make the `terminal-rain` command available globally while keeping its dependencies isolated.\n\n### Using `pipx` (Recommended)\n\n`pipx` installs Python command-line applications into isolated environments and makes them globally available without polluting your global Python installation or requiring manual virtual environment activation to run.\n\n1. **Install `pipx`** (if you haven't already):\n\nThe best way to install `pipx` on Linux is through your distribution's package manager, if available. This ensures proper system integration and updates.\n\nCommon distro installs pulled from the [pipx repo](https://github.com/pypa/pipx):\n\n- Ubuntu 23.04 or above\n\n```\nsudo apt update\nsudo apt install pipx\npipx ensurepath\nsudo pipx ensurepath --global # optional to allow pipx actions with --global argument\n```\n\n- Fedora:\n\n```\nsudo dnf install pipx\npipx ensurepath\nsudo pipx ensurepath --global # optional to allow pipx actions with --global argument\n```\n\n- Arch:\n\n```\nsudo pacman -S python-pipx\npipx ensurepath\nsudo pipx ensurepath --global # optional to allow pipx actions with --global argument\n```\n\n- Using `pip` on other distributions:\n\n```\npython3 -m pip install --user pipx\npython3 -m pipx ensurepath\nsudo pipx ensurepath --global # optional to allow pipx actions with --global argument\n```\n\n2. **Install `terminal-rain-lightning`:**\n\n- From GitHub (directly):\n\n```bash\npipx install git+https://github.com/rmaake1/terminal-rain-lightning.git\n```\n\n- From a local clone:\n\n```bash\ngit clone https://github.com/rmaake1/terminal-rain-lightning.git\ncd terminal-rain-lightning\npipx install .\n```\n\n## Usage\n\nOnce installed:\n\n*   If you used `pipx` simply type:\n\n```bash\nterminal-rain\n```\n\n### Controls\n\n*   `t` or `T`: Toggle thunderstorm mode on/off.\n*   `q` or `Q` or `Esc`: Quit the animation.\n*   `Ctrl+C`: Also quits the animation.\n*   The animation will adapt if you resize your terminal window.\n\n### Command-line Options\n\nCustomize the appearance of the animation:\n\n```bash\nterminal-rain [OPTIONS]\n```\n\n## Options:\n* --rain-color COLOR: Set the color for the rain. Default: cyan.\n* --lightning-color COLOR: Set the color for the lightning. Default: yellow.\n* --help: Show this help message and exit.\n* Available COLOR choices: black, red, green, yellow, blue, magenta, cyan, white.\n\nExample:\n\n```bash\nterminal-rain --rain-color blue --lightning-color white\n```\n\n## Troubleshooting\n\n\"curses.error: ...\" / Garbled Output / Colors Not Working:\n\n* Ensure your terminal emulator fully supports curses, 256 colors, and attributes like bold/dim. Modern terminals like Alacritty or Kitty generally work well.\n* Check your TERM environment variable (e.g., echo $TERM). Values like xterm-256color are good.\n* The script attempts to use default terminal colors if color changing isn't supported, but full support provides the best experience.\n\n## License\n\nDistributed under the MIT License. See LICENSE file for more information. Do whatever you want with this script.\n\n## Acknowledgements\n\nInspired by classic terminal screensavers and effects, asciiquarium, bash-pipes, etc.\n\nBuilt with Python and the curses library.\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=42\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n# backend-path = \".\" # Optional, default is \".\" - indicates setup.cfg/setup.py is in the same dir if needed\n\n[project]\nname = \"terminal-rain-lightning\"\nversion = \"0.1.0\" # Start with an initial version\nauthors = [\n    { name = \"ryan\", email = \"hello@rmaake.com\" }, # UPDATE THIS\n]\ndescription = \"A terminal rain and lightning animation using Python and curses.\"\nreadme = \"README.md\" # We'll create this next\nlicense = { file = \"LICENSE\" }\nrequires-python = \">=3.6\"\nclassifiers = [\n    \"Development Status :: 4 - Beta\",\n    \"Environment :: Console :: Curses\",\n    \"Intended Audience :: End Users/Desktop\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.6\",\n    \"Programming Language :: Python :: 3.7\",\n    \"Programming Language :: Python :: 3.8\",\n    \"Programming Language :: Python :: 3.9\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Topic :: Artistic Software\",\n    \"Topic :: Games/Entertainment :: Simulation\",\n    \"Topic :: Terminals\",\n    \"Operating System :: POSIX\",\n    \"Operating System :: MacOS\",\n]\n\n[project.urls]\n\"Homepage\" = \"https://github.com/rmaake1/terminal-rain-lightning\" # UPDATE THIS\n\"Bug Tracker\" = \"https://github.com/rmaake1/terminal-rain-lightning/issues\" # UPDATE THIS\n\n# This section makes your script runnable from the command line after installation\n[project.scripts]\nterminal-rain = \"terminal_rain_lightning:main\"\n"
  },
  {
    "path": "terminal_rain_lightning.py",
    "content": "#!/usr/bin/env python3\n\nimport curses\nimport time\nimport random\nimport os\nimport argparse # Added for command-line arguments\n\nUPDATE_INTERVAL = 0.015 # Speed up slightly again w/o clouds/complex bolts\n\n# --- Rain Configuration ---\nRAIN_CHARS = ['|', '.', '`'] # Characters for raindrops\nCOLOR_PAIR_RAIN_NORMAL = 1\nCOLOR_PAIR_LIGHTNING = 4\n\n# Defined curses color names (lowercase) for argument parsing\nCURSES_COLOR_MAP = {\n    'black': curses.COLOR_BLACK,\n    'red': curses.COLOR_RED,\n    'green': curses.COLOR_GREEN,\n    'yellow': curses.COLOR_YELLOW,\n    'blue': curses.COLOR_BLUE,\n    'magenta': curses.COLOR_MAGENTA,\n    'cyan': curses.COLOR_CYAN,\n    'white': curses.COLOR_WHITE,\n}\n\n\nclass Raindrop:\n    def __init__(self, x, y, speed, char):\n        self.x = x\n        self.y = y\n        self.speed = speed # How many steps to fall per update\n        self.char = char\n        # self.state = \"falling\" # Removed state\n        # self.splash_timer = 0 # Removed timer\n\n# --- Cloud Configuration --- # Removed\n# ...\n\n# --- Lightning ---\nLIGHTNING_COLOR_ATTR = None # Will be set in setup_colors\nLIGHTNING_CHANCE = 0.005 # Slightly higher chance\nLIGHTNING_CHARS = ['*', '+', '#'] # Different intensity characters [dimmest -> brightest]\nLIGHTNING_GROWTH_DELAY = 0.002 # Grow slightly faster\nLIGHTNING_MAX_BRANCHES = 2\nLIGHTNING_BRANCH_CHANCE = 0.3\n# LIGHTNING_FADE_DURATION = 30 # Removed - Bolt removed when all segments expired\n# LIGHTNING_TAIL_FADE_LENGTH = 15 # Removed - Fade based on segment lifespan\nFORK_CHANCE = 0.15 # Chance for a side fork to spawn during growth\nFORK_HORIZONTAL_SPREAD = 3 # Max horizontal distance a fork segment can jump\nSEGMENT_LIFESPAN = 0.8 # Seconds for a segment to fade from # to invisible\n\n\n# class Cloud: # Removed\n#     ...\n\n\nclass LightningBolt:\n    def __init__(self, start_row, start_col, max_y, max_x):\n        self.start_col = start_col\n        self.target_length = random.randint(max_y // 2, max_y - 2) # Random length\n        # Segments store: (y, x, creation_time)\n        self.segments = [(start_row, start_col, time.time())]\n        self.last_growth_time = time.time() # Renamed from last_segment_time\n        self.is_growing = True\n        # self.fade_timer = LIGHTNING_FADE_DURATION # Removed\n        self.max_y = max_y # Store for boundary checks\n        self.max_x = max_x # Store for boundary checks\n        # self.age_offset = 0 # Removed\n\n    def update(self):\n        \"\"\"Updates bolt growth and checks if it should be removed.\"\"\"\n        current_time = time.time()\n\n        # --- Growth ---\n        if self.is_growing and (current_time - self.last_growth_time >= LIGHTNING_GROWTH_DELAY):\n            self.last_growth_time = current_time\n            new_segments_this_step = [] # Store segments added *this* step\n            added_segment = False\n            last_y, last_x, _ = self.segments[-1] # Ignore creation_time for position\n\n            if len(self.segments) < self.target_length and last_y < self.max_y - 1 :\n                 branches = 1\n                 if random.random() < LIGHTNING_BRANCH_CHANCE:\n                     branches = random.randint(1, LIGHTNING_MAX_BRANCHES + 1)\n\n                 current_x = last_x\n                 next_primary_x = current_x # Track primary path for fork check\n                 for i in range(branches):\n                     offset = random.randint(-2, 2)\n                     next_x = max(0, min(self.max_x - 1, current_x + offset))\n                     # Allow reaching bottom row (max_y - 1)\n                     # next_y = last_y + 1\n                     next_y = min(self.max_y - 1, last_y + 1)\n                     # if next_y < self.max_y: # Check was redundant with min()\n                     # Add new segment with current time\n                     new_segments_this_step.append((next_y, next_x, current_time))\n                     if i == 0: next_primary_x = next_x # Store first path pos\n                     current_x = next_x\n                     added_segment = True\n\n                 # --- Add Secondary Forks --- #\n                 if random.random() < FORK_CHANCE:\n                     fork_offset = random.randint(-FORK_HORIZONTAL_SPREAD, FORK_HORIZONTAL_SPREAD)\n                     if fork_offset == 0: fork_offset = random.choice([-1, 1])\n                     fork_x = max(0, min(self.max_x - 1, last_x + fork_offset))\n                     # Allow fork reaching bottom row\n                     # fork_y = last_y + 1\n                     fork_y = min(self.max_y - 1, last_y + 1)\n                     # if fork_y < self.max_y and fork_x != next_primary_x:\n                     if fork_x != next_primary_x:\n                          # Add new fork segment with current time\n                          new_segments_this_step.append((fork_y, fork_x, current_time))\n                          added_segment = True\n\n            # Stop growing if no new segments were added or target length reached\n            # Also stop if we hit the bottom edge\n            if not added_segment or len(self.segments) >= self.target_length or last_y >= self.max_y -1:\n                self.is_growing = False\n\n            # Add the newly created segments to the main list\n            if new_segments_this_step:\n                 # Optional: Add only unique positions added this step?\n                 unique_new = list({(s[0], s[1]): s for s in new_segments_this_step}.values())\n                 self.segments.extend(unique_new)\n\n\n        # --- Check for Removal ---\n        # Bolt should be removed if all its segments have exceeded their lifespan\n        all_expired = True\n        if not self.segments: # Should not happen, but safe check\n            return False # Remove empty bolt\n\n        for _, _, creation_time in self.segments:\n            if (current_time - creation_time) <= SEGMENT_LIFESPAN:\n                all_expired = False\n                break\n        # Return False if all segments are expired (signal removal)\n        return not all_expired\n\n\n    def draw(self, stdscr):\n        \"\"\"Draws segments based on their individual age.\"\"\"\n        current_time = time.time()\n        max_char_index = len(LIGHTNING_CHARS) - 1\n\n        for y, x, creation_time in self.segments:\n            segment_age = current_time - creation_time\n            is_visible = True\n            char = ' ' # Default to invisible\n\n            if segment_age <= SEGMENT_LIFESPAN:\n                # Determine character based on age progress through lifespan\n                # Normalize age (0.0 = new, 1.0 = lifespan reached)\n                norm_age = segment_age / SEGMENT_LIFESPAN\n\n                # Map normalized age (0->1) to char index (max->0)\n                # Example mapping: 0-0.33 -> #, 0.33-0.66 -> +, 0.66-1.0 -> *\n                if norm_age < 0.33:\n                    char_index = 2 # '#'\n                elif norm_age < 0.66:\n                    char_index = 1 # '+'\n                else:\n                    char_index = 0 # '*'\n\n                # Ensure index is valid (should be by logic)\n                char_index = max(0, min(max_char_index, char_index))\n                char = LIGHTNING_CHARS[char_index]\n                is_visible = True\n            else:\n                is_visible = False # Segment is older than lifespan\n\n            if not is_visible:\n                continue\n\n            # Apply attributes (always bold for now)\n            attr = LIGHTNING_COLOR_ATTR\n\n            try:\n                max_r, max_c = stdscr.getmaxyx()\n                if y < max_r and x < max_c:\n                   stdscr.addstr(int(y), int(x), char, attr)\n            except curses.error:\n                pass\n\n\ndef setup_colors(rain_color_str='cyan', lightning_color_str='yellow'):\n    \"\"\"Initializes color pairs for the rain and lightning based on input strings.\"\"\"\n    global LIGHTNING_COLOR_ATTR\n    if curses.has_colors():\n        curses.start_color()\n        if curses.can_change_color():\n             curses.use_default_colors()\n             bg = -1\n        else:\n             bg = curses.COLOR_BLACK # Fallback background\n\n        # --- Get curses color constants from strings --- #\n        rain_fg = CURSES_COLOR_MAP.get(rain_color_str.lower(), curses.COLOR_CYAN)\n        lightning_fg = CURSES_COLOR_MAP.get(lightning_color_str.lower(), curses.COLOR_YELLOW)\n        # ------------------------------------------------ #\n\n        curses.init_pair(COLOR_PAIR_RAIN_NORMAL, rain_fg, bg)\n        # curses.init_pair(COLOR_PAIR_RAIN_SPLASH, curses.COLOR_BLUE, bg) # Removed\n        # curses.init_pair(COLOR_PAIR_CLOUD, curses.COLOR_WHITE, bg) # Removed\n        curses.init_pair(COLOR_PAIR_LIGHTNING, lightning_fg, bg)\n        LIGHTNING_COLOR_ATTR = curses.color_pair(COLOR_PAIR_LIGHTNING) | curses.A_BOLD\n\n        return True\n    else:\n        # --- Non-color fallback --- #\n        # We still need LIGHTNING_COLOR_ATTR for non-color bold\n        rain_fg = curses.COLOR_WHITE # Ignored, but keep variable\n        lightning_fg = curses.COLOR_WHITE # Ignored, but keep variable\n        bg = curses.COLOR_BLACK\n        # -------------------------- #\n\n        curses.init_pair(COLOR_PAIR_RAIN_NORMAL, curses.COLOR_WHITE, curses.COLOR_BLACK)\n        # curses.init_pair(COLOR_PAIR_RAIN_SPLASH, curses.COLOR_WHITE, curses.COLOR_BLACK) # Removed\n        # curses.init_pair(COLOR_PAIR_CLOUD, curses.COLOR_WHITE, curses.COLOR_BLACK) # Removed\n        curses.init_pair(COLOR_PAIR_LIGHTNING, curses.COLOR_WHITE, curses.COLOR_BLACK)\n        LIGHTNING_COLOR_ATTR = curses.color_pair(COLOR_PAIR_LIGHTNING) | curses.A_BOLD\n        return False\n\ndef simulate_rain(stdscr, rain_color_str='cyan', lightning_color_str='yellow'):\n    \"\"\"Main curses visualization loop for rain simulation.\"\"\"\n    curses.curs_set(0) # Hide cursor\n    stdscr.nodelay(True) # Non-blocking input\n    stdscr.timeout(1) # ms, minimal timeout\n\n    has_colors = setup_colors(rain_color_str, lightning_color_str)\n    raindrops = []\n    active_bolts = [] # List of active LightningBolt objects\n    rows, cols = stdscr.getmaxyx()\n    is_thunderstorm = False\n\n    last_update_time = time.time()\n\n    while True:\n        # --- Input --- #\n        key = stdscr.getch()\n        if key == curses.KEY_RESIZE:\n             rows, cols = stdscr.getmaxyx()\n             stdscr.clear()\n             raindrops.clear()\n             active_bolts.clear()\n        elif key == ord('q') or key == ord('Q') or key == 27:\n            break\n        elif key == ord('t') or key == ord('T'):\n            is_thunderstorm = not is_thunderstorm\n            stdscr.clear()\n\n        # --- Frame Rate Control --- #\n        current_time = time.time()\n        delta_time = current_time - last_update_time\n        if delta_time < UPDATE_INTERVAL:\n             # Reduce sleep time if many bolts exist? Maybe not necessary yet.\n             time.sleep(UPDATE_INTERVAL - delta_time)\n        last_update_time = time.time() # Use the time *after* sleep for age calcs\n\n        # --- Update --- #\n\n        # 1. Lightning Bolts\n        next_bolts = []\n        if is_thunderstorm and len(active_bolts) < 3 and random.random() < LIGHTNING_CHANCE:\n             start_col = random.randint(cols // 4, 3 * cols // 4)\n             start_row = random.randint(0, rows // 5)\n             active_bolts.append(LightningBolt(start_row, start_col, rows, cols))\n\n        for bolt in active_bolts:\n             if bolt.update(): # update now returns True if bolt should *keep* existing\n                 next_bolts.append(bolt)\n        active_bolts = next_bolts\n\n        # 2. Raindrops (generation and update)\n        generation_chance = 0.5 if is_thunderstorm else 0.3\n        max_new_drops = cols // 8 if is_thunderstorm else cols // 15\n        min_speed = 0.3 if is_thunderstorm else 0.3\n        max_speed = 1.0 if is_thunderstorm else 0.6\n\n        if random.random() < generation_chance:\n            num_new_drops = random.randint(1, max(1, max_new_drops))\n            for _ in range(num_new_drops):\n                 x = random.randint(0, cols - 1)\n                 y = 0 # Start at top row\n                 speed = random.uniform(min_speed, max_speed)\n                 char = random.choice(RAIN_CHARS)\n                 raindrops.append(Raindrop(x, y, speed, char))\n\n        next_raindrops = []\n        for drop in raindrops:\n            drop.y += drop.speed\n            # Let drop fall off the bottom edge\n            # if drop.y < rows - 1:\n            if int(drop.y) < rows:\n                next_raindrops.append(drop)\n        raindrops = next_raindrops\n\n        # --- Drawing --- #\n        stdscr.clear()\n\n        # 1. Lightning\n        for bolt in active_bolts:\n             bolt.draw(stdscr)\n\n        # 2. Raindrops\n        for drop in raindrops:\n             try:\n                 attr = curses.color_pair(COLOR_PAIR_RAIN_NORMAL)\n                 if is_thunderstorm:\n                     attr |= curses.A_BOLD\n                 elif drop.speed < 0.8:\n                     attr |= curses.A_DIM\n                 # Prevent drawing on bottom line\n                 # if int(drop.y) < rows -1:\n                 # Allow drawing on bottom line now\n                 if int(drop.y) < rows:\n                    stdscr.addstr(int(drop.y), drop.x, drop.char, attr)\n             except curses.error:\n                 pass\n                \n        stdscr.noutrefresh()\n        curses.doupdate()\n\n\ndef main():\n    if not os.isatty(1) or os.environ.get('TERM') == 'dumb':\n        print(\"Error: This script requires a TTY with curses support.\")\n        return\n\n    # --- Argument Parsing --- #\n    parser = argparse.ArgumentParser(description=\"Simulates rain and thunderstorms in the terminal.\")\n    valid_colors = list(CURSES_COLOR_MAP.keys())\n    parser.add_argument(\n        '--rain-color',\n        type=str,\n        default='cyan',\n        choices=valid_colors,\n        help=f\"Color for the rain. Default: cyan. Choices: {', '.join(valid_colors)}\"\n    )\n    parser.add_argument(\n        '--lightning-color',\n        type=str,\n        default='yellow',\n        choices=valid_colors,\n        help=f\"Color for the lightning. Default: yellow. Choices: {', '.join(valid_colors)}\"\n    )\n    args = parser.parse_args()\n    # ------------------------ #\n\n    try:\n        # Pass parsed colors to the main simulation function\n        curses.wrapper(simulate_rain, args.rain_color, args.lightning_color)\n    except curses.error as e:\n        try: curses.endwin()\n        except Exception: pass\n        print(f\"\\nA curses error occurred: {e}\")\n        print(\"Terminal might not fully support curses features (like color/attributes).\")\n        print(\"Try resizing the terminal or using a different terminal emulator.\")\n    except KeyboardInterrupt:\n        print(\"\\nExiting...\")\n    except Exception as e:\n        try: curses.endwin()\n        except Exception: pass\n        print(f\"\\nAn unexpected error occurred: {e}\")\n        import traceback\n        traceback.print_exc()\n\nif __name__ == \"__main__\":\n    main() "
  }
]