diff --git a/laudatory-larkspurs/.github/workflows/build.yaml b/laudatory-larkspurs/.github/workflows/build.yaml new file mode 100644 index 00000000..4e8ac56b --- /dev/null +++ b/laudatory-larkspurs/.github/workflows/build.yaml @@ -0,0 +1,84 @@ +name: Build project and publish + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + build: + name: Build the project + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Build project + run: python build.py + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build + path: build + if-no-files-found: error + + publish: + name: Publish build artifact + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure Git + run: | + BOT_NAME="github-actions[bot]" + BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + + - name: Prepare build branch + run: | + set -e + if git ls-remote --exit-code origin build; then + git fetch origin build:build + git checkout build + else + git checkout --orphan build + fi + find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: build + path: . + + - name: Commit build folder + run: | + git add . + git diff --cached --quiet && echo "No build changes to commit" || git commit -m "Deploying production from @ ${GITHUB_SHA::7} ๐Ÿš€" + git push origin build + + timeout-minutes: 15 diff --git a/laudatory-larkspurs/.github/workflows/deploy.yaml b/laudatory-larkspurs/.github/workflows/deploy.yaml new file mode 100644 index 00000000..68b3c3c8 --- /dev/null +++ b/laudatory-larkspurs/.github/workflows/deploy.yaml @@ -0,0 +1,42 @@ +name: Deploy to Vercel + +on: + workflow_run: + workflows: ["Build project and publish"] + branches: [main] + types: [completed] + workflow_dispatch: + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy production build + if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run && github.event.workflow_run.conclusion == 'success') }} + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: build + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + - name: Deploy to Vercel + run: vercel deploy --prod --yes --token=${{ secrets.VERCEL_TOKEN }} + + timeout-minutes: 15 diff --git a/laudatory-larkspurs/.github/workflows/lint.yaml b/laudatory-larkspurs/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/laudatory-larkspurs/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/laudatory-larkspurs/.gitignore b/laudatory-larkspurs/.gitignore new file mode 100644 index 00000000..da472080 --- /dev/null +++ b/laudatory-larkspurs/.gitignore @@ -0,0 +1,34 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store + +build/ +.vercel diff --git a/laudatory-larkspurs/.pre-commit-config.yaml b/laudatory-larkspurs/.pre-commit-config.yaml new file mode 100644 index 00000000..c0a8de23 --- /dev/null +++ b/laudatory-larkspurs/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-check + - id: ruff-format diff --git a/laudatory-larkspurs/CONTRIBUTING.md b/laudatory-larkspurs/CONTRIBUTING.md new file mode 100644 index 00000000..1c479e04 --- /dev/null +++ b/laudatory-larkspurs/CONTRIBUTING.md @@ -0,0 +1,141 @@ +# Contributing + +## Information + +This project uses [Pyodide](https://pyodide.org) to run Python directly in the browser using WebAssembly (WASM). +Almost no JavaScript is required โ€” the frontend is written entirely in Python and HTML/CSS. + +## Dev Code Checks + +Use `ruff check` to check your code style. and fix it + +```shell +ruff check . +ruff check . --fix +``` + +Use `pre-commit` to run linting before committing. `pre-commit install` to install. + +### Examples + +```shell +pre-commit run --show-diff-on-failure --all-files +pre-commit run ruff-check --all-files +pre-commit run check-toml --all-files +``` + +**Pre-commit hooks:** + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff-check`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## Our coding rules + +1. Comment your classes, functions, and non-obvious logic. +2. Use docstrings with author tags and short descriptions. + +```py +class ClassName: + '''Handle user input and validation. + + @author Mira + ''' + ... + +# Short function description +def do_something(): + '''Perform a single-step operation. + + :param paramname: does something + :return: None + + @author Mira + ''' + ... +``` + +## Naming Stuff Rules + +Use `ruff check` to check your code style. and fix it + +```shell +ruff check . +ruff check . --fix +``` + +**Functions**: lowercase and use underscores + +```py +def my_function(): + my_variable = "value" +``` + +**Classes and Variable names**: PascalCase style + +```py +from typing import List + +class MyClass: + pass + +ListOfMyClass = List[MyClass] +``` + +**Constants**: SCREAMING_SNAKE_CASE style + +```py +MY_CONSTANT = 1 +``` + +**Operators**: at the start of a newline + +```py +# No +result = ( + 1 + + 2 * + 3 +) +# Yes +result = ( + 1 + + 2 + * 3 +) +``` + +**equivalent to None**: use `is`, `is not` instead of `==` + +```py +if variable == None: # No + print("Variable is None") +if variable is None: # Yes + print("Variable is None")ยจ +``` + +**not** positioning: + +```py +if not variable is None: # No + print("Variable is not None") + +if variable is not None: # Yes, easier to read + print("Variable is not None") +``` + +**Imports**: do not import multiple modules on one line or everything from a module (\*) + +```py +# No +import pathlib, os +from pathlib import * + +# Yes +import os +import pathlib +from pathlib import Path +``` diff --git a/laudatory-larkspurs/Dockerfile b/laudatory-larkspurs/Dockerfile new file mode 100644 index 00000000..02c36cec --- /dev/null +++ b/laudatory-larkspurs/Dockerfile @@ -0,0 +1,33 @@ +############################# +# Single-stage Python image # +############################# +FROM python:3.12-slim + +LABEL org.opencontainers.image.title="good-image-terminal" \ + org.opencontainers.image.description="A simple yet interesting image editor." \ + org.opencontainers.image.licenses="MIT" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=8000 + +# Set the working directory +WORKDIR /app + +# Copy project files +COPY build.py pyproject.toml README.md uv.lock* ./ +RUN pip install --no-cache-dir . + +COPY public ./public +COPY src ./src + +EXPOSE 8000 + +CMD ["bash", "-c", "python build.py --serve --port ${PORT}"] + +##################################################################################################### +# Usage # +# docker build -t good-image-terminal:latest . # +# docker run --rm --name good-image-terminal -e PORT=8000 -p 8000:8000 good-image-terminal:latest # +# App: http://localhost:8000 # +##################################################################################################### diff --git a/laudatory-larkspurs/LICENSE b/laudatory-larkspurs/LICENSE new file mode 100644 index 00000000..3afb3122 --- /dev/null +++ b/laudatory-larkspurs/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 Mira, Jont, Julien, Philip, Richard Szilagyi, Mark + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/laudatory-larkspurs/README.md b/laudatory-larkspurs/README.md new file mode 100644 index 00000000..8cbbcfa9 --- /dev/null +++ b/laudatory-larkspurs/README.md @@ -0,0 +1,178 @@ +# Good Image Terminal + +

+Lint +Build +Deploy +Deploy +

+ +

+ Logo +

+ +

+ An image editor. In the terminal. In the browser. +

+ +This project is a web-based image editing tool that runs entirely in the browser through a terminal. It uses Pyodide to enable Python-based image processing without the need for a backend server. All while letting JavaScript sit back, relax, and just load the page. + +The tool allows users to upload images and apply edits through various commands, all within a user-friendly interface. + +The inherently visual task of image editing performed entirely through programmatic terminal commands makes Good Image Terminal the "Wrong tool for the job." Despite this dissonance, we've made GIT comfortable and responsive. + +## Project Structure + +```text +codejam-laudatory-larkspurs/ +โ”œโ”€ build.py # Build + serve script (Pyodide bundling) +โ”œโ”€ Dockerfile # Docker configuration +โ”œโ”€ pyproject.toml # Project & dependency metadata +โ”œโ”€ uv.lock # Locked dependency versions +โ”œโ”€ README.md / CONTRIBUTING.md +โ”œโ”€ LICENSE +โ”œโ”€ .pre-commit-config.yaml # Lint & format hooks +โ”œโ”€ .github/workflows/ +โ”‚ โ”œโ”€ build.yaml # CI build pipeline +โ”‚ โ”œโ”€ deploy.yaml # CI deployment pipeline +โ”‚ โ””โ”€ lint.yaml # CI lint pipeline +โ”œโ”€ docs/ # Documentation files +โ”œโ”€ public/ # Static assets +โ”‚ โ”œโ”€ index.html +โ”‚ โ”œโ”€ favicon.* / icons +โ”‚ โ”œโ”€ site.webmanifest +โ”‚ โ””โ”€ templates/ +โ”‚ โ””โ”€ app_template.html +โ””โ”€ src/ # Application source (runs in Pyodide) + โ”œโ”€ main.py # Entry point + โ”œโ”€ terminal.py # Terminal UI + โ”œโ”€ image.py # Image model + โ”œโ”€ commands/ # Individual terminal commands + โ”‚ โ”œโ”€ background.py + โ”‚ โ”œโ”€ base_command.py + โ”‚ โ”œโ”€ draw_circle.py + โ”‚ โ”œโ”€ draw_line.py + โ”‚ โ”œโ”€ draw_pixel.py + โ”‚ โ”œโ”€ draw_polygon.py + โ”‚ โ”œโ”€ draw_rectangle.py + โ”‚ โ”œโ”€ foreground.py + โ”‚ โ”œโ”€ help.py + โ”‚ โ”œโ”€ image_info.py + โ”‚ โ”œโ”€ load_image.py + โ”‚ โ”œโ”€ ls.py + โ”‚ โ”œโ”€ ping.py + โ”‚ โ”œโ”€ save_image.py + โ”‚ โ”œโ”€ terminal_background.py + โ”‚ โ”œโ”€ undo.py + โ”‚ โ””โ”€ __init__.py + โ”œโ”€ gui/ # Lightweight GUI abstraction + โ”‚ โ”œโ”€ element.py + โ”‚ โ”œโ”€ layout.py + โ”‚ โ”œโ”€ components/ + โ”‚ โ”‚ โ”œโ”€ description.py + โ”‚ โ”‚ โ”œโ”€ drag_drop_handler.py + โ”‚ โ”‚ โ”œโ”€ file_upload_handler.py + โ”‚ โ”‚ โ”œโ”€ image_display_manager.py + โ”‚ โ”‚ โ”œโ”€ image_preview.py + โ”‚ โ”‚ โ”œโ”€ separator.py + โ”‚ โ”‚ โ”œโ”€ terminal_gui.py + โ”‚ โ”‚ โ”œโ”€ terminal_input.py + โ”‚ โ”‚ โ”œโ”€ terminal_io.py + โ”‚ โ”‚ โ””โ”€ __init__.py + โ”‚ โ””โ”€ __init__.py + โ”œโ”€ images/ + โ”‚ โ””โ”€ default.png + โ””โ”€ utils/ + โ”œโ”€ color.py + โ””โ”€ __init__.py +``` + +## Setup + +1. First we set up our python enviroment + +```shell +python -m venv .venv +``` + +2. Entering it + +```shell +# Linux, Bash +$ source .venv/bin/activate +# Linux, Fish +$ source .venv/bin/activate.fish +# Linux, Csh +$ source .venv/bin/activate.csh +# Linux, PowerShell Core +$ .venv/bin/Activate.ps1 +# Windows, cmd.exe +> .venv\Scripts\activate.bat +# Windows, PowerShell +> .venv\Scripts\Activate.ps1 +``` + +3. Installing development dependecies + +```shell +pip install --group dev +``` + +_If it gives errors try:_ + +```shell +python -m pip install --upgrade pip +``` + +4. If we want to exit our enviroment we do + +```shell +deactivate +``` + +## Running the project + +To build the project, run + +```shell +python build.py --serve --port 8000 +``` + +This will serve the project on `http://localhost:8000` after building it to `build/`. If you make changes to your code, run `build.py` again to rebuild the project. + +### Running with Docker + +You can run the app without a local Python setup using the provided `Dockerfile`. + +Build the image: + +```shell +docker build -t good-image-terminal:latest . +``` + +Run (default port `8000`): + +```shell +docker run --rm --name good-image-terminal -e PORT=8000 -p 8000:8000 good-image-terminal:latest +``` + +Custom port: + +```shell +docker run --rm --name good-image-terminal -e PORT=9000 -p 9000:9000 good-image-terminal:latest +``` + +The container runs `python build.py --serve --port $PORT` on startup: + +- Builds fresh each run (output in container at `/app/build`). +- Serves the site at `http://localhost:`. + +Faster rebuilds during iteration: + +```shell +docker build -t good-image-terminal:latest . # after changing code +``` + +## Contributors + +[![Contributors](https://contrib.rocks/image?repo=Miras3210/codejam-laudatory-larkspurs)](https://github.com/Miras3210/codejam-laudatory-larkspurs/graphs/contributors) diff --git a/laudatory-larkspurs/README_template.md b/laudatory-larkspurs/README_template.md new file mode 100644 index 00000000..3bf4bfba --- /dev/null +++ b/laudatory-larkspurs/README_template.md @@ -0,0 +1,186 @@ +# Python Discord Code Jam Repository Template + +## A primer + +Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! + +This document contains the following information: + +1. [What does this template contain?](#what-does-this-template-contain) +2. [How do I use this template?](#how-do-i-use-this-template) +3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) + +> [!TIP] +> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. + +## What does this template contain? + +Here is a quick rundown of what each file in this repository contains: + +- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. +- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. +- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). +- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. +- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. + +Each of these files have comments for you to understand easily, and modify to fit your needs. + +### Ruff: general style rules + +Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. +It is run with the command `ruff check` in the project root. + +Here is a sample output: + +```shell +$ ruff check +app.py:1:5: N802 Function name `helloWorld` should be lowercase +app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` +app.py:2:5: D400 First line should end with a period +app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` +app.py:3:15: W292 No newline at end of file +Found 5 errors. +``` + +Each line corresponds to an error. The first part is the file path, then the line number, and the column index. +Then comes the error code, a unique identifier of the error, and then a human-readable message. + +If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. +For example: + +```python +def helloWorld(): # noqa: N802 + ... + +``` + +This will ignore the function naming issue and pass linting. + +> [!WARNING] +> We do not recommend ignoring errors unless you have a good reason to do so. + +### Ruff: formatting + +Ruff also comes with a formatter, which can be run with the command `ruff format`. +It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. + +### Pre-commit: run linting before committing + +The second tool doesn't check your code, but rather makes sure that you actually *do* check it. + +It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. +The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. + +It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. + +[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) + +#### List of hooks + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff-check`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## How do I use this template? + +### Creating your team repository + +One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. + +1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. + ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) +2. Give the repository a name and a description. + ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) +3. Click **Create repository from template**. +4. Click **Settings** in your newly created repository. + ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) +5. In the "Access" section of the sidebar, click **Collaborators**. + ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) +6. Click **Add people**. +7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. + +You are now ready to go! Sit down, relax, and wait for the kickstart! + +> [!IMPORTANT] +> Don't forget to change the project name, description, and authors at the top of the [`pyproject.toml`](pyproject.toml) file, and swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. + +### Using the default pip setup + +Our default setup includes a dependency group to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). +It works with pip and uv, and we recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. +More on that [below](#how-do-i-adapt-this-template-to-my-project). + +Dependency groups are a relatively new feature, specified in [PEP 735](https://peps.python.org/pep-0735/). +You can read more about them in the [Python Packaging User Guide](https://packaging.python.org/en/latest/specifications/dependency-groups/). + +#### Creating the environment + +Create a virtual environment in the folder `.venv`. + +```shell +python -m venv .venv +``` + +#### Entering the environment + +It will change based on your operating system and shell. + +```shell +# Linux, Bash +$ source .venv/bin/activate +# Linux, Fish +$ source .venv/bin/activate.fish +# Linux, Csh +$ source .venv/bin/activate.csh +# Linux, PowerShell Core +$ .venv/bin/Activate.ps1 +# Windows, cmd.exe +> .venv\Scripts\activate.bat +# Windows, PowerShell +> .venv\Scripts\Activate.ps1 +``` + +#### Installing the dependencies + +Once the environment is created and activated, use this command to install the development dependencies. + +```shell +pip install --group dev +``` + +#### Exiting the environment + +Interestingly enough, it is the same for every platform. + +```shell +deactivate +``` + +Once the environment is activated, all the commands listed previously should work. + +> [!IMPORTANT] +> We highly recommend that you run `pre-commit install` as soon as possible. + +## How do I adapt this template to my project? + +If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`pyproject.toml`](pyproject.toml) to the development dependencies of your tool. + +We've included a porting to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). +Note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. + +When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. +For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. +I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. + +> [!IMPORTANT] +> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. + +## Final words + +> [!IMPORTANT] +> Don't forget to replace this README with an actual description of your project! Images are also welcome! + +We hope this template will be helpful. Good luck in the jam! diff --git a/laudatory-larkspurs/build.py b/laudatory-larkspurs/build.py new file mode 100644 index 00000000..6edc5499 --- /dev/null +++ b/laudatory-larkspurs/build.py @@ -0,0 +1,56 @@ +"""The build script for the website.""" + +import argparse +import http.server +import pathlib +import shutil +import socketserver + +_this_dir = pathlib.Path(__file__).parent.resolve() + +# Project directories +BUILD_DIR = _this_dir / "build" +PUBLIC_DIR = _this_dir / "public" +SRC_DIR = _this_dir / "src" + + +def _zip_dir(src: pathlib.Path, dest: pathlib.Path) -> None: + """Create a zip file from a directory and places it in `dest`.""" + shutil.make_archive(str(dest), "zip", str(src)) + + +class _DevHandler(http.server.SimpleHTTPRequestHandler): + """Allows for serving the website locally for development.""" + + def __init__(self, request, client_address, server) -> None: # noqa: ANN001 + super().__init__(request, client_address, server, directory=BUILD_DIR) + + +def main() -> None: + """Define the build entry point.""" + parser = argparse.ArgumentParser() + parser.add_argument("--no-clean", action="store_false", dest="clean", default=True) + parser.add_argument("--serve", action="store_true", default=False) + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + if not BUILD_DIR.exists(): # FileNotFoundError if this isn't here + BUILD_DIR.mkdir(exist_ok=True) + + elif args.clean: + shutil.rmtree(BUILD_DIR) + BUILD_DIR.mkdir(exist_ok=True) + + _zip_dir(SRC_DIR, BUILD_DIR / "src") + shutil.copytree(PUBLIC_DIR, BUILD_DIR, dirs_exist_ok=True) + + if args.serve: + print(f"Serving on http://localhost:{args.port}") + httpd = socketserver.TCPServer(("", args.port), _DevHandler) + httpd.serve_forever() + else: + print("Add --serve to start") + + +if __name__ == "__main__": + main() diff --git a/laudatory-larkspurs/docs/.nojekyll b/laudatory-larkspurs/docs/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/laudatory-larkspurs/docs/README.md b/laudatory-larkspurs/docs/README.md new file mode 100644 index 00000000..8fe267ea --- /dev/null +++ b/laudatory-larkspurs/docs/README.md @@ -0,0 +1,54 @@ + + + + + +# Good Image Terminal + +## A Wrong Tool for the Job + + + +This project is a web-based image editing tool that runs entirely in the browser through a terminal. It uses [Pyodide](https://pyodide.org) to enable Python-based image processing without the need for a backend server. All while letting JavaScript sit back, relax, and just load the page. + +The tool allows users to upload images and apply edits through various commands, all within a user-friendly interface. + +The inherently visual task of image editing performed entirely through programmatic terminal commands makes Good Image Terminal the "Wrong tool for the job." Despite this dissonance, we've made GIT comfortable and responsive. + +## Packages + +This project uses the following packages: + +- [Pillow](https://python-pillow.org/) - for image processing +- [Pyodide](https://pyodide.org) - to run Python in the browser and for in-browser file management +- [Webcolors](https://pypi.org/project/webcolors/) - used to parse hex and CSS named colors + +## FAQ + +
+

How can I download the image?

+

On Chrome / Firefox / Brave / Safari / Edge, you can right-click the image and select Save image as... to download it.

+

On iOS, you can tap and hold the image to bring up the context menu, then select Add to Photos or Save Image.

+

On Android, you can tap and hold the image, then select Download Image from the context menu.

+
+ +## License + +This project is licensed under the MIT License - see the [LICENSE](https://github.com/Miras3210/codejam-laudatory-larkspurs/blob/main/LICENSE) file for details. + +### Assets & Media + +All project-specific assets are original to this repository and are released under the same MIT License as the source code, unless a file header or adjacent notice explicitly states otherwise. + +By contributing media assets you agree they are provided under MIT. diff --git a/laudatory-larkspurs/docs/_coverpage.md b/laudatory-larkspurs/docs/_coverpage.md new file mode 100644 index 00000000..621194bc --- /dev/null +++ b/laudatory-larkspurs/docs/_coverpage.md @@ -0,0 +1,15 @@ + + +![logo](_media/icon.png) + +# Good Image Terminal 0.1.0 + +> An image editor. In the terminal. In the browser. + +- Built with **Pyodide** +- Supports basic image editing commands +- Easy to use with a simple command-line interface +- Runs in the browser + +[Start Editing](https://good-image-terminal.vercel.app) +[Getting Started](#good-image-terminal) diff --git a/laudatory-larkspurs/docs/_media/banner.png b/laudatory-larkspurs/docs/_media/banner.png new file mode 100644 index 00000000..e4f1fe63 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/banner.png differ diff --git a/laudatory-larkspurs/docs/_media/banner.svg b/laudatory-larkspurs/docs/_media/banner.svg new file mode 100644 index 00000000..fa1f5b4a --- /dev/null +++ b/laudatory-larkspurs/docs/_media/banner.svg @@ -0,0 +1,118 @@ + + + +$good image terminal diff --git a/laudatory-larkspurs/docs/_media/icon.png b/laudatory-larkspurs/docs/_media/icon.png new file mode 100644 index 00000000..056fdbe9 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/icon.png differ diff --git a/laudatory-larkspurs/docs/_media/icon.svg b/laudatory-larkspurs/docs/_media/icon.svg new file mode 100644 index 00000000..88e8fcc6 --- /dev/null +++ b/laudatory-larkspurs/docs/_media/icon.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/laudatory-larkspurs/docs/_media/showcase/bg.gif b/laudatory-larkspurs/docs/_media/showcase/bg.gif new file mode 100644 index 00000000..25207f55 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/bg.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/draw_circle.gif b/laudatory-larkspurs/docs/_media/showcase/draw_circle.gif new file mode 100644 index 00000000..326d9dd5 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/draw_circle.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/draw_line.gif b/laudatory-larkspurs/docs/_media/showcase/draw_line.gif new file mode 100644 index 00000000..cbd98169 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/draw_line.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/draw_pixel.gif b/laudatory-larkspurs/docs/_media/showcase/draw_pixel.gif new file mode 100644 index 00000000..2cb6768d Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/draw_pixel.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/draw_polygon.gif b/laudatory-larkspurs/docs/_media/showcase/draw_polygon.gif new file mode 100644 index 00000000..174e9162 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/draw_polygon.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/draw_rectangle.gif b/laudatory-larkspurs/docs/_media/showcase/draw_rectangle.gif new file mode 100644 index 00000000..93a259a1 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/draw_rectangle.gif differ diff --git a/laudatory-larkspurs/docs/_media/showcase/fg.gif b/laudatory-larkspurs/docs/_media/showcase/fg.gif new file mode 100644 index 00000000..b1fa7763 Binary files /dev/null and b/laudatory-larkspurs/docs/_media/showcase/fg.gif differ diff --git a/laudatory-larkspurs/docs/_navbar.md b/laudatory-larkspurs/docs/_navbar.md new file mode 100644 index 00000000..a4c5f1ab --- /dev/null +++ b/laudatory-larkspurs/docs/_navbar.md @@ -0,0 +1,3 @@ + + +* [Summer Code Jam 12](/code-jam-12/) diff --git a/laudatory-larkspurs/docs/_sidebar.md b/laudatory-larkspurs/docs/_sidebar.md new file mode 100644 index 00000000..ef6cd9bb --- /dev/null +++ b/laudatory-larkspurs/docs/_sidebar.md @@ -0,0 +1,8 @@ + + +* [Home](/ "Good Image Terminal docs") +* [Features](features.md) +* [Installation](installation.md) +* [Commands](commands.md) +* [Color Formats](color_formats.md) +* [Contribution](contribution.md) diff --git a/laudatory-larkspurs/docs/code-jam-12/README.md b/laudatory-larkspurs/docs/code-jam-12/README.md new file mode 100644 index 00000000..e3ae61ab --- /dev/null +++ b/laudatory-larkspurs/docs/code-jam-12/README.md @@ -0,0 +1,21 @@ + + + + +# Summer Code Jam 12 + +This project is created as a part of the [Summer Code Jam 12](https://www.pythondiscord.com/events/code-jams/12/) event hosted by the Python Discord community. + +> A Code Jam is a chance to create something with a team. In each jam, you are paired up with a group of other users just like yourself who will then be given a type of program to make and a theme to help guide it. You then have a little over a week's time to create the best project you can. +> +> Source: [Python Discord โ€“ What is a Code Jam?](https://www.pythondiscord.com/events/code-jams/#what-is-code-jam) + +## Technology + +For this year's Code Jam, the chosen technology is **Python in the Browser**. + +## Theme + +The theme for this year is **Wrong tool for the job**. diff --git a/laudatory-larkspurs/docs/code-jam-12/_sidebar.md b/laudatory-larkspurs/docs/code-jam-12/_sidebar.md new file mode 100644 index 00000000..fecb839c --- /dev/null +++ b/laudatory-larkspurs/docs/code-jam-12/_sidebar.md @@ -0,0 +1,2 @@ +* [Home](/ "Docs: Good Image Terminal") +* [Summer Code Jam 12](/code-jam-12/) diff --git a/laudatory-larkspurs/docs/color_formats.md b/laudatory-larkspurs/docs/color_formats.md new file mode 100644 index 00000000..3372a8e5 --- /dev/null +++ b/laudatory-larkspurs/docs/color_formats.md @@ -0,0 +1,28 @@ + + +# Color Formats + +These color formats are accepted by commands that take a `` argument (e.g. `bg`, `fg`, `draw_*`, `terminal_background`, etc.). + +## Supported Formats + +- Space- or comma-separated `R G B [A]` (0โ€“255) +- Hex `#RRGGBB` +- Named CSS colors + - List can be found [here](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color) +- Functional notations: + - `rgb(r g b)` / `rgb(r, g, b)` + - `rgba(r g b a)` / `rgba(r, g, b, a)` + - `hsv(h s v)` / `hsva(h s v a)` + +## Examples + +- `255 255 255` +- `100,0,0,255` +- `gold` +- `#C0FFEE` +- `rgb(0 200 150)` +- `rgba(0 255 255 100)` +- `hsv(360 100 100)` + +> Alpha channel (A) is optional. Values are rejected if out of bounds. diff --git a/laudatory-larkspurs/docs/commands.md b/laudatory-larkspurs/docs/commands.md new file mode 100644 index 00000000..6cdd8f61 --- /dev/null +++ b/laudatory-larkspurs/docs/commands.md @@ -0,0 +1,267 @@ + + +# Commands + +## `bg` + +Sets the background color of the canvas. All subsequent drawing will be rendered on top of this background. + +### Arguments + +- ``: The background color to set. + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: bg + +```bash +bg +``` + +![bg command](_media/showcase/bg.gif) + +## `draw_circle` + +Draws a circle on the canvas at a specified position with customizable radius, colors, and outline thickness. + +### Arguments + +- ``: The x-coordinate of the center of the circle. +- ``: The y-coordinate of the center of the circle. +- ``: The radius of the circle. +- `--fg `: The foreground color for the circle (optional). +- `--bg `: The background color for the circle (optional). +- `--outline `: The outline thickness (optional). +- `--no-fill`: If specified, the circle will not be filled. + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: draw_circle + +```bash +draw_circle [--fg ] [--bg ] [--outline ] [--no-fill] +``` + +![draw_circle command](_media/showcase/draw_circle.gif) + +## `draw_line` + +Draws a straight line between two points. The line color can be customized. + +### Arguments + +- ``: The x-coordinate of the start point. +- ``: The y-coordinate of the start point. +- ``: The x-coordinate of the end point. +- ``: The y-coordinate of the end point. +- `--fg `: The foreground color for the line (optional). + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: draw_line + +```bash +draw_line [--fg ] +``` + +![draw_line command](_media/showcase/draw_line.gif) + +## `draw_pixel` + +Places a single pixel at the specified coordinates. The color can be defined with the foreground option. + +### Arguments + +- ``: The x-coordinate of the pixel. +- ``: The y-coordinate of the pixel. +- `--fg `: The foreground color for the pixel (optional). + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: draw_pixel + +```bash +draw_pixel [--fg ] +``` + +![draw_pixel command](_media/showcase/draw_pixel.gif) + +## `draw_polygon` + +Draws a polygon using the provided vertices, with options for fill, outline, and colors. + +### Arguments + +- ``: A list of points defining the polygon, e.g., `x1,y1 x2,y2 x3,y3 ...`. +- `--fg `: The foreground color for the polygon (optional). +- `--bg `: The background color for the polygon (optional). +- `--outline `: The outline thickness (optional). +- `--no-fill`: If specified, the polygon will not be filled. + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: draw_polygon + +```bash +draw_polygon [--fg ] [--bg ] [--outline ] [--no-fill] +``` + +![draw_polygon command](_media/showcase/draw_polygon.gif) + +## `draw_rectangle` + +Draws a rectangle on the canvas at the specified position and dimensions. + +### Arguments + +- ``: The x-coordinate of the top-left corner. +- ``: The y-coordinate of the top-left corner. +- ``: The width of the rectangle. +- ``: The height of the rectangle. +- `--fg `: The foreground color for the rectangle (optional). +- `--bg `: The background color for the rectangle (optional). +- `--outline `: The outline thickness (optional). +- `--no-fill`: If specified, the rectangle will not be filled. + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: draw_rectangle + +```bash +draw_rectangle +``` + +![draw_rectangle command](_media/showcase/draw_rectangle.gif) + +## `fg` + +Sets the foreground color for use in drawing commands. + +#### Arguments + +- ``: The color to set as the foreground color. + +#### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: fg + +```bash +fg +``` + +![fg command](_media/showcase/fg.gif) + +## `help` + +Displays information about available commands. When a specific command is given, it shows detailed usage instructions. + +### Arguments + +- `[command]`: The command to get help for (optional). +- `[page]`: The specific page of help to display (optional). + +### Usage: help + +```bash +help [command] [page] +``` + +## `image_info` + +Displays details about the currently loaded image, such as dimensions and format. + +### Usage: image_info + +```bash +image_info +``` + +## `load_image` + +Loads an image file into the canvas for editing or manipulation. + +### Arguments + +- ``: The name of the image file to load. + +### Usage: load_image + +```bash +load_image +``` + +## `ls` + +Lists available files in the current working directory. + +### Usage: ls + +```bash +ls +``` + +## `ping` + +Pong! + +### Usage: ping + +```bash +ping +``` + +## `save_image` + +Saves the current canvas to an image file with the specified name. + +### Usage: save_image + +### Arguments + +- ``: The name of the image file to save. + +```bash +save_image +``` + +## `terminal_background` + +Changes the background color of the terminal (not the canvas). + +### Arguments + +- ``: The color to set as the terminal background. + +### Color Argument + +See [Color Formats](color_formats.md) for supported color syntaxes and examples. + +### Usage: terminal_background + +```bash +terminal_background +``` + +## `undo` + +Reverts the most recent drawing action on the canvas. + +### Usage: undo + +```bash +undo +``` diff --git a/laudatory-larkspurs/docs/contribution.md b/laudatory-larkspurs/docs/contribution.md new file mode 100644 index 00000000..7b31c8db --- /dev/null +++ b/laudatory-larkspurs/docs/contribution.md @@ -0,0 +1,204 @@ + + +# Contributing Guidelines + +## How to Contribute + +1. Fork and clone the repo from . +2. Make some changes to the source code. +3. Run code checks to make sure nothing breaks. +4. Push your changes and open a pull request. + +## Code Checks + +Use `ruff check` to check your code style and fix it. + +```shell +ruff check . +ruff check . --fix +``` + +Use `pre-commit` to run linting before committing. + +> [!NOTE] +> `pre-commit install` to install. + +### Examples + +```shell +pre-commit run --show-diff-on-failure --all-files +pre-commit run ruff-check --all-files +pre-commit run check-toml --all-files +``` + +**Pre-commit hooks:** + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff-check`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## Rules + +1. Comment your classes, functions, and non-obvious logic. +2. Use docstrings with author tags and short descriptions. + +```py +class ClassName: + '''Handle user input and validation. + + @author Mira + ''' + ... + +# Short function description +def do_something(): + '''Perform a single-step operation. + + :param paramname: does something + :return: None + + @author Mira + ''' + ... +``` + +## Naming Conventions + +### Variables and Functions + +All variables and functions use lowercase naming with underscores. + +```py +def my_function(): + my_variable = "value" +``` + +### Classes and Type Aliases + +Classes use the PascalCase naming convention. + +```py +from typing import List + +class MyClass: + pass + +ListOfMyClass = List[MyClass] +``` + +### Constants + +Constants use the SCREAMING_SNAKE_CASE naming convention. + +```py +MY_CONSTANT = 1 +``` + +### Operators + +Place operators at the start of a newline. + +```py +# No +result = ( + 1 + + 2 * + 3 +) +# Yes +result = ( + 1 + + 2 + * 3 +) +``` + +### Equivalent to `None` + +Use `is` or `is not` instead of `==`. + +```py +if variable == None: # No + print("Variable is None") +if variable is None: # Yes + print("Variable is None")ยจ +``` + +### `not` positioning + +```py +if not variable is None: # No + print("Variable is not None") + +if variable is not None: # Yes, easier to read + print("Variable is not None") +``` + +### **Imports** + +Do not import multiple modules on one line or everything from a module (`*`) + +```py +# No +import pathlib, os +from pathlib import * + +# Yes +import os +import pathlib +from pathlib import Path +``` + +## Contributors + +Contributors + +### Mira + +**Team Leader** + + + +- Python image editing and handling +- Some of commands listed in [/commands](commands.md) +- bugfixing pyodide's filesystem + - handling image files + +### Philip + + + +- Command parser/environment and command framework +- Written/maintained a Majority of the commands listed in [/commands](commands.md) as the system evolved + - notable ones being help and bg/fg +- Came up with the initial idea. (which then got misinterpreted twice) + +### Ricky + + + +- Implemented functionality for image file upload through a drag and drop interface +- Created components for image display manager and GUI preview +- Simple RGB color information extraction and display feature +- Integrated Docker for containerized development +- Set up continuous deployment using GitHub Actions workflows + +### Jont + + + +- Developed core GUI microframework +- Created build script and initial HTML structure with Pyodide integration +- Implemented the terminal UI + +### Julien + + + +- app layout design +- help with gui +- new feature ideas +- testing diff --git a/laudatory-larkspurs/docs/favicon.ico b/laudatory-larkspurs/docs/favicon.ico new file mode 100644 index 00000000..c70b5834 Binary files /dev/null and b/laudatory-larkspurs/docs/favicon.ico differ diff --git a/laudatory-larkspurs/docs/features.md b/laudatory-larkspurs/docs/features.md new file mode 100644 index 00000000..8bc3728f --- /dev/null +++ b/laudatory-larkspurs/docs/features.md @@ -0,0 +1,25 @@ + + + + +# Features + +## Terminal-like UI + +The core interface mimics a terminal, with command input, output history, and colored feedback for success or error. + +To implement the UI, we developed a custom lightweight GUI abstraction that wraps HTML elements. This abstraction allows us to create custom stateful GUI elements that can be easily extended and manipulated to create complex user interfaces. + +## Command Suggestions + +As you type, get realtime suggestions for available commands. + +## Image Preview + +The app provides a preview of the image you are currently editing. Also, you can drag and drop an image file onto the preview area to upload it for editing. + +## Color/Position Information + +The app provides information about the current cursor position and color of the pixel under the cursor. diff --git a/laudatory-larkspurs/docs/index.html b/laudatory-larkspurs/docs/index.html new file mode 100644 index 00000000..3148d094 --- /dev/null +++ b/laudatory-larkspurs/docs/index.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/laudatory-larkspurs/docs/installation.md b/laudatory-larkspurs/docs/installation.md new file mode 100644 index 00000000..91da6330 --- /dev/null +++ b/laudatory-larkspurs/docs/installation.md @@ -0,0 +1,81 @@ +# Installation Guide + +Choose one of the methods below to get started. + +## Source code + +### 1. Fork the repository + +```bash +git clone https://github.com/Miras3210/codejam-laudatory-larkspurs.git && cd codejam-laudatory-larkspurs +``` + +### 2. Setup and activate a Python environment + +```bash +python -m venv .venv +``` + +Linux / macOS + +```bash +source .venv/bin/activate +``` + +Windows, cmd.exe + +``` +.venv\Scripts\activate.bat +``` + +Windows, PowerShell + +``` +.venv\Scripts\Activate.ps1 +``` + +### 3. Install dependencies + +```bash +pip install . +``` + +> [!NOTE] +> If you encounter issues, try upgrading pip. + +```bash +pip install --upgrade pip +``` + +### 4. Build and serve the project + +```bash +python build.py --serve --port 8000 +``` + +Open [http://localhost:8000](http://localhost:8000). + +## Dev Install + +```bash +pip install --upgrade pip +pip install --group dev +``` + +## Docker + +You can run the app without a local Python setup using the provided `Dockerfile`. + +### 1. Build the image + +```bash +docker build -t good-image-terminal:latest . +``` + +### 2. Run on default port `8000` + +```bash +docker run --rm --name good-image-terminal -e PORT=8000 -p 8000:8000 good-image-terminal:latest +``` + +Then visit [http://localhost:8000](http://localhost:8000). diff --git a/laudatory-larkspurs/public/android-chrome-192x192.png b/laudatory-larkspurs/public/android-chrome-192x192.png new file mode 100644 index 00000000..2f2a6678 Binary files /dev/null and b/laudatory-larkspurs/public/android-chrome-192x192.png differ diff --git a/laudatory-larkspurs/public/android-chrome-512x512.png b/laudatory-larkspurs/public/android-chrome-512x512.png new file mode 100644 index 00000000..bf88c148 Binary files /dev/null and b/laudatory-larkspurs/public/android-chrome-512x512.png differ diff --git a/laudatory-larkspurs/public/apple-touch-icon.png b/laudatory-larkspurs/public/apple-touch-icon.png new file mode 100644 index 00000000..a450f56a Binary files /dev/null and b/laudatory-larkspurs/public/apple-touch-icon.png differ diff --git a/laudatory-larkspurs/public/favicon.ico b/laudatory-larkspurs/public/favicon.ico new file mode 100644 index 00000000..c70b5834 Binary files /dev/null and b/laudatory-larkspurs/public/favicon.ico differ diff --git a/laudatory-larkspurs/public/favicon.svg b/laudatory-larkspurs/public/favicon.svg new file mode 100644 index 00000000..88e8fcc6 --- /dev/null +++ b/laudatory-larkspurs/public/favicon.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/laudatory-larkspurs/public/index.html b/laudatory-larkspurs/public/index.html new file mode 100644 index 00000000..d1130f59 --- /dev/null +++ b/laudatory-larkspurs/public/index.html @@ -0,0 +1,69 @@ + + + + + + + + Good Image Terminal + + + + + + + + + + +
Loading Python...
+ + + + diff --git a/laudatory-larkspurs/public/site.webmanifest b/laudatory-larkspurs/public/site.webmanifest new file mode 100644 index 00000000..63a8dc54 --- /dev/null +++ b/laudatory-larkspurs/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#000000","background_color":"#000000","display":"standalone"} diff --git a/laudatory-larkspurs/public/templates/app_template.html b/laudatory-larkspurs/public/templates/app_template.html new file mode 100644 index 00000000..1db94405 --- /dev/null +++ b/laudatory-larkspurs/public/templates/app_template.html @@ -0,0 +1,168 @@ + + + + + + App template + + + + +

[Placeholder Image]

+
+
+ Image editor v2.1 $ ping
+ pong!
+ Image editor v2.1 $ +
+ +
+

โ˜ฐ

+
+

How to use the app:

+
+
+ + + + diff --git a/laudatory-larkspurs/pyproject.toml b/laudatory-larkspurs/pyproject.toml new file mode 100644 index 00000000..288cdf4e --- /dev/null +++ b/laudatory-larkspurs/pyproject.toml @@ -0,0 +1,78 @@ +[project] +name = "good-image-terminal" +description = "A simple yet interesting image editor." +authors = [ + { name = "Mira"}, + { name = "Julien"}, + { name = "Jont"}, + { name = "Philip"}, + { name = "Richard Szilagyi", email = "szprichard@proton.me"}, + { name = "Mark"} +] +version = "0.1.0" +license = "MIT" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "pyodide-py>=0.27.7", + "pillow~=11.3.0", + "webcolors~=24.11.1" +] + +[dependency-groups] +# This `dev` group contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. +dev = [ + "pyodide-py>=0.27.7", + "pre-commit~=4.2.0", + "ruff~=0.12.2", + "pillow~=11.3.0", + "webcolors~=24.11.1" +] + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", +] + +[tool.ruff.lint.per-file-ignores] +# ignore Magic value and ambigus variable names when handling colors and color space conversions +"src/utils/color.py" = ["E741", "PLR2004"] diff --git a/laudatory-larkspurs/src/__init__.py b/laudatory-larkspurs/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/laudatory-larkspurs/src/commands/__init__.py b/laudatory-larkspurs/src/commands/__init__.py new file mode 100644 index 00000000..1a3ba2d0 --- /dev/null +++ b/laudatory-larkspurs/src/commands/__init__.py @@ -0,0 +1,34 @@ +from commands.background import Background +from commands.base_command import BaseCommand +from commands.draw_circle import DrawCircle +from commands.draw_line import DrawLine +from commands.draw_pixel import DrawPixel +from commands.draw_polygon import DrawPolygon +from commands.draw_rectangle import DrawRectangle +from commands.foreground import Foreground +from commands.help import Help +from commands.image_info import ImageInfo +from commands.load_image import LoadImage +from commands.ls import Ls +from commands.ping import Ping +from commands.save_image import SaveImage +from commands.terminal_background import TerminalBackground +from commands.undo import Undo + +all_commands: dict[str, BaseCommand] = { + Ls.name: Ls(), + Help.name: Help(), + Ping.name: Ping(), + Undo.name: Undo(), + DrawLine.name: DrawLine(), + DrawPixel.name: DrawPixel(), + ImageInfo.name: ImageInfo(), + LoadImage.name: LoadImage(), + SaveImage.name: SaveImage(), + TerminalBackground.name: TerminalBackground(), + DrawCircle.name: DrawCircle(), + DrawRectangle.name: DrawRectangle(), + Foreground.name: Foreground(), + Background.name: Background(), + DrawPolygon.name: DrawPolygon(), +} diff --git a/laudatory-larkspurs/src/commands/background.py b/laudatory-larkspurs/src/commands/background.py new file mode 100644 index 00000000..7761793f --- /dev/null +++ b/laudatory-larkspurs/src/commands/background.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import create_color + +if TYPE_CHECKING: + from terminal import Terminal + + +class Background(BaseCommand): + """Sets the background color for use in drawing commands. + + @author Philip + """ + + name: str = "bg" + help_pages: tuple[str, ...] = ( + """Sets the background color for use in drawing commands. + + Usage: bg + Examples: + bg 255 255 255 + bg 100 0 0 255 + bg gold + bg #C0FFEE + bg rgb(0 200 150) + bg rgba(0 255 255 100) + bg hsv(360 100 100) + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Set the background color for use in drawing commands. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Philip + """ + try: + color = create_color(" ".join(args)) + except ValueError as e: + terminal.output_error(e.args[0]) + return False + + terminal.background_color = color + + return True + + def predict_args(self, terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Predicts the next argument for help. + + :param terminal: The terminal instance. + :param args: Arguments already passed to the command. + :return: The predicted continuance of the arguments for the command. If new argument, start with space. + If no more arguments "". If error in arguments, return None. + + @author Philip + """ + if not all(arg.isdigit() for arg in args): + return "" + + match len(args): + case 0: + return " " + str(terminal.background_color.r) + case 1: + return " " + str(terminal.background_color.g) + case 2: + return " " + str(terminal.background_color.b) + case 3: + return " " + str(terminal.background_color.a) + + return None diff --git a/laudatory-larkspurs/src/commands/base_command.py b/laudatory-larkspurs/src/commands/base_command.py new file mode 100644 index 00000000..b19dbc35 --- /dev/null +++ b/laudatory-larkspurs/src/commands/base_command.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING + +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + + +class BaseCommand: + """BaseCommand is the class that all commands should inherit from. + + it contains some utility functions, but most calls should be overridden in full command implementation. + + `name` and `help_pages` should be overwritten in full command implementation. + `known_options` is the options that a command can take. + + The options "fg" and "bg" are special and represent the foreground and background color for drawing. + If these are requested, they will always be supplied + either temporarily changed from command line or set from `fg` and `bg` commands. + + @author Philip + """ + + name: str = "BaseCommand" + help_pages: tuple[str, ...] = ( + """BaseCommand is the class that all commands should inherit from. + + raises NotImplementedError when called as it and this message should never be seen. + if you see this message in the application report how + """, + ) + known_options: tuple[str, ...] = () + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: + """Preforms the command being called using `*args`. + + This function should be overridden by subclasses. + + The subclasses implementation should handle argument handling. + + :param terminal: The terminal instance. + :param args: Arguments passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: Was the command executed successfully? + + @author Philip + """ + msg = "BaseCommand should not be called and should be overridden" + raise NotImplementedError(msg) + + def predict_args(self, terminal: "Terminal", *args: str, **options: str | Color) -> str | None: + """Predicts the next argument for the command. + + This function should be overridden by subclasses. + + The subclasses implementation should do error handling on incorrect arguments. + + :param terminal: The terminal instance. + :param args: Arguments already passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: The predicted continuance of the arguments for the command. If new argument, start with space. + If no more arguments "". If error in arguments, return None. + + @author Philip + """ + msg = "BaseCommand should `predict_args` not be called and should be overridden" + raise NotImplementedError(msg) diff --git a/laudatory-larkspurs/src/commands/draw_circle.py b/laudatory-larkspurs/src/commands/draw_circle.py new file mode 100644 index 00000000..b01d9939 --- /dev/null +++ b/laudatory-larkspurs/src/commands/draw_circle.py @@ -0,0 +1,101 @@ +import dataclasses +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + +REQUIRED_NUMBER_ARGS = 3 + + +class DrawCircle(BaseCommand): + """Circle drawing on PaintImage. + + @author Mira + """ + + name: str = "draw_circle" + help_pages: tuple[str, ...] = ( + """ + Usage: draw_circle + + arguments x,y: coordinate numbers + argument radius: color name + """, + """ + Options: + fg : set fill color for circle + bg : set border color for circle + no-fill: don't fill circle + outline : set size of outline around circle + """, + ) + known_options = ("fg", "bg", "no-fill", "outline") + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: # noqa: PLR0911 + """Draw circle command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) != REQUIRED_NUMBER_ARGS: + terminal.output_error("Bad amount of arguments, see help for options") + return False + + size = terminal.image.img.size + if not ( + args[0].isdigit() and args[1].isdigit() and 0 <= int(args[0]) < size[0] and 0 <= int(args[1]) < size[1] + ): + terminal.output_error("Invalid coordinates.") + return False + x, y = int(args[0]), int(args[1]) + + if not (args[2].isdigit()): + terminal.output_error("Invalid radius.") + return False + rad = int(args[2]) + if rad < 0: + terminal.output_error("Radius cannot be negative.") + return False + + if "no-fill" in options: + fill_color = dataclasses.replace(options["fg"]) + fill_color.a = 0 + else: + fill_color = options["fg"] + + if "outline" in options: + if options["outline"].isdigit(): + outline_size = int(options["outline"]) + if outline_size < 0: + terminal.output_error("Invalid outline size.") + return False + boarder_color = options["bg"] + else: + terminal.output_error("Invalid outline size.") + return False + else: + outline_size = 0 + boarder_color = None + + terminal.image.draw_circle(x, y, rad, fill_color, boarder_color, outline_size) + terminal.output_info(f"Circle at {x}x{y} size {rad} filled with rgb{options['fg'].rgba}.") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str | Color) -> str | None: + """Argument predictor.""" + result = "" + match len(args): + case 0: + result = " x" + case 1: + result = " y" + case 2: + result = " radius" + return result diff --git a/laudatory-larkspurs/src/commands/draw_line.py b/laudatory-larkspurs/src/commands/draw_line.py new file mode 100644 index 00000000..850dee1f --- /dev/null +++ b/laudatory-larkspurs/src/commands/draw_line.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + +REQUIRED_NUMBER_ARGS = 4 + + +class DrawLine(BaseCommand): + """Line drawing on PaintImage. + + @author Mira + """ + + name: str = "draw_line" + help_pages: tuple[str, ...] = ( + """ + Usage: draw_line + + arguments x1,y1: starting coordinates + arguments x2,y2: ending coordinates + argument color: color name + arguments r,g,b: red,green,blue numbers + """, + """ + Options: + fg : set color of line + """, + ) + known_options = ("fg",) + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: + """Draw line command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) != REQUIRED_NUMBER_ARGS: + terminal.output_error("Bad amount of arguments, see help for options") + return False + + size = terminal.image.img.size + if not ( + args[0].isdigit() and args[1].isdigit() and 0 <= int(args[0]) < size[0] and 0 <= int(args[1]) < size[1] + ): + terminal.output_error("Invalid starting coordinates.") + return False + if not ( + args[2].isdigit() and args[3].isdigit() and 0 <= int(args[2]) < size[0] and 0 <= int(args[3]) < size[1] + ): + terminal.output_error("Invalid ending coordinates.") + return False + + x1, y1 = int(args[0]), int(args[1]) + x2, y2 = int(args[2]), int(args[3]) + + terminal.image.draw_line(x1, y1, x2, y2, options["fg"]) + terminal.output_info(f"line from {x1}x{y1} to {x2}x{y2} with rgb{options['fg'].rgba}") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str | Color) -> str | None: + """Argument predictor.""" + result = "" + match len(args): + case 0: + result = " x1" + case 1: + result = " y1" + case 2: + result = " x2" + case 3: + result = " y2" + return result diff --git a/laudatory-larkspurs/src/commands/draw_pixel.py b/laudatory-larkspurs/src/commands/draw_pixel.py new file mode 100644 index 00000000..962f9249 --- /dev/null +++ b/laudatory-larkspurs/src/commands/draw_pixel.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + +REQUIRED_NUMBER_ARGS = 2 + + +class DrawPixel(BaseCommand): + """Pixel drawing on PaintImage. + + @author Mira + """ + + name: str = "draw_pixel" + help_pages: tuple[str, ...] = ( + """ + Usage: draw_pixel + + arguments x,y: coordinate numbers + """, + """ + Options: + fg : set color of pixel + """, + ) + known_options = ("fg",) + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: + """Draw pixel command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) != REQUIRED_NUMBER_ARGS: + terminal.output_error("Bad amount of arguments, see help for options") + return False + + size = terminal.image.img.size + if not ( + args[0].isdigit() and args[1].isdigit() and 0 <= int(args[0]) < size[0] and 0 <= int(args[1]) < size[1] + ): + terminal.output_error("Invalid coordinates.") + return False + x, y = int(args[0]), int(args[1]) + + terminal.image.set_pixel(x, y, options["fg"]) + terminal.output_info(f"Pixel at {x}x{y} filled with rgb{options['fg'].rgba}.") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Argument predictor.""" + result = "" + match len(args): + case 0: + result = " x" + case 1: + result = " y" + return result diff --git a/laudatory-larkspurs/src/commands/draw_polygon.py b/laudatory-larkspurs/src/commands/draw_polygon.py new file mode 100644 index 00000000..370f2070 --- /dev/null +++ b/laudatory-larkspurs/src/commands/draw_polygon.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + +REQUIRED_NUMBER_ARGS = 6 + + +class DrawPolygon(BaseCommand): + """Polygon drawing on PaintImage. + + @author Philip + """ + + name: str = "draw_polygon" + help_pages: tuple[str, ...] = ( + """ + Usage: draw_rectangle ... + + arguments x,y: coordinate numbers for points on polygon + Requires at least 3 points and even number of arguments + """, + """ + Options: + fg : set fill color for polygon + bg : set border color for polygon + no-fill: don't fill polygon + outline : set size of outline around polygon + """, + ) + known_options = ("fg", "bg", "no-fill", "outline") + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: + """Draw polygon command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) < REQUIRED_NUMBER_ARGS and len(args) % 2 == 0: + terminal.output_error("Bad amount of arguments, see help for options") + return False + + size = terminal.image.img.size + points: list[tuple[int, int]] = [] + for x, y in zip(args[::2], args[1::2], strict=False): + if not (x.isdigit() and y.isdigit() and 0 <= int(x) < size[0] and 0 <= int(y) < size[1]): + terminal.output_error(f"Invalid coordinates: ({x}, {y})") + return False + points.append((int(x), int(y))) + + fill_color = None if "no-fill" in options else options["fg"] + + if "outline" in options: + if options["outline"].isdigit(): + outline_size = int(options["outline"]) + if outline_size < 0: + terminal.output_error("Invalid outline size.") + return False + outline_color = options["bg"] + else: + terminal.output_error("Invalid outline size.") + return False + else: + outline_size = 0 + outline_color = None + + terminal.image.draw_polygon(points, fill_color, outline_color, outline_size) + terminal.output_info(f"drawn {len(points)}-sided polygon filled with rgba{fill_color.rgba}") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str | Color) -> str | None: + """Argument predictor.""" + return " x" if len(args) % 2 == 0 else " y" diff --git a/laudatory-larkspurs/src/commands/draw_rectangle.py b/laudatory-larkspurs/src/commands/draw_rectangle.py new file mode 100644 index 00000000..dd2cd1cf --- /dev/null +++ b/laudatory-larkspurs/src/commands/draw_rectangle.py @@ -0,0 +1,100 @@ +import dataclasses +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import Color + +if TYPE_CHECKING: + from terminal import Terminal + +REQUIRED_NUMBER_ARGS = 4 + + +class DrawRectangle(BaseCommand): + """Rectangle drawing on PaintImage. + + @author Mira + """ + + name: str = "draw_rectangle" + help_pages: tuple[str, ...] = ( + """ + Usage: draw_rectangle + + arguments x,y: coordinate numbers + arguments width,height: width and height of the rectangle + """, + """ + Options: + fg : set fill color for rectangle + bg : set border color for rectangle + no-fill: don't fill rectangle + outline : set size of outline around rectangle + """, + ) + known_options = ("fg", "bg", "no-fill", "outline") + + def __call__(self, terminal: "Terminal", *args: str, **options: str | Color) -> bool: + """Draw rectangle command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) != REQUIRED_NUMBER_ARGS: + terminal.output_error("Bad amount of arguments, see help for options") + return False + + size = terminal.image.img.size + if not ( + args[0].isdigit() and args[1].isdigit() and 0 <= int(args[0]) < size[0] and 0 <= int(args[1]) < size[1] + ): + terminal.output_error("Invalid coordinates.") + return False + x, y = int(args[0]), int(args[1]) + + if not (args[2].isdigit() and args[3].isdigit()): + terminal.output_error("Invalid size.") + return False + w, h = int(args[2]), int(args[3]) + + if "no-fill" in options: + fill_color = dataclasses.replace(options["fg"]) + fill_color.a = 0 + else: + fill_color = options["fg"] + + if "outline" in options: + if options["outline"].isdigit(): + outline_size = int(options["outline"]) + if outline_size < 0: + terminal.output_error("Invalid outline size.") + return False + outline_color = options["bg"] + else: + terminal.output_error("Invalid outline size.") + return False + else: + outline_size = 0 + outline_color = None + + terminal.image.fill_rect(x, y, w, h, fill_color, outline_color, outline_size) + terminal.output_info(f"rectangle at {x}x{y} size {w}x{h} filled with rgb{options['fg'].rgba}") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str | Color) -> str | None: + """Argument predictor.""" + result = "" + match len(args): + case 0: + result = " x" + case 1: + result = " y" + case 2: + result = " width" + case 3: + result = " height" + return result diff --git a/laudatory-larkspurs/src/commands/foreground.py b/laudatory-larkspurs/src/commands/foreground.py new file mode 100644 index 00000000..6289e204 --- /dev/null +++ b/laudatory-larkspurs/src/commands/foreground.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import create_color + +if TYPE_CHECKING: + from terminal import Terminal + + +class Foreground(BaseCommand): + """Sets the foreground color for use in drawing commands. + + @author Philip + """ + + name: str = "fg" + help_pages: tuple[str, ...] = ( + """Sets the foreground color for use in drawing commands. + + Usage: fg + Examples: + bg 255 255 255 + bg 100 0 0 255 + bg gold + bg #C0FFEE + bg rgb(0 200 150) + bg rgba(0 255 255 100) + bg hsv(360 100 100) + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Set the foreground color for use in drawing commands. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Philip + """ + try: + color = create_color(" ".join(args)) + except ValueError as e: + terminal.output_error(e.args[0]) + return False + + terminal.foreground_color = color + + return True + + def predict_args(self, terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Predicts the next argument for help. + + :param terminal: The terminal instance. + :param args: Arguments already passed to the command. + :return: The predicted continuance of the arguments for the command. If new argument, start with space. + If no more arguments "". If error in arguments, return None. + + @author Philip + """ + if not all(arg.isdigit() for arg in args): + return "" + + match len(args): + case 0: + return " " + str(terminal.foreground_color.r) + case 1: + return " " + str(terminal.foreground_color.g) + case 2: + return " " + str(terminal.foreground_color.b) + case 3: + return " " + str(terminal.foreground_color.a) + + return None diff --git a/laudatory-larkspurs/src/commands/help.py b/laudatory-larkspurs/src/commands/help.py new file mode 100644 index 00000000..92ed57ca --- /dev/null +++ b/laudatory-larkspurs/src/commands/help.py @@ -0,0 +1,95 @@ +from typing import TYPE_CHECKING + +import commands +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + + +class Help(BaseCommand): + """Help is a command that displays the help documentation of the command given. + + @author Philip + """ + + name: str = "help" + help_pages: tuple[str, ...] = ( + """help is a command that displays the help documentation of the command given. + + Usage: help + + The help documentation may also contain multiple pages so it can either be call multiple times + with the same arguments to get the next page or be called with the page number you are looking for + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Pushes the text present in the help_pages of each command. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Philip + """ + page = 1 + match len(args): + case 0: + terminal.output_info("Available commands: ") + terminal.output_info(", ".join(sorted(commands.all_commands.keys()))) + terminal.output_info("for more information on a command use `help command`.") + return True + case 1: + page = 1 + case 2: + if not args[1].isdigit(): + terminal.output_error("second argument must be an integer.") + page = int(args[1]) + case _: + terminal.output_error("too many arguments.") + return False + + if args[0] not in commands.all_commands: + terminal.output_error(f"`{args[0]}` is an Unknown command.") + terminal.output_error("use `help` to see a list of available commands") + return False + + command = commands.all_commands[args[0]] + + if page > len(command.help_pages): + terminal.output_error(f"`{args[0]}` is not a valid page.") + return False + + terminal.output_info(f"help for `{args[0]}`\t\t page: {page}/{len(command.help_pages)}") + for line in command.help_pages[page - 1].split("\n"): + terminal.output_info(line.strip()) + + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Predicts the next argument for help. + + :param _terminal: The terminal instance. + :param args: Arguments already passed to the command. + :return: The predicted continuance of the arguments for the command. If new argument, start with space. + If no more arguments "". If error in arguments, return None. + + @author Philip + """ + match len(args): + case 0: + return " help" + case 1: + if args[0] not in commands.all_commands: + for command in commands.all_commands: + if command.startswith(args[0]): + return command + return None # invalid command + return " 1" + case 2: + if args[1].isdigit(): + return "" + + return None diff --git a/laudatory-larkspurs/src/commands/image_info.py b/laudatory-larkspurs/src/commands/image_info.py new file mode 100644 index 00000000..d80f66b2 --- /dev/null +++ b/laudatory-larkspurs/src/commands/image_info.py @@ -0,0 +1,62 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + + +class ImageInfo(BaseCommand): + """Display Image info. + + @author Mira + """ + + name: str = "image_info" + help_pages: tuple[str, ...] = ( + """ + Usage: image_info + or you can get specific pixel info: image_info + No arguments. + Displays: size, number of colors + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """... + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + info = terminal.image.get_info() + if len(args) == 2: # noqa: PLR2004 + # check if the pixel is in an image + if ( + args[0].isdigit() + and args[0].isdigit() + and 0 <= int(args[0]) < info["size"][0] + and 0 <= int(args[1]) < info["size"][1] + ): + terminal.output_info(f"Image pixel info (x:{int(args[0])} y:{int(args[1])}):") + terminal.output_info(f"Color: rgb{terminal.image.get_pixel(int(args[0]), int(args[1]))}") + return True + terminal.output_error("Incorrectly placed x and y coordinates of a pixel.") + return False + terminal.output_info("Image info:") + terminal.output_info(f"Size: {info['size'][0]}x{info['size'][1]} pixels") + terminal.output_info(f"Edit count: {info['edits']}") + if info["colors"]: + terminal.output_info(f"Colors: {len(info['colors'])}") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Argument predictor.""" + if len(args) == 0: + return " x y" + if len(args) == 1: + return " y" + return "" diff --git a/laudatory-larkspurs/src/commands/load_image.py b/laudatory-larkspurs/src/commands/load_image.py new file mode 100644 index 00000000..379afdb8 --- /dev/null +++ b/laudatory-larkspurs/src/commands/load_image.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + +IMAGES_PATH = Path(__file__).parent.parent.resolve() / "images" + + +class LoadImage(BaseCommand): + """load_image is a command that loads the given image. + + @author Mira + """ + + name: str = "load_image" + help_pages: tuple[str, ...] = ( + """ + Usage: load_image + + Default image loading: load_image default + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Load an image to program memory. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if not args: + terminal.output_error("You need to provide a full image name. See help for more info.") + return False + if args[0] == "default": + terminal.image.load() + terminal.output_info("default image loaded") + elif terminal.image.load(args[0]): + terminal.output_error("Image not found.") + return False + terminal.output_info(f"image `{args[0]}` loaded") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Argument predictor.""" + if len(args) != 1: + return "" + for path in IMAGES_PATH.iterdir(): + if path.name.startswith(args[0]): + return path.name + return "" diff --git a/laudatory-larkspurs/src/commands/ls.py b/laudatory-larkspurs/src/commands/ls.py new file mode 100644 index 00000000..b907faf1 --- /dev/null +++ b/laudatory-larkspurs/src/commands/ls.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + +IMAGES_PATH = Path(__file__).parent.parent.resolve() / "images" + + +class Ls(BaseCommand): + """Listing of image directory. + + @author Mira + """ + + name: str = "ls" + help_pages: tuple[str, ...] = ( + """ + Usage: ls + + Lists the directory of images. + Does not need any arguments. + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """List all image files. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if args: + terminal.output_error("No arguments needed") + terminal.output_info("Files: " + " ".join([path.name for path in IMAGES_PATH.iterdir() if path.is_file()])) + return True + + def predict_args(self, _terminal: "Terminal", *_args: str, **_options: str) -> str | None: + """Argument predictor.""" + return "" diff --git a/laudatory-larkspurs/src/commands/ping.py b/laudatory-larkspurs/src/commands/ping.py new file mode 100644 index 00000000..21462cfb --- /dev/null +++ b/laudatory-larkspurs/src/commands/ping.py @@ -0,0 +1,38 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + + +class Ping(BaseCommand): + """Test ping command. + + Just echos pong to terminal + + @author Philip + """ + + name: str = "ping" + help_pages: tuple[str, ...] = ( + """Pong!!! + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Print pong to terminal. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Philip + """ + terminal.output_success("pong" + (f": {', '.join(args)}" if args else "")) + return True + + def predict_args(self, _terminal: "Terminal", *_args: str, **_options: str) -> str | None: + """Argument predictor.""" + return "" diff --git a/laudatory-larkspurs/src/commands/save_image.py b/laudatory-larkspurs/src/commands/save_image.py new file mode 100644 index 00000000..767e8e5f --- /dev/null +++ b/laudatory-larkspurs/src/commands/save_image.py @@ -0,0 +1,74 @@ +from pathlib import Path +from string import ascii_letters, digits +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + +IMAGES_PATH = Path(__file__).parent.parent.resolve() / "images" + + +class SaveImage(BaseCommand): + """... + + @author Mira + """ + + name: str = "save_image" + help_pages: tuple[str, ...] = ( + """ + Usage: save_image + + Allowed characters: A-Z a-z 0-9 _ + + flags: + --overwrite overwrites previous image + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """... + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if len(args) == 0: + terminal.output_error("You need to provide a full image name. See help for more info.") + return False + if len(args) > 1: + terminal.output_error("Too many arguments. See help for more info.") + return False + path = args[0] + if not path.endswith(".png"): + terminal.output_error("Please save the image as a .png") + return False + for character in path[:-4]: + if character not in ascii_letters + digits + "_": + terminal.output_error("Invalid characters in Image name.") + terminal.output_error("Please check `help save_image` for more information.") + return False + if (IMAGES_PATH / path).exists() and not args.__contains__("--overwrite"): + terminal.output_error("This path already exists, use --overwrite to overwrite it") + return False + terminal.image.save(path) + terminal.output_info(f"Image succesfully saved as `{path}`") + return True + + def predict_args(self, _terminal: "Terminal", *args: str, **_options: str) -> str | None: + """Argument predictor.""" + if len(args) > 2 or len(args) == 0: # noqa: PLR2004 + return "" + if args[0].endswith(".png"): + if (IMAGES_PATH / args[0]).exists(): + return args[0] + " --overwrite" + return "" + for path in IMAGES_PATH.iterdir(): + if path.name.startswith(args[0]): + return path.name + " --overwrite" + return args[0].split(".")[0] + ".png" diff --git a/laudatory-larkspurs/src/commands/terminal_background.py b/laudatory-larkspurs/src/commands/terminal_background.py new file mode 100644 index 00000000..6c1ea6da --- /dev/null +++ b/laudatory-larkspurs/src/commands/terminal_background.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand +from utils.color import create_color + +if TYPE_CHECKING: + from terminal import Terminal + + +class TerminalBackground(BaseCommand): + """terminal_background is a command that changes background color of the terminal. + + @author Julien + """ + + name: str = "terminal_background" + help_pages: tuple[str, ...] = ( + """terminal_background is a command that changes background color of the terminal. + + Usage: terminal_background + Exemple: bg rgb(255, 100, 0) + """, + ) + + def __call__(self, terminal: "Terminal", *args: str, **_options: str) -> bool: + """Change the background color of the web page. + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Julien + """ + try: + terminal.terminal_display.background_color = f"rgb{create_color(' '.join(args)).rgb}" + except ValueError as e: + terminal.output_error(e.args[0]) + return False + + terminal.output_info("background-color succesfully changed") + return True diff --git a/laudatory-larkspurs/src/commands/undo.py b/laudatory-larkspurs/src/commands/undo.py new file mode 100644 index 00000000..870df0ee --- /dev/null +++ b/laudatory-larkspurs/src/commands/undo.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING + +from commands.base_command import BaseCommand + +if TYPE_CHECKING: + from terminal import Terminal + + +class Undo(BaseCommand): + """Magic Undo button. + + @author Mira + """ + + name: str = "undo" + help_pages: tuple[str, ...] = ( + """ + Usage: undo + + Undoes the last thing you did. + Can be only done once! + """, + ) + + def __call__(self, terminal: "Terminal", *_args: str, **_options: str) -> bool: + """... + + :param terminal: The terminal instance. + :param args: Arguments to be passed to the command. + :param options: Options passed to the command with optional arguments with those options. + :return: True if command was executed successfully. + + @author Mira + """ + if terminal.image.undo(): + terminal.output_error("Cannot be undone.") + return False + terminal.output_success("Undone :)") + return True + + def predict_args(self, _terminal: "Terminal", *_args: str, **_options: str) -> str | None: + """Argument predictor.""" + return "" diff --git a/laudatory-larkspurs/src/gui/__init__.py b/laudatory-larkspurs/src/gui/__init__.py new file mode 100644 index 00000000..9187e997 --- /dev/null +++ b/laudatory-larkspurs/src/gui/__init__.py @@ -0,0 +1,163 @@ +import js # type: ignore[import] + +from gui.element import Element +from gui.layout import Layout + +_base_style = """ +html, +body { + margin: 0; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: monospace; + background-color: var(--body-background-color); + color: var(--text-color); +} + +:root { + color-scheme: dark; + --body-background-color: #262626; + --terminal-background-color: black; + --terminal-output-color: white; + --terminal-error-color: red; + --terminal-success-color: green; + --terminal-suggestion-color: rgb(119, 119, 119); + --description-background-color: #1c1c1c; + --description-button-border-color: #6b6b6b; + --text-color: white; + --image-preview-background: repeating-conic-gradient(#202020 0 25%, #0000 0 50%) 50% / 3em 3em; + --separator-color: #6b6b6b; +} + +#description { + position: fixed; + top: 0; + right: 0; + width: 5em; + background-color: var(--description-background-color); + transition: width 0.2s ease; + z-index: 100; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; +} + +#description.open { + width: 40%; + overflow-y: auto; + overflow-x: hidden; +} + +@media screen and (max-width: 600px) { + #description { + width: 100%; + position: static; + } + + #description.open { + width: 100%; + } + + #description > .expand-btn { + min-height: 2em; + } +} + +.expand-btn:hover { + filter: brightness(0.9); + cursor: pointer; +} + +#description > .description-content { + transition: height 0.2s ease; + display: flex; + flex-direction: column; + height: 0; + overflow-y: hidden; +} + +#description.open > .description-content { + height: 100vh; + width: 100%; + overflow-y: auto; +} + +#image-preview { + cursor: pointer; +} + +#image-preview:hover { + background-color: #b8b8b8; +} + +#image-preview.drag-over { + background-color: #e3f2fd !important; + border-color: #2196f3 !important; +} + +#image-preview { + cursor: pointer; +} + +#image-preview:hover { + background-color: #b8b8b8; +} + +#image-preview.drag-over { + background-color: #e3f2fd !important; + border-color: #2196f3 !important; +} + +.terminal-output, .user-input, .user-input > span { + white-space: pre-wrap; + word-break: break-all; +} + +#terminal, #terminal * { + color: var(--terminal-output-color); + background-color: var(--terminal-background-color); +} + +#terminal::selection, #terminal *::selection { + color: var(--terminal-background-color); + background-color: var(--terminal-output-color); +} + +.terminal-input-verb-text, .command-verb-span { + text-decoration: underline; +} +""" + + +def init_gui() -> Element: + """Initialize the top-level layout for the application. + + --- + + Authors: + - Jont + - Ricky + """ + # Hide the loading screen + js.document.getElementById("loading").style.display = "none" + + body = Element(element=js.document.body) + + # Set the base style for the app + base_style = Element("style") + base_style.text = _base_style + js.document.head.appendChild(base_style.html_element) + + # Create the main layout with image preview, separator, and terminal + layout = Layout(parent=body) + + # Set up global event handlers + body.on("click", lambda _: layout.description["classList"].remove("open")) + body.on("mouseup", layout.handle_global_mouse_up) + body.on("mousemove", layout.handle_global_mouse_move) + + return body diff --git a/laudatory-larkspurs/src/gui/components/__init__.py b/laudatory-larkspurs/src/gui/components/__init__.py new file mode 100644 index 00000000..ebfb0c1b --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/__init__.py @@ -0,0 +1,20 @@ +"""GUI components module.""" + +from .description import Description +from .image_preview import ImagePreview +from .separator import Separator +from .terminal_gui import TerminalGui +from .terminal_input import TerminalInput +from .terminal_io import TerminalHistory, TerminalInputVerb, TerminalOutput, UserInput + +__all__ = [ + "Description", + "ImagePreview", + "Separator", + "TerminalGui", + "TerminalHistory", + "TerminalInput", + "TerminalInputVerb", + "TerminalOutput", + "UserInput", +] diff --git a/laudatory-larkspurs/src/gui/components/description.py b/laudatory-larkspurs/src/gui/components/description.py new file mode 100644 index 00000000..83b63125 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/description.py @@ -0,0 +1,123 @@ +from typing import Any + +from gui.element import Button, Element, HTMLElement + + +class DescriptionContent(Element): + """The description content element for displaying useful information to the user. + + --- + + :author: Jont + + """ + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + super().__init__( + "div", + parent=parent, + id="description-content", + style="text-align: left;", + ) + self.class_name = "description-content" + + self.content_wrapper = Element( + "div", + parent=self, + style="padding: 0 20px;", + ) + + header = Element( + "h3", + parent=self.content_wrapper, + ) + + header.text = "How to use the app:" + + paragraph = Element( + "p", + parent=self.content_wrapper, + ) + + paragraph.text = ( + "Drag and drop an image file onto the image preview area to upload it. " + "You can also click the image preview area to select a file from your computer. " + "Once an image is uploaded, you can interact with it using the terminal commands. " + "Use the 'help' command to see a list of available commands." + ) + + # Footer / attribution + footer_sep = Element( + "hr", + parent=self.content_wrapper, + style=""" + margin: 16px 0; + border: 0; + border-top: 1px solid var(--description-button-border-color); + """, + ) + footer_sep.class_name = "description-footer-separator" + + footer = Element( + "div", + parent=self.content_wrapper, + style=""" + font-size: 12px; + opacity: 0.85; + padding-bottom: 4px; + """, + ) + footer.class_name = "description-footer" + + footer_text = Element( + "span", + parent=footer, + ) + footer_text.text = "Project for the Pydis Summer Codejam 2025. " + + link = Element( + "a", + parent=footer, + style=""" + color: var(--link-color, #4ea1ff); + text-decoration: underline; + cursor: pointer; + """, + ) + + link["href"] = "https://github.com/Miras3210/codejam-laudatory-larkspurs/" + link["target"] = "_blank" + link.text = "GitHub Repository" + + +class Description(Element): + """The description element for displaying useful information to the user.""" + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + super().__init__("div", parent=parent, id="description") + self.class_name = "description" + + expand_btn = Button( + parent=self, + id="expand-btn", + style=""" + background-color: var(--description-background-color); + color: var(--text-color); + width: 100%; + text-align: center; + user-select: none; + font-family: monospace; + border: 3px solid var(--description-button-border-color); + """, + ) + expand_btn.class_name = "expand-btn" + expand_btn.text = "About" + expand_btn.on_click(self._on_expand_btn_click) + + self.description_content = DescriptionContent(parent=self) + + self.on("click", lambda event: event.stopPropagation()) + + def _on_expand_btn_click(self, event: Any) -> None: # noqa: ANN401 + self["classList"].toggle("open") + event.stopPropagation() diff --git a/laudatory-larkspurs/src/gui/components/drag_drop_handler.py b/laudatory-larkspurs/src/gui/components/drag_drop_handler.py new file mode 100644 index 00000000..d0d228a3 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/drag_drop_handler.py @@ -0,0 +1,200 @@ +from collections.abc import Callable +from typing import Any + +from gui.element import Element + + +class DragDropHandler: + """Handles drag and drop functionality for file uploads. + + --- + + Authors: + - Ricky + """ + + def __init__( + self, + element: Element, + on_file_drop: Callable[[Any], None], + on_drag_enter: Callable[[], None] | None = None, + on_drag_leave: Callable[[], None] | None = None, + on_error: Callable[[str], None] | None = None, + ) -> None: + """Initialize drag drop handler element. + + Args: + element: The element to attach drag/drop events to + on_file_drop: Callback function when a file is dropped + on_drag_enter: Optional callback when drag enters + on_drag_leave: Optional callback when drag leaves + on_error: Optional callback for error handling + + --- + + :author: Ricky + + """ + self.element = element + self.on_file_drop = on_file_drop + self.on_drag_enter = on_drag_enter + self.on_drag_leave = on_drag_leave + self.on_error = on_error + self.drag_overlay = None + + def setup_drag_overlay(self) -> Element: + """Create and return the drag overlay element. + + --- + + :author: Ricky + + """ + self.drag_overlay = Element( + "div", + parent=self.element, + style=""" + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 123, 255, 0.1); + border: 2px dashed #007bff; + display: none; + justify-content: center; + align-items: center; + z-index: 10; + """, + ) + + overlay_text = Element( + "div", + parent=self.drag_overlay, + style=""" + font-size: 24px; + color: #007bff; + font-weight: bold; + text-align: center; + user-select: none; + """, + ) + overlay_text.text = "Drop image here" + + return self.drag_overlay + + def setup_events(self) -> None: + """Set up drag and drop event handlers. + + --- + + :author: Ricky + + """ + self.element.on("dragover", self._handle_drag_over) + self.element.on("dragenter", self._handle_drag_enter) + self.element.on("dragleave", self._handle_drag_leave) + self.element.on("drop", self._handle_drop) + + def _handle_drag_over(self, event: Any) -> None: # noqa: ANN401 + """Handle drag over event. + + Args: + event: The mouse drag event + + --- + + :author: Ricky + + """ + event.preventDefault() + event.stopPropagation() + + def _handle_drag_enter(self, event: Any) -> None: # noqa: ANN401 + """Handle drag enter event. + + Args: + event: The mouse drag event + + --- + + :author: Ricky + + """ + event.preventDefault() + event.stopPropagation() + + if self.drag_overlay: + self.drag_overlay["style"].display = "flex" + self.element["style"].borderColor = "#007bff" + + # Call the optional drag enter callback + if self.on_drag_enter: + self.on_drag_enter() + + def _handle_drag_leave(self, event: Any) -> None: # noqa: ANN401 + """Handle drag leave event. + + Args: + event: The mouse drag event + + --- + + :author: Ricky + + """ + event.preventDefault() + event.stopPropagation() + + # Only hide overlay if we're leaving the container + try: + related_target = event.relatedTarget + if related_target is None or not self.element.html_element.contains( + related_target, + ): + if self.drag_overlay: + self.drag_overlay["style"].display = "none" + self.element["style"].borderColor = "transparent" + + # Call the optional drag leave callback + if self.on_drag_leave: + self.on_drag_leave() + except (AttributeError, TypeError): + # If there's any issue accessing relatedTarget, just hide the overlay + if self.drag_overlay: + self.drag_overlay["style"].display = "none" + self.element["style"].borderColor = "transparent" + + # Call the optional drag leave callback + if self.on_drag_leave: + self.on_drag_leave() + + def _handle_drop(self, event: Any) -> None: # noqa: ANN401 + """Handle file drop event. + + Args: + event: The mouse drag event + + --- + + :author: Ricky + + """ + event.preventDefault() + event.stopPropagation() + + if self.drag_overlay: + self.drag_overlay["style"].display = "none" + self.element["style"].borderColor = "transparent" + + files = event.dataTransfer.files + if files.length > 0: + file = files.item(0) + if file.type.startswith("image/"): + self.on_file_drop(file) + else: + error_msg = "Please drop an image file (PNG, JPG, etc.)" + if self.on_error: + self.on_error(error_msg) + else: + print(error_msg) diff --git a/laudatory-larkspurs/src/gui/components/file_upload_handler.py b/laudatory-larkspurs/src/gui/components/file_upload_handler.py new file mode 100644 index 00000000..cab3552c --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/file_upload_handler.py @@ -0,0 +1,110 @@ +import base64 +from collections.abc import Callable +from typing import Any + +import js # type: ignore[import] +from pyodide.ffi import create_proxy + + +class FileUploadHandler: + """Handles file upload functionality via click and file processing. + + --- + + Authors: + - Ricky + """ + + def __init__( + self, + on_file_processed: Callable[[str], None], + on_error: Callable[[str], None], + ) -> None: + """Initialize file upload handler. + + Args: + on_file_processed: Callback when file is successfully processed with data URL + on_error: Callback when an error occurs during file processing + + --- + + :author: Ricky + + """ + self.on_file_processed = on_file_processed + self.on_error = on_error + + def handle_click_upload(self, _event: Any) -> None: # noqa: ANN401 + """Handle click to upload functionality. + + Args: + _event: The mouse click event + + --- + + :author: Ricky + + """ + # Create a hidden file input + file_input = js.document.createElement("input") + file_input.type = "file" + file_input.accept = "image/*" + file_input.style.display = "none" + + # Handle file selection + def handle_file_select(e: Any) -> None: # noqa: ANN401 + files = e.target.files + if files.length > 0: + self.process_file(files.item(0)) + + # Create a persistent proxy for the event handler + file_select_proxy = create_proxy(handle_file_select) + file_input.addEventListener("change", file_select_proxy) + js.document.body.appendChild(file_input) + file_input.click() + js.document.body.removeChild(file_input) + + def process_file(self, file: Any) -> None: # noqa: ANN401 + """Process the uploaded file and convert it to a data URL. + + Args: + file: The file to process + + --- + + :author: Ricky + + """ + reader = js.FileReader.new() + + # Create a persistent proxy for the load event handler + def on_load(event: Any) -> None: # noqa: ANN401 + # Get the file data + array_buffer = event.target.result + + # Convert to data URL for display + try: + # Convert array buffer to bytes + uint8_array = js.Uint8Array.new(array_buffer) + file_bytes = bytes(uint8_array.to_py()) + + # Create base64 data URL + file_b64 = base64.b64encode(file_bytes).decode("utf-8") + + # Determine MIME type based on file type + mime_type = "image/png" # Default + if hasattr(file, "type") and file.type: + mime_type = str(file.type) + + data_url = f"data:{mime_type};base64,{file_b64}" + self.on_file_processed(data_url) + + except ImportError as e: + self.on_error(f"Error importing required modules: {e!s}") + except (AttributeError, TypeError, ValueError) as e: + self.on_error(f"Error processing image data: {e!s}") + + # Use create_proxy to ensure the event handler persists + load_proxy = create_proxy(on_load) + reader.addEventListener("load", load_proxy) + reader.readAsArrayBuffer(file) diff --git a/laudatory-larkspurs/src/gui/components/image_display_manager.py b/laudatory-larkspurs/src/gui/components/image_display_manager.py new file mode 100644 index 00000000..9281b147 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/image_display_manager.py @@ -0,0 +1,238 @@ +from typing import Any + +import js # type: ignore[import] + +from gui.element import Element + + +class ImageDisplayManager: + """Manages image display functionality. + + --- + + Authors: + - Jont + - Ricky + """ + + image_container: Element + image_element: Element + placeholder_text: Element + cursor_info: Element + color_info: Element | None + + def __init__( + self, + container: Element, + cursor_info_element: Element, + color_info_element: Element | None = None, + ) -> None: + self.container = container + self.current_image_src: str | None = None + self.cursor_info = cursor_info_element + self.color_info = color_info_element + self._canvas_context: Any | None = None + self._setup_elements() + + def _setup_elements(self) -> None: + """Create the image container, image element, and placeholder text. + + --- + + :author: Ricky + + """ + # Image container element + self.image_container = Element( + "div", + parent=self.container, + style=""" + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + position: relative; + """, + ) + + # Image element + self.image_element = Element( + "img", + parent=self.image_container, + style=""" + max-width: 100%; + max-height: 100%; + display: none; + object-fit: contain; + """, + ) + + # Create placeholder text + self.placeholder_text = Element( + "div", + parent=self.image_container, + style=""" + font-size: 18px; + color: #666; + text-align: center; + user-select: none; + """, + ) + self.placeholder_text.text = "Loading default image..." + + # Events + self.image_element.on("mousemove", self._on_image_mouse_move) + self.image_element.on("mouseleave", self._on_image_mouse_leave) + self.image_element.on("load", self._on_image_load) + + def _on_image_load(self, _event: Any) -> None: # noqa: ANN401 + """Prepare an offscreen canvas for pixel color sampling. + + Args: + _event: The load event + + --- + + :author: Ricky + + """ + try: + natural_width = self.image_element["naturalWidth"] + natural_height = self.image_element["naturalHeight"] + canvas = js.document.createElement("canvas") + canvas.width = natural_width + canvas.height = natural_height + ctx = canvas.getContext("2d") + ctx.willReadFrequently = True + ctx.drawImage(self.image_element.html_element, 0, 0) + self._canvas_context = ctx + except (AttributeError, RuntimeError) as exc: # pragma: no cover + print(f"Failed to prepare canvas for color sampling: {exc}") + + def _on_image_mouse_move(self, event: Any) -> None: # noqa: ANN401 + """Update cursor and color display while the mouse moves over the image. + + Args: + event: The mouse event providing client coordinates and element offsets + + --- + + Authors: + - Jont (`cursor_info`) + - Ricky (`color_info`) + + """ + if not self.current_image_src: + self.cursor_info.text = "" + if self.color_info is not None: + self.color_info.text = "" + return + + natural_width = self.image_element["naturalWidth"] + natural_height = self.image_element["naturalHeight"] + intrinsic_mouse_x = int( + ((event.clientX - self.image_element["offsetLeft"]) / self.image_element["clientWidth"]) * natural_width, + ) + intrinsic_mouse_y = int( + ((event.clientY - self.image_element["offsetTop"]) / self.image_element["clientHeight"]) * natural_height, + ) + intrinsic_mouse_x = max(0, min(intrinsic_mouse_x, natural_width - 1)) + intrinsic_mouse_y = max(0, min(intrinsic_mouse_y, natural_height - 1)) + self.cursor_info.text = f"X: {intrinsic_mouse_x}, Y: {intrinsic_mouse_y}" + + if self.color_info is not None and self._canvas_context is not None: + try: + pixel = self._canvas_context.getImageData( + intrinsic_mouse_x, + intrinsic_mouse_y, + 1, + 1, + ).data + r, g, b = pixel[0], pixel[1], pixel[2] + self.color_info.text = f"R: {r} G: {g} B: {b}" + except ( + AttributeError, + RuntimeError, + ValueError, + ) as exc: # pragma: no cover + self.color_info.text = "R: - G: - B: -" + print(f"Color sample error: {exc}") + + def _on_image_mouse_leave(self, _event: Any) -> None: # noqa: ANN401 + """Clear info when mouse leaves the image. + + Args: + _event: The mouse leave event + + --- + + :author: Jont + + """ + self.cursor_info.text = "" + if self.color_info is not None: + self.color_info.text = "" + + def display_image(self, image_src: str) -> None: + """Display an image in the preview area. + + Args: + image_src: The source URL of the image to display + + --- + + :author: Ricky + + """ + self.current_image_src = image_src + self.image_element["src"] = image_src + self.image_element["style"].display = "block" + self.placeholder_text["style"].display = "none" + + def hide_image(self) -> None: + """Hide the current image. Useful during drag operations. + + --- + + :author: Ricky + + """ + if self.current_image_src: + self.image_element["style"].display = "none" + + def show_image(self) -> None: + """Show the current image. + + --- + + :author: Ricky + + """ + if self.current_image_src: + self.image_element["style"].display = "block" + + def show_error(self, message: str) -> None: + """Print an error message to the console. + + Args: + message: The error message to print + + --- + + :author: Ricky + + """ + print(message) + + def _reset_placeholder(self) -> None: + """Reset placeholder to original state. + + --- + + :author: Ricky + + """ + if not self.current_image_src: + self.placeholder_text.text = "Drop an image here or click to upload" + self.placeholder_text["style"].color = "#666" diff --git a/laudatory-larkspurs/src/gui/components/image_preview.py b/laudatory-larkspurs/src/gui/components/image_preview.py new file mode 100644 index 00000000..d4dcc0ef --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/image_preview.py @@ -0,0 +1,164 @@ +from typing import Any + +from gui.components.drag_drop_handler import DragDropHandler +from gui.components.file_upload_handler import FileUploadHandler +from gui.components.image_display_manager import ImageDisplayManager +from gui.element import Element, HTMLElement + + +class ImagePreview(Element): + """An image preview component for displaying an image with a drag-drop upload functionality. + + --- + + Authors: + - Jont + - Ricky + """ + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + """Initialize the image preview element. + + Args: + parent: The parent element to attach this preview to + + --- + + Authors: + - Jont (`cursor_info`) + - Ricky (`color_info`) + + """ + super().__init__( + tag_name="div", + parent=parent, + id="image-preview", + style=""" + background: var(--image-preview-background); + height: 50%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex-shrink: 0; + position: relative; + border: 2px dashed transparent; + transition: border-color 0.3s ease, background-color 0.3s ease; + """, + ) + self.class_name = "image-preview" + + # Cursor info element + self.cursor_info = Element( + "div", + parent=self, + style=""" + position: absolute; + bottom: 10px; + left: 10px; + color: white; + font-size: 14px; + z-index: 1; + """, + ) + + # Color info element + self.color_info = Element( + "div", + parent=self, + style=""" + position: absolute; + bottom: 10px; + right: 10px; + color: white; + font-size: 14px; + z-index: 1; + text-align: right; + """, + ) + self.color_info.text = "R: - G: - B: -" + + # Initialize the image display manager + self.image_manager = ImageDisplayManager( + self, + self.cursor_info, + self.color_info, + ) + + # Initialize the file upload handler + self.file_handler = FileUploadHandler( + on_file_processed=self._on_file_processed, + on_error=self._on_error, + ) + + # Initialize the drag drop handler + self.drag_handler = DragDropHandler( + element=self, + on_file_drop=self._on_file_drop, + on_drag_enter=self.image_manager.hide_image, + on_drag_leave=self.image_manager.show_image, + on_error=self._on_error, + ) + + # Set up drag overlay and events + self.drag_overlay = self.drag_handler.setup_drag_overlay() + self.drag_handler.setup_events() + + # Add click to upload functionality + self.on("click", self.file_handler.handle_click_upload) + self.image = None + + def _on_file_processed(self, data_url: str) -> None: + """Handle successfully processed file. + + Args: + data_url: The data URL of the processed image + + --- + + :author: Ricky + + """ + if self.image is not None: + self.image.load_from_image_link(data_url) + else: + self.image_manager.display_image(data_url) + + def _on_error(self, error_message: str) -> None: + """Handle file processing error. + + Args: + error_message: The error message to display + + --- + + :author: Ricky + + """ + self.image_manager.show_error(error_message) + + def _on_file_drop(self, file: Any) -> None: # noqa: ANN401 + """Handle file drop from drag and drop. + + Args: + file: The dropped file + + --- + + :author: Ricky + + """ + self.file_handler.process_file(file) + + def display_image(self, image_src: str) -> None: + """Display an image in the preview area. + + Args: + image_src: The source URL of the image to display + + --- + + :author: Ricky + + """ + self.image_manager.display_image(image_src) diff --git a/laudatory-larkspurs/src/gui/components/separator.py b/laudatory-larkspurs/src/gui/components/separator.py new file mode 100644 index 00000000..70d73364 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/separator.py @@ -0,0 +1,147 @@ +from collections.abc import Callable +from typing import Any + +from gui.element import Element, HTMLElement + + +class Separator(Element): + """A draggable separator component for resizing adjacent elements. + + --- + + Authors: + - Jont + - Ricky + """ + + def __init__( + self, + parent: HTMLElement | Element | None = None, + on_resize: Callable[[int], None] | None = None, + ) -> None: + """Initialize the separator element. + + Args: + parent: Parent element to attach to + on_resize: Callback function called with mouse Y position during resize + + --- + + :author: Jont + + """ + super().__init__( + tag_name="div", + parent=parent, + id="separator", + style=""" + background-color: var(--separator-color); + width: 100%; + height: 3px; + cursor: ns-resize; + flex-shrink: 0; + """, + ) + self.class_name = "separator" + + self._is_dragging = False + self._on_resize = on_resize + + # Set up event handlers + self.on("mousemove", self._handle_mouse_move) + self.on("mousedown", self._handle_mouse_down) + self.on("mouseup", self._handle_mouse_up) + + def _handle_mouse_move(self, event: Any) -> None: # noqa: ANN401 + """Handle mouse movement for resizing. + + Args: + event: The mouse event containing the client Y position. + + --- + + :author: Jont + + """ + if not self._is_dragging: + return + + # Prevent default to avoid interfering with other behaviors + event.preventDefault() + event.stopPropagation() + + mouse_y = event.clientY + if self._on_resize: + self._on_resize(mouse_y) + + def _handle_mouse_down(self, event: Any) -> None: # noqa: ANN401 + """Handle mouse down to start dragging. + + Args: + event: The mouse event containing the client Y position. + + --- + + :author: Jont + + """ + if event.button != 0: # Only handle left mouse button + return + self._is_dragging = True + self["parentElement"].style.userSelect = "none" + + # Prevent default to avoid interfering with other drag behaviors + event.preventDefault() + event.stopPropagation() + + def _handle_mouse_up(self, _event: Any) -> None: # noqa: ANN401 + """Handle mouse up to stop dragging. + + Args: + _event: The mouse event containing the client Y position. + + --- + + :author: Jont + + """ + if self._is_dragging: + self._is_dragging = False + self["parentElement"].style.userSelect = "auto" + + @property + def is_dragging(self) -> bool: + """Check if the separator is currently being dragged. + + --- + + :author: Ricky + + """ + return self._is_dragging + + def handle_mouse_up(self, event: Any) -> None: # noqa: ANN401 + """Public method to handle mouse up event. + + Args: + event: The mouse event containing the client Y position. + + --- + + :author: Ricky + + """ + self._handle_mouse_up(event) + + def handle_mouse_move(self, event: Any) -> None: # noqa: ANN401 + """Public method to handle mouse move event. + + Args: + event: The mouse event containing the client Y position. + + --- + + :author: Ricky + + """ + self._handle_mouse_move(event) diff --git a/laudatory-larkspurs/src/gui/components/terminal_gui.py b/laudatory-larkspurs/src/gui/components/terminal_gui.py new file mode 100644 index 00000000..1575dce3 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/terminal_gui.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from collections import deque +from typing import TYPE_CHECKING, Any + +import js # type: ignore[import] + +from gui.element import Element, HTMLElement + +from .terminal_input import TerminalInput +from .terminal_io import TerminalHistory, TerminalOutput, UserInput + +if TYPE_CHECKING: + from terminal import Terminal + +KEYCODE_TAB = 9 +KEYCODE_ENTER = 13 + + +class CssVariable: + """A class for managing a CSS variable for an Element.""" + + name: str + element: Element + + def __init__(self, name: str, element: Element) -> None: + self.name = name + self.element = element + + def get(self) -> str: + """Get the value of the CSS variable.""" + return js.document.getComputedStyle(self.element.html_element).getPropertyValue(self.name) + + def set(self, value: str) -> None: + """Set the value of the CSS variable.""" + self.element.html_element.style.setProperty(self.name, value) + + +class TerminalGui(Element): + """The terminal GUI component for displaying terminal-like output and input.""" + + max_previous_commands: int = 20 + previous_commands: deque[str] + current_command_idx: int | None = None + terminal: Terminal | None = None + + _output_color_variable: CssVariable + _background_color_variable: CssVariable + _success_color_variable: CssVariable + _error_color_variable: CssVariable + _suggestion_color_variable: CssVariable + + def get_suggestion(self, command: str | None) -> str | None: + """Get a suggestion for the given command.""" + if not command: + return None + return self.terminal.predict_command(command) + + def print_terminal_output(self, text: str, color: str | None = None) -> None: + """Print the given text to the terminal output.""" + output = TerminalOutput(text, color=color) + self.history.add_history(output) + + def clear_terminal_history(self) -> None: + """Clear the terminal history.""" + self.history.clear_history() + self.input.set_suggestion(None) + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + super().__init__( + tag_name="div", + id="terminal", + parent=parent, + style=""" + background-color: var(--terminal-background-color); + color: var(--terminal-output-color); + flex-grow: 1; + overflow-y: scroll; + font-family: monospace; + border: 0; + outline: 0; + margin: 0; + padding: 20px; + white-space: pre; + """, + ) + + # Initialize CSS variables for terminal colors + self._output_color_variable = CssVariable("--terminal-output-color", self) + self._background_color_variable = CssVariable("--terminal-background-color", self) + self._success_color_variable = CssVariable("--terminal-success-color", self) + self._error_color_variable = CssVariable("--terminal-error-color", self) + self._suggestion_color_variable = CssVariable("--terminal-suggestion-color", self) + + self.previous_commands = deque(maxlen=self.max_previous_commands) + self.class_name = "terminal" + + self.history = TerminalHistory(parent=self) + self.input = TerminalInput(parent=self) + + self.input.text_input.on("keydown", self._on_input_control_keydown) + self.input.text_input.on("input", self._on_input) + self.on("click", self._focus_input) + + @property + def output_color(self) -> str: + """The color of the terminal output.""" + return self._output_color_variable.get() + + @output_color.setter + def output_color(self, value: str) -> None: + self._output_color_variable.set(value) + + @property + def background_color(self) -> str: + """The background color of the terminal.""" + return self._background_color_variable.get() + + @background_color.setter + def background_color(self, value: str) -> None: + self._background_color_variable.set(value) + + @property + def success_color(self) -> str: + """The color used for successful terminal commands.""" + return self._success_color_variable.get() + + @success_color.setter + def success_color(self, value: str) -> None: + self._success_color_variable.set(value) + + @property + def error_color(self) -> str: + """The color used for error terminal commands.""" + return self._error_color_variable.get() + + @error_color.setter + def error_color(self, value: str) -> None: + self._error_color_variable.set(value) + + @property + def suggestion_color(self) -> str: + """The color used for terminal command suggestions.""" + return self._suggestion_color_variable.get() + + @suggestion_color.setter + def suggestion_color(self, value: str) -> None: + self._suggestion_color_variable.set(value) + + def _submit_input(self, event: Any) -> None: # noqa: ANN401 + value = event.target.value + self.history.add_history(UserInput(value)) + + last_command = self.previous_commands[-1] if self.previous_commands else None + if value and (last_command is None or value != last_command): + self.previous_commands.append(value) + + if self.terminal is not None: + self.terminal.run_str(value) + else: + print("Warning: TerminalGui has no Terminal instance assigned.") + + event.target.value = "" + self.input.set_suggestion(None) + self.input.set_value("") + + def _confirm_suggestion(self, event: Any) -> None: # noqa: ANN401 + value = event.target.value + self.input.set_value(self.get_suggestion(value) or value) + + def _navigate_commands(self, offset: int) -> None: + if not self.previous_commands: + return + if self.current_command_idx is None: + self.current_command_idx = len(self.previous_commands) + self.current_command_idx = max(0, self.current_command_idx + offset) + if self.current_command_idx >= len(self.previous_commands): + self.current_command_idx = None + if self.current_command_idx is None: + self.input.set_value("") + else: + self.input.set_value(self.previous_commands[self.current_command_idx]) + + def _on_input_control_keydown(self, event: Any) -> None: # noqa: ANN401 + if event.keyCode == KEYCODE_ENTER: + self._submit_input(event) + self.current_command_idx = None + event.preventDefault() + elif event.keyCode == KEYCODE_TAB: + self._confirm_suggestion(event) + self.current_command_idx = None + event.preventDefault() + elif event.key == "ArrowUp": + self._navigate_commands(-1) + event.preventDefault() + elif event.key == "ArrowDown": + self._navigate_commands(1) + event.preventDefault() + elif event.key == "ArrowRight" and event.target.selectionStart == len(event.target.value): + self._confirm_suggestion(event) + self.current_command_idx = None + event.preventDefault() + + def _on_input(self, event: Any) -> None: # noqa: ANN401 + self.input.set_suggestion(self.get_suggestion(event.target.value)) + self.input.set_value(event.target.value) + self.current_command_idx = None + + def _focus_input(self, _event: Any) -> None: # noqa: ANN401 + if len(js.window.getSelection().toString()) > 0: + return + self.input.text_input["focus"]() diff --git a/laudatory-larkspurs/src/gui/components/terminal_input.py b/laudatory-larkspurs/src/gui/components/terminal_input.py new file mode 100644 index 00000000..11b19541 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/terminal_input.py @@ -0,0 +1,121 @@ +"""Terminal input widget for the terminal GUI.""" + +import re + +from gui.element import Element, HTMLElement, Input + +from .terminal_io import TerminalInputVerb + + +class TerminalInput(Element): + """A terminal input component for user commands.""" + + text_input: Input + suggestion_span: Element + input_wrapper: Element + command_verb_span: TerminalInputVerb + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + super().__init__( + tag_name="div", + parent=parent, + id="terminal-input", + style=""" + display: flex; + flex-direction: row; + align-items: center; + flex-shrink: 0; + """, + ) + self.class_name = "terminal-input" + self.text = "$ " + + self.input_wrapper = self._make_input_wrapper() + self.suggestion_span = self._make_suggestion_span() + self.suggestion_span.class_name = "suggestion-span" + self.suggestion_span.text = "" + + self.command_verb_span = TerminalInputVerb( + parent=self.input_wrapper, + style=""" + background-color: transparent; + font-family: monospace; + position: absolute; + left: 0; + top: 0; + font-size: 1rem; + pointer-events: none; + white-space: pre; + """, + ) + + self.text_input = self._make_text_input() + self.text_input.class_name = "terminal-text-input" + + def set_value(self, value: str) -> None: + """Set the value of the text input.""" + self.text_input["value"] = value + command = re.search(r"^(\s*)(\S+\b)", value) + if not command: + self.command_verb_span.set_text("", "") + return + self.command_verb_span.set_text(command.group(1), command.group(2)) + + def _make_input_wrapper(self) -> Element: + return Element( + "div", + parent=self, + style=""" + position: relative; + flex-grow: 1; + width: 100%; + display: flex; + align-items: center; + """, + ) + + def _make_suggestion_span(self) -> Element: + return Element( + "span", + parent=self.input_wrapper, + style=""" + background-color: transparent; + color: var(--terminal-suggestion-color); + font-family: monospace; + position: absolute; + left: 0; + top: 0; + width: 100%; + font-size: 1rem; + pointer-events: none; + white-space: pre; + """, + id="suggestion-span", + ) + + def _make_text_input(self) -> Input: + return Input( + parent=self.input_wrapper, + id="terminal-text-input", + style=""" + width: 100%; + font-family: monospace; + background-color: transparent; + color: var(--terminal-input-color); + border: 0; + outline: 0; + margin: 0; + padding: 0; + font-size: 1rem; + position: relative; + z-index: 2; + """, + autocomplete="off", + ) + + def set_suggestion(self, suggestion: str | None) -> None: + """Set the suggestion for the text input.""" + if not suggestion: + self.suggestion_span.text = "" + return + self.suggestion_span.text = suggestion diff --git a/laudatory-larkspurs/src/gui/components/terminal_io.py b/laudatory-larkspurs/src/gui/components/terminal_io.py new file mode 100644 index 00000000..728dd387 --- /dev/null +++ b/laudatory-larkspurs/src/gui/components/terminal_io.py @@ -0,0 +1,95 @@ +"""Terminal input and output components for the terminal GUI.""" + +import re + +from gui.element import Element, HTMLElement + + +class UserInput(Element): + """A user input command element for the terminal GUI, part of the terminal history.""" + + def __init__(self, text: str, parent: HTMLElement | Element | None = None) -> None: + super().__init__(tag_name="div", parent=parent) + self.class_name = "user-input" + self.text = "$ " + + command_verb_span = Element(tag_name="span") + command_verb_span.class_name = "command-verb-span" + + command_verb = re.search(r"^(\s*)(\S+)\b", text) + + if command_verb: + self.text += command_verb.group(1) + + command_verb_span.text = command_verb.group(2) + self.append_child(command_verb_span) + + command_end = text[len(command_verb.group(0)) :] + command_end_span = Element(tag_name="span") + command_end_span.text = command_end + self.append_child(command_end_span) + else: + self.text += text + + +class TerminalOutput(Element): + """A terminal output element for displaying command results in the terminal GUI.""" + + def __init__(self, text: str, color: str | None = None, parent: HTMLElement | Element | None = None) -> None: + style = f"--terminal-output-color: {color};" if color is not None else "" + super().__init__(tag_name="div", parent=parent, style=style) + self.class_name = "terminal-output" + self.text = text + + +class TerminalInputVerb(Element): + """A terminal input verb element for the terminal GUI. This is used to display the command verb in the input.""" + + def __init__(self, parent: HTMLElement | Element | None = None, style: str | None = None) -> None: + super().__init__(tag_name="span", parent=parent, style=style) + self.class_name = "terminal-input-verb" + + self.wrapper = Element( + tag_name="span", + parent=self, + style=""" + position: relative; + display: inline-block; + flex-shrink: 0; + """, + ) + + self.space_span = Element(tag_name="span", parent=self.wrapper) + self.space_span.class_name = "terminal-input-space" + + self.verb_span = Element(tag_name="span", parent=self.wrapper) + self.verb_span.class_name = "terminal-input-verb-text" + + def set_text(self, start_space: str, verb: str) -> None: + """Set the text of the terminal input verb. The space before the verb is used to align the underlined verb.""" + self.space_span.text = " " * len(start_space) + self.verb_span.text = " " * len(verb) + + +class TerminalHistory(Element): + """A terminal history element for the terminal GUI.""" + + _elements: list[UserInput | TerminalOutput] + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + super().__init__(tag_name="div", parent=parent, id="terminal-history") + self._elements = [] + self.class_name = "terminal-history" + + def add_history(self, element: UserInput | TerminalOutput) -> None: + """Add a terminal output or user input element to the history.""" + self._elements.append(element) + self.append_child(element) + if self["parentElement"] is not None: + self["parentElement"].scrollTop = self["parentElement"].scrollHeight + + def clear_history(self) -> None: + """Clear the terminal history.""" + for element in self._elements: + self.remove_child(element) + self._elements.clear() diff --git a/laudatory-larkspurs/src/gui/element.py b/laudatory-larkspurs/src/gui/element.py new file mode 100644 index 00000000..3b8df109 --- /dev/null +++ b/laudatory-larkspurs/src/gui/element.py @@ -0,0 +1,179 @@ +"""Defines GUI elements.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from collections.abc import Callable + +import js # type: ignore[import] +from pyodide.ffi import create_proxy + +# Note: Disregarding N815, N802, and ANN401 are +# done for classes and functions that directly +# interact with or reflect the JS API + + +class HTMLElement(Protocol): + """Define a protocol for HTML elements to allow for type checking. This is a subset of the DOM API.""" + + id: str + textContent: str # noqa: N815 + innerHTML: str # noqa: N815 + value: str + style: Any + className: str # noqa: N815 + + def appendChild(self, child: HTMLElement) -> None: # noqa: N802 + """Append a child element to this element.""" + ... + + def removeChild(self, child: HTMLElement) -> None: # noqa: N802 + """Remove a child element from this element.""" + ... + + def addEventListener(self, event: str, handler: Callable) -> None: # noqa: N802 + """Add an event listener to this element.""" + ... + + def setAttribute(self, name: str, value: str) -> None: # noqa: N802 + """Set an attribute on this element.""" + ... + + +class Element: + """Base class for all GUI elements. This is a wrapper around an HTML element. + + Element provides several convenience methods for accessing the underlying HTML element. + Getting or setting an item will access the underlying HTML element directly. + + ```py + element = Element("div") + element["style"].color = "red" + ``` + + If a convenience method exists on Element, it should be used instead of accessing the + underlying HTML element directly. + """ + + _html_element: HTMLElement + + def __init__( + self, + tag_name: str | None = None, + *, + element: HTMLElement | None = None, + parent: HTMLElement | Element | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + if element is not None: + # Initialize from an existing HTML element + if tag_name is not None: + msg = "Cannot specify both element and tag name" + raise ValueError(msg) + if parent is not None: + msg = "Cannot specify both element and parent" + raise ValueError(msg) + if kwargs: + msg = "Cannot specify both element and kwargs" + raise ValueError(msg) + self._html_element = element + return + + self._html_element = js.document.createElement(tag_name) + + for key, value in kwargs.items(): + self._html_element.setAttribute(key, value) + + if isinstance(parent, Element): + root = parent.html_element + elif parent is None: + root = js.document.body + else: + root = parent + + root.appendChild(self.html_element) + + @property + def html_element(self) -> HTMLElement: + """Get the underlying HTML element. This is a read-only property.""" + return self._html_element + + def on(self, event: str, handler: Callable[[Any], None]) -> None: + """Add an event handler to the element. The handler will be called with the element as the first argument.""" + + def event_handler(event: Any) -> None: # noqa: ANN401 + handler(event) + + self.html_element.addEventListener(event, create_proxy(event_handler)) + + @property + def text(self) -> str: + """Get or set the text content of the element.""" + return self.html_element.textContent + + @text.setter + def text(self, value: str) -> None: + self.html_element.textContent = value + + @property + def html(self) -> str: + """Get or set the HTML content of the element.""" + return self.html_element.innerHTML + + @html.setter + def html(self, value: str) -> None: + """Set the HTML content of the element.""" + self.html_element.innerHTML = value + + @property + def class_name(self) -> str: + """Get or set the class name of the element.""" + return self.html_element.className + + @class_name.setter + def class_name(self, value: str) -> None: + self.html_element.className = value + + def append_child(self, child: Element) -> None: + """Append a child element to this element.""" + self.html_element.appendChild(child.html_element) + + def remove_child(self, child: Element) -> None: + """Remove a child element from this element.""" + self.html_element.removeChild(child.html_element) + + def __getitem__(self, key: str) -> Any: # noqa: ANN401 + """Get an attribute on the underlying HTML element.""" + return getattr(self.html_element, key) + + def __setitem__(self, key: str, value: Any) -> None: # noqa: ANN401 + """Set an attribute on the underlying HTML element.""" + setattr(self.html_element, key, value) + + +class Button(Element): + """A button element.""" + + def __init__(self, parent: HTMLElement | Element | None = None, **kwargs: Any) -> None: # noqa: ANN401 + super().__init__("button", parent=parent, **kwargs) + + def on_click(self, handler: Callable[[Any], None]) -> None: + """Add a click event handler to the button.""" + self.on("click", handler) + + +class Input(Element): + """An input element.""" + + def __init__(self, parent: HTMLElement | Element | None = None, **kwargs: Any) -> None: # noqa: ANN401 + super().__init__("input", parent=parent, **kwargs) + + def on_change(self, handler: Callable[[Any], None]) -> None: + """Add a change event handler to the input.""" + self.on("change", handler) + + def on_input(self, handler: Callable[[Any], None]) -> None: + """Add an input event handler to the input.""" + self.on("input", handler) diff --git a/laudatory-larkspurs/src/gui/layout.py b/laudatory-larkspurs/src/gui/layout.py new file mode 100644 index 00000000..a304ba08 --- /dev/null +++ b/laudatory-larkspurs/src/gui/layout.py @@ -0,0 +1,94 @@ +from typing import Any + +from gui.components.description import Description +from gui.components.image_preview import ImagePreview +from gui.components.separator import Separator +from gui.components.terminal_gui import TerminalGui +from gui.element import Element, HTMLElement +from image import PaintImage +from terminal import Terminal + + +class Layout(Element): + """Main layout component that manages image preview and terminal sections with a resizable separator. + + --- + + Authors: + - Jont + - Ricky + """ + + def __init__(self, parent: HTMLElement | Element | None = None) -> None: + """Initialize the main layout component. + + Args: + parent: Optional parent element that will contain this layout. If `None`, it becomes a + root component. + --- + + :author: Jont + + """ + super().__init__( + tag_name="div", + parent=parent, + style=""" + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + """, + ) + + self.description = Description(parent=self) + self.image_preview = ImagePreview(parent=self) + self.separator = Separator(parent=self, on_resize=self._handle_resize) + self.terminal_gui = TerminalGui(parent=self) + + image = PaintImage(self.image_preview) + self.image_preview.image = image + + image.load() + + # create a terminal + self.terminal = Terminal(image, self.terminal_gui) + + def _handle_resize(self, mouse_y: int) -> None: + """Handle resizing of the image preview section. + + Args: + mouse_y: Mouse Y position for calculating new height + + --- + + :author: Ricky + + """ + self.image_preview["style"].height = f"{mouse_y}px" + + def handle_global_mouse_up(self, event: Any) -> None: # noqa: ANN401 + """Handle global mouse up event to stop separator dragging. + + Args: + event: Mouse event data triggering the end of the drag. + + --- + + :author: Ricky + + """ + self.separator.handle_mouse_up(event) + + def handle_global_mouse_move(self, event: Any) -> None: # noqa: ANN401 + """Handle global mouse move event for separator dragging. + + Args: + event: Mouse event data triggering the start of the drag. + + --- + + :author: Ricky + + """ + self.separator.handle_mouse_move(event) diff --git a/laudatory-larkspurs/src/hello.py b/laudatory-larkspurs/src/hello.py new file mode 100644 index 00000000..bb2570a5 --- /dev/null +++ b/laudatory-larkspurs/src/hello.py @@ -0,0 +1,12 @@ +"""Testing file.""" + + +def hello() -> str: + """Return hello world string.""" + return "Hello World!" + + +def main() -> None: + """Initialize attachments.py.""" + print("attachment.py main") + print(hello()) diff --git a/laudatory-larkspurs/src/image.py b/laudatory-larkspurs/src/image.py new file mode 100644 index 00000000..495e687b --- /dev/null +++ b/laudatory-larkspurs/src/image.py @@ -0,0 +1,202 @@ +import base64 +import io +import pathlib + +from PIL import Image, ImageDraw + +from gui.components.image_preview import ImagePreview +from utils.color import Color + +IMAGES_DIR = pathlib.Path(__file__).parent.resolve() / "images" + + +class PaintImage: + """Image for creation of image objects. + + :author: Mira + """ + + def __init__(self, image_preview: ImagePreview) -> None: + """Create an image object.""" + self.img_name = "" + self.img = Image.new("RGB", (400, 250), (0, 0, 0)) + self.backupImage = self.img.copy() + self.undo_available = True + self.edits = 0 + self.image_preview = image_preview + + def refresh_image(self) -> None: + """Display edits on screen.""" + self.edits += 1 + self.image_preview.display_image(self.get_js_link()) + + def load(self, image_name: str = "default.png") -> int: + """Load image from images. + + params image_name: name of an image with .ext + return returns 0 if image has loaded 1 if the image wasn't located + """ + if (IMAGES_DIR / image_name).exists(): + self.img = Image.open(IMAGES_DIR / image_name, "r").copy() + self.img_name = image_name + self.edits = -1 + self.refresh_image() + return 0 + return 1 + + def save(self, img_name: str) -> int: + """Save image to images/. + + returns 0 if image has saved 1 if the image wasn't + """ + if not img_name: + print("Can't save an empty image.") + return 1 + self.img.save(IMAGES_DIR / img_name, format="PNG") + self.edits = 0 + return 0 + + def get_js_link(self) -> str: + """Return base64 link for an image file. + + return string - image src link + """ + buf = io.BytesIO() + self.img.save(buf, format="PNG") + data = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:image/png;base64,{data}" + + def load_from_image_link(self, js_link: str) -> None: + """Load image from a base64 image src link (data URL) into self.img. + + js_link: str - base64 data URL like "data:image/png;base64,iVBORw0..." + """ + header, encoded = js_link.split(",", 1) + img_data = base64.b64decode(encoded) + buf = io.BytesIO(img_data) + self.img = Image.open(buf) + self.undo_save() + self.edits = -1 + self.refresh_image() + + def undo(self) -> int: + """Return 0 if chages undone, otherwise 1.""" + if self.undo_available: + self.img = self.backup_image + self.undo_available = False + self.refresh_image() + return 0 + return 1 + + def undo_save(self) -> None: + """Save for undo.""" + self.backup_image = self.img.copy() + self.undo_available = True + + def get_info(self) -> dict[str, tuple[int, int] | str | bool | list | None]: + """Return image information dictionary. + + return dictionary containing basic information including: size, format, colors + """ + return { + "size": self.img.size, + "format": self.img.format, + "edits": self.edits, + "colors": self.img.getcolors(), + } + + def set_pixel(self, x: int, y: int, color: Color) -> None: + """Set an image pixel.""" + self.undo_save() + + draw = ImageDraw.Draw(self.img, "RGBA") + draw.point((x, y), color.rgb) + self.refresh_image() + + def get_pixel(self, x: int, y: int) -> tuple[int, ...]: + """Get an image pixel.""" + return self.img.getpixel((x, y)) + + def fill_rect( # noqa: PLR0913 + self, + x: int, + y: int, + width: int, + height: int, + fill_color: Color | None = None, + outline_color: Color | None = None, + outline_size: int = 0, + ) -> int: + """Fill rectangle on an image. + + return 0 on success 1 on fail + """ + if width <= 0 or height <= 0: + return 1 + + self.undo_save() + + draw = ImageDraw.Draw(self.img, "RGBA") + draw.rectangle( + [x, y, x + width - 1, y + height - 1], + fill=fill_color.rgba if fill_color else None, + outline=outline_color.rgba if outline_color else None, + width=outline_size, + ) + self.refresh_image() + return 0 + + def draw_line(self, x1: int, y1: int, x2: int, y2: int, color: Color) -> int: + """Draw a straight line on the image.""" + self.undo_save() + + draw = ImageDraw.Draw(self.img, "RGBA") + draw.line((x1, y1, x2, y2), fill=color.rgb) + self.refresh_image() + return 0 + + def draw_circle( # noqa: PLR0913 + self, + cx: int, + cy: int, + radius: int, + fill_color: Color | None = None, + outline_color: Color | None = None, + outline_size: int = 0, + ) -> int: + """Draw a circle on the image.""" + self.undo_save() + + draw = ImageDraw.Draw(self.img, "RGBA") + bbox = [cx - radius, cy - radius, cx + radius, cy + radius] + draw.ellipse( + bbox, + fill=fill_color.rgba if fill_color else None, + outline=outline_color.rgba if outline_color else None, + width=outline_size, + ) + self.refresh_image() + return 0 + + def draw_polygon( + self, + points: list[tuple[int, int]], + fill_color: Color | None = None, + outline_color: Color | None = None, + outline_size: int = 0, + ) -> int: + """Fill polygon on an image. + + Return 0 on success 1 on fail + """ + self.undo_save() + + draw = ImageDraw.Draw(self.img, "RGBA") + draw.polygon( + points, + fill=fill_color.rgba if fill_color else None, + outline=outline_color.rgba if outline_color else None, + width=outline_size, + ) + self.refresh_image() + return 0 diff --git a/laudatory-larkspurs/src/images/default.png b/laudatory-larkspurs/src/images/default.png new file mode 100644 index 00000000..5715ad6f Binary files /dev/null and b/laudatory-larkspurs/src/images/default.png differ diff --git a/laudatory-larkspurs/src/main.py b/laudatory-larkspurs/src/main.py new file mode 100644 index 00000000..2d978b80 --- /dev/null +++ b/laudatory-larkspurs/src/main.py @@ -0,0 +1,8 @@ +"""The main entry point for client-side code.""" + +from gui import init_gui + + +def main() -> None: + """Run the client-side Python code. This is the entry point for the browser.""" + init_gui() diff --git a/laudatory-larkspurs/src/terminal.py b/laudatory-larkspurs/src/terminal.py new file mode 100644 index 00000000..998a67af --- /dev/null +++ b/laudatory-larkspurs/src/terminal.py @@ -0,0 +1,162 @@ +from commands import all_commands +from gui.components.terminal_gui import TerminalGui +from image import PaintImage +from utils.color import Color, create_color + +SUCCESS_COLOUR = "var(--terminal-success-color)" +ERROR_COLOUR = "var(--terminal-error-color)" + + +def get_options(args: list[str]) -> tuple[list[str], dict[str, str]]: + """Remove options from a given list of arguments.""" + options_start: int = 0 + while options_start < len(args) and not args[options_start].startswith("--"): + options_start += 1 + + options: dict[str, str] = {} + last_key: str | None = None + for arg in args[options_start:]: + if arg.startswith("--"): + last_key = arg[2:] + options[last_key] = "" + continue + if options[last_key] != "": + options[last_key] += " " + options[last_key] += arg + return args[:options_start], options + + +class Terminal: + """Terminal manages a custom command environment. + + @author Philip + """ + + foreground_color = Color(255, 255, 255) + background_color = Color(0, 0, 0) + + def __init__(self, image: PaintImage, display: TerminalGui) -> None: + self.image = image + + self.terminal_display = display + display.terminal = self + + def run_str(self, command_str: str) -> bool: + """Parse and then run the given command. + + :param command_str: String of command to be executed + :return: success of command execution + + @author Philip + """ + if command_str.strip() == "": + return False + + command: str + args: list[str] + command, *args = command_str.strip().split() + + options: dict[str, str | Color] + args, options = get_options(args) + + if command in all_commands: + command_obj = all_commands[command] + else: + self.output_error(f"`{command}` is not a valid command.") + self.output_error("use `help` to see list of available commands`") + return False + + invalid_options: tuple[str, ...] = tuple( + option for option in options if option not in command_obj.known_options + ) + + if any(option not in command_obj.known_options for option in options): + self.output_error(f"{invalid_options} are not a valid option(s) for the command.") + return False + + try: + if "fg" in command_obj.known_options: + if "fg" in options: + options["fg"] = create_color(options["fg"]) + else: + options["fg"] = self.foreground_color + if "bg" in command_obj.known_options: + if "bg" in options: + options["bg"] = create_color(options["bg"]) + else: + options["bg"] = self.background_color + + except ValueError as e: + self.output_error(e.args[0]) + return False + + command_obj(self, *args, **options) + + return True + + def predict_command(self, command_str: str) -> str | None: + """Predicts the command and arguments the user is typing. + + Argument handling is offloaded to commands predict_args. + + :param command_str: Currently typed text in terminal. + :return: The full predicted command with next argument. Returns None on error. + + @author Philip + """ + if command_str.strip() == "": + return "" + + command, *args = command_str.strip().split() + + args, options = get_options(args) + + if command in all_commands: + output = command + prediction = all_commands[command].predict_args(self, *args, **options) + if prediction is None: + return None + if prediction == "": + return command_str + if not prediction.startswith(" "): + args.pop() + prediction = " " + prediction + if args: + output += " " + " ".join(args) + output += prediction + return output + + for full_command in all_commands: + if full_command.startswith(command): + return full_command + return None + + def output_info(self, output: str) -> None: + """Output the given input to the display with `info_colour`. + + :param output: Text to be printed + :return: None + + @authors Philip + """ + self.terminal_display.print_terminal_output(output) + + def output_success(self, output: str) -> None: + """Output the given input to the display with `success_colour`. + + :param output: Text to be printed + :return: None + + @author Philip + """ + self.terminal_display.print_terminal_output(output, SUCCESS_COLOUR) + + def output_error(self, output: str) -> None: + """Output the given input to the display with `error_colour`. + + :param output: Text to be printed + :return: None + + @author Philip + """ + self.terminal_display.print_terminal_output(output, ERROR_COLOUR) diff --git a/laudatory-larkspurs/src/utils/__init__.py b/laudatory-larkspurs/src/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/laudatory-larkspurs/src/utils/color.py b/laudatory-larkspurs/src/utils/color.py new file mode 100644 index 00000000..500383eb --- /dev/null +++ b/laudatory-larkspurs/src/utils/color.py @@ -0,0 +1,195 @@ +import re +from colorsys import hsv_to_rgb +from dataclasses import dataclass +from math import atan2, degrees, sqrt + +import webcolors + + +@dataclass +class Color: + """Color class. + + Has various conversion and output methods. + """ + + r: int + g: int + b: int + a: int = 255 + + @property + def rgb(self) -> tuple[int, int, int]: + """Output as RGB tuple.""" + return self.r, self.g, self.b + + @property + def rgba(self) -> tuple[int, int, int, int]: + """Output as RGBA tuple.""" + return self.r, self.g, self.b, self.a + + @property + def hex(self) -> str: + """Output as hexadecimal string.""" + return f"#{self.r:02x}{self.g:02x}{self.b:02x}{self.a:02x}" + + @property + def hsv(self) -> tuple[float, float, float]: + """Output as tuple in HSV color space.""" + mx = max(self.r, self.g, self.b) + mn = min(self.r, self.g, self.b) + df = mx - mn + h = 0 + if mx == mn: + h = 0 + elif mx == self.r: + h = (60 * ((self.g - self.b) / df) + 360) % 360 + elif mx == self.g: + h = (60 * ((self.b - self.r) / df) + 120) % 360 + elif mx == self.b: + h = (60 * ((self.r - self.g) / df) + 240) % 360 + s = 0 if mx == 0 else df / mx + v = mx + return h, s * 100, v / 255 + + @property + def xyz(self) -> tuple[float, float, float]: + """Output as tuple in XYZ color space.""" + r, g, b = (self.r / 255.0, self.g / 255.0, self.b / 255.0) + r = r / 12.92 if r <= 0.04045 else ((r + 0.055) / 1.055) ** 2.4 + g = g / 12.92 if g <= 0.04045 else ((g + 0.055) / 1.055) ** 2.4 + b = b / 12.92 if b <= 0.04045 else ((b + 0.055) / 1.055) ** 2.4 + r, g, b = r * 100, g * 100, b * 100 + x = r * 0.4124 + g * 0.3576 + b * 0.1805 + y = r * 0.2126 + g * 0.7152 + b * 0.0722 + z = r * 0.0193 + g * 0.1192 + b * 0.9505 + return x, y, z + + @property + def lab(self) -> tuple[float, float, float]: + """Output as tuple in LAB color space.""" + x, y, z = [value / ref for value, ref in zip(self.xyz, (95.047, 100.000, 108.883), strict=False)] + x = x ** (1 / 3) if x > 0.008856 else (7.787 * x) + (16 / 116) + y = y ** (1 / 3) if y > 0.008856 else (7.787 * y) + (16 / 116) + z = z ** (1 / 3) if z > 0.008856 else (7.787 * z) + (16 / 116) + l = (116 * y) - 16 + a = 500 * (x - y) + b = 200 * (y - z) + return l, a, b + + @property + def lch(self) -> tuple[float, float, float]: + """Output as tuple in LCH color space.""" + l, a, b = self.lab + c = sqrt(a**2 + b**2) + h = degrees(atan2(b, a)) + h = h + 360 if h < 0 else h + return l, c, h + + +def create_color(color_string: str) -> Color: + """Create a color object from string. + + This string can be hex, name of color or rgb integers separated by comma or space + + @author Philip + """ + try: + return Color(*webcolors.hex_to_rgb(color_string)) + except ValueError: + pass + try: + return Color(*webcolors.name_to_rgb(color_string)) + except ValueError: + pass + + color_string = color_string.lower() + + sep = r"(?:, |,| )" + match = re.search(rf"^(\d+){sep}(\d+){sep}(\d+)(?:{sep}(\d+))?$", color_string) or re.search( + rf"^rgba?\((\d+){sep}(\d+){sep}(\d+)(?:{sep}(\d+))?\)$", + color_string, + ) + if match: + return rgb_factory(*(int(i) for i in match.groups() if i is not None)) + + match = re.search(rf"^hsva?\((\d+){sep}(\d+){sep}(\d+)(?:{sep}(\d+))?\)$", color_string) + if match: + return hsv_factory(*(int(i) for i in match.groups() if i is not None)) + + msg = f"Invalid color: {color_string}" + raise ValueError(msg) + + +def rgb_factory(r: int, g: int, b: int, a: int | None = None) -> Color: + """Create Color from rgb values with checks.""" + if r < 0 or r > 255: + msg = f"r must be between 0 and 255: {r}" + raise ValueError(msg) + + if g < 0 or g > 255: + msg = f"g must be between 0 and 255: {g}" + raise ValueError(msg) + + if b < 0 or b > 255: + msg = f"b must be between 0 and 255: {b}" + raise ValueError(msg) + + if a and (a < 0 or a > 255): + msg = f"a must be between 0 and 255: {a}" + raise ValueError(msg) + + return Color(r, g, b, a if a else 255) + + +def hsv_factory(h: int, s: int, v: int, a: int | None = None) -> Color: + """Create Color from hsv values with checks.""" + if h < 0 or h > 360: + msg = f"h must be between 0 and 360: {h}" + raise ValueError(msg) + h /= 360 + + if s < 0 or s > 100: + msg = f"s must be between 0 and 100: {s}" + raise ValueError(msg) + s /= 100 + + if v < 0 or v > 100: + msg = f"v must be between 0 and 100: {v}" + raise ValueError(msg) + v /= 100 + + if a and (a < 0 or a > 255): + msg = f"a must be between 0 and 255: {a}" + raise ValueError(msg) + + r, g, b = hsv_to_rgb(h, s, v) + r *= 255 + g *= 255 + b *= 255 + + return Color(int(r), int(g), int(b), a if a else 255) + + +colors = { + "black": Color(0, 0, 0), + "white": Color(255, 255, 255), + "red": Color(255, 0, 0), + "green": Color(0, 255, 0), + "blue": Color(0, 0, 255), + "yellow": Color(255, 255, 0), + "cyan": Color(0, 255, 255), + "magenta": Color(255, 0, 255), + "gray": Color(128, 128, 128), + "maroon": Color(128, 0, 0), + "olive": Color(128, 128, 0), + "purple": Color(128, 0, 128), + "teal": Color(0, 128, 128), + "navy": Color(0, 0, 128), + "silver": Color(192, 192, 192), + "lime": Color(0, 255, 0), + "orange": Color(255, 165, 0), + "brown": Color(165, 42, 42), + "pink": Color(255, 192, 203), + "gold": Color(255, 215, 0), +} diff --git a/laudatory-larkspurs/uv.lock b/laudatory-larkspurs/uv.lock new file mode 100644 index 00000000..63964d9c --- /dev/null +++ b/laudatory-larkspurs/uv.lock @@ -0,0 +1,265 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "command-line-image-editor" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pillow" }, + { name = "pyodide-py", version = "0.27.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "pyodide-py", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pillow" }, + { name = "pre-commit" }, + { name = "pyodide-py", version = "0.27.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "pyodide-py", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pillow", specifier = "~=11.3.0" }, + { name = "pyodide-py", specifier = ">=0.27.7" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pillow", specifier = "~=11.3.0" }, + { name = "pre-commit", specifier = "~=4.2.0" }, + { name = "pyodide-py", specifier = ">=0.27.7" }, + { name = "ruff", specifier = "~=0.12.2" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyodide-py" +version = "0.27.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/99/d7b3c9137de5a76a63f2ef89d43c878dcb4dce8118866fb58d26290698f9/pyodide_py-0.27.7.tar.gz", hash = "sha256:afb68f8abf503f691a4ab5d2ffdbf6dd05117920508e1161e04a34737c649d36", size = 52051, upload-time = "2025-06-05T04:10:49.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c5/825f73fb815a17838bef3342999247bea1100a0b2e576e5c17b2bdd68766/pyodide_py-0.27.7-py3-none-any.whl", hash = "sha256:2fa7db63a14720e548eb6174d492643424f8b5f21d43b7c9fecb6d712187fe6a", size = 57930, upload-time = "2025-06-05T04:10:48.789Z" }, +] + +[[package]] +name = "pyodide-py" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bdb623dc6b1165c906b958b25f81ea2b0ba2f06ded5e491d272f9a54c35d/pyodide_py-0.28.1.tar.gz", hash = "sha256:11464c6e0e3063e7c0bfc2802f53c74f75fb0834190b86bb78769f84c635a18e", size = 52569, upload-time = "2025-08-04T21:31:55.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/f9/a2533ac6831763c36dd1f4cb6ba9eebb8f41b1880be9e89e3b5994c6c1f8/pyodide_py-0.28.1-py3-none-any.whl", hash = "sha256:e158e58da4cee77d1dc825bdeddd689335a41b8a79b4fa4483a0f3c50e0454e7", size = 58478, upload-time = "2025-08-04T21:31:54.07Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" }, + { url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" }, + { url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" }, + { url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" }, + { url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/60/4f20960df6c7b363a18a55ab034c8f2bcd5d9770d1f94f9370ec104c1855/virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8", size = 6082160, upload-time = "2025-08-05T16:10:55.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ff/ded57ac5ff40a09e6e198550bab075d780941e0b0f83cbeabd087c59383a/virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67", size = 6060362, upload-time = "2025-08-05T16:10:52.81Z" }, +]