From 4bf76d1c1bed37d41a5b70e84f2f128df71595d6 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Wed, 6 Aug 2025 00:41:45 -0700 Subject: [PATCH 001/196] Initial commit --- .github/workflows/lint.yaml | 35 +++++++ .gitignore | 31 ++++++ .pre-commit-config.yaml | 18 ++++ LICENSE.txt | 7 ++ README.md | 186 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 63 ++++++++++++ samples/Pipfile | 15 +++ samples/pyproject.toml | 19 ++++ 8 files changed, 374 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 samples/Pipfile create mode 100644 samples/pyproject.toml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/.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/.gitignore b/.gitignore new file mode 100644 index 00000000..233eb87e --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c0a8de23 --- /dev/null +++ b/.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/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..5a04926b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Python Discord + +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/README.md b/README.md new file mode 100644 index 00000000..3bf4bfba --- /dev/null +++ b/README.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/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6a232d06 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[project] +# This section contains metadata about your project. +# Don't forget to change the name, description, and authors to match your project! +name = "code-jam-template" +description = "Add your description here" +authors = [ + { name = "Your Name" } +] +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[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 = [ + "pre-commit~=4.2.0", + "ruff~=0.12.2", +] + +[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", +] diff --git a/samples/Pipfile b/samples/Pipfile new file mode 100644 index 00000000..dedd0c50 --- /dev/null +++ b/samples/Pipfile @@ -0,0 +1,15 @@ +# Sample Pipfile. + +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +ruff = "~=0.12.2" +pre-commit = "~=4.2.0" + +[requires] +python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml new file mode 100644 index 00000000..b486e506 --- /dev/null +++ b/samples/pyproject.toml @@ -0,0 +1,19 @@ +# Sample poetry configuration. + +[tool.poetry] +name = "Name" +version = "0.1.0" +description = "Description" +authors = ["Author 1 "] +license = "MIT" + +[tool.poetry.dependencies] +python = "3.12.*" + +[tool.poetry.dev-dependencies] +ruff = "~0.12.2" +pre-commit = "~4.2.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" From 56af5a05b7e55435061f4c5fd086cdbae39c55fa Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:30:01 -0700 Subject: [PATCH 002/196] Remove tool.ruff.target-version from pyproject.toml --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a232d06..21d66983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ authors = [ ] version = "0.1.0" readme = "README.md" +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. requires-python = ">=3.12" dependencies = [] @@ -25,9 +27,6 @@ dev = [ # 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 From c0133c82e1c777b3ae48bb89b0d7541d8199eb50 Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 18:12:39 -0700 Subject: [PATCH 003/196] Update LICENSE.txt with team GitHub names and year --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 5a04926b..e70f173b 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2021 Python Discord +Copyright 2025 Mannyvv, afx8732, enskyeing, husseinhirani, jks85, MeGaGiGaGon 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: From fbb73606521561fc197574894d4d8bc2d8be1e18 Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 18:39:27 -0700 Subject: [PATCH 004/196] Delete samples directory --- samples/Pipfile | 15 --------------- samples/pyproject.toml | 19 ------------------- 2 files changed, 34 deletions(-) delete mode 100644 samples/Pipfile delete mode 100644 samples/pyproject.toml diff --git a/samples/Pipfile b/samples/Pipfile deleted file mode 100644 index dedd0c50..00000000 --- a/samples/Pipfile +++ /dev/null @@ -1,15 +0,0 @@ -# Sample Pipfile. - -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -[dev-packages] -ruff = "~=0.12.2" -pre-commit = "~=4.2.0" - -[requires] -python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml deleted file mode 100644 index b486e506..00000000 --- a/samples/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Sample poetry configuration. - -[tool.poetry] -name = "Name" -version = "0.1.0" -description = "Description" -authors = ["Author 1 "] -license = "MIT" - -[tool.poetry.dependencies] -python = "3.12.*" - -[tool.poetry.dev-dependencies] -ruff = "~0.12.2" -pre-commit = "~4.2.0" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" From 2f919f1a046ae5c81161668a5d0e920364c47700 Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 18:57:06 -0700 Subject: [PATCH 005/196] Update pyproject.toml with pinned nicegui dependency --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 21d66983..e744ec63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,9 @@ readme = "README.md" # Target Python 3.12. If you decide to use a different version of Python # you will need to update this value. requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "nicegui~=2.22.2", +] [dependency-groups] # This `dev` group contains all the development requirements for our linting toolchain. From b5f17892c2bb128467fc6b15cbe47784368267a1 Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 19:09:24 -0700 Subject: [PATCH 006/196] Update .gitignore with uv.lock --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 233eb87e..5c4b2d11 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ build/ .vscode/ # MacOS .DS_Store + +# Since uv is not required for the project and all dependancies are pinned, +# ignore the lock file for the convenience of people using uv +uv.lock From 25cc0f7daaf444f5943be3f3e94bb0dce1e6edd4 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sat, 9 Aug 2025 21:19:09 -0500 Subject: [PATCH 007/196] add our names to pyproject.toml --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e744ec63..62cb9306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,12 @@ name = "code-jam-template" description = "Add your description here" authors = [ - { name = "Your Name" } + { name = "Mannyvv" }, + { name = "afx8732" }, + { name = "enskyeing" }, + { name = "husseinhirani" }, + { name = "jks85" }, + { name = "MeGaGiGaGon" }, ] version = "0.1.0" readme = "README.md" From 67350d8d0a84551403a720b25f1db73b27fb9ac6 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 19:22:33 -0700 Subject: [PATCH 008/196] Create main.py with basic NiceGUI template --- src/main.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..56ad82ae --- /dev/null +++ b/src/main.py @@ -0,0 +1,5 @@ +from nicegui import ui + +ui.label("Hello NiceGUI!") + +ui.run() From aae1f53e7467e93a1c663895dc4bbf6383e7e7cf Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 20:15:17 -0700 Subject: [PATCH 009/196] Rename template README.md, keeping it for now to reference --- README.md => README-template.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README.md => README-template.md (100%) diff --git a/README.md b/README-template.md similarity index 100% rename from README.md rename to README-template.md From deefc35ceff6b7f04dcdad3d1fb8f36b87718d9c Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 9 Aug 2025 21:16:31 -0700 Subject: [PATCH 010/196] Create new readme.md --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..2436b130 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Monumental Monsteras CJ25 Project +Monumental Monsteras CJ25 Project is a typing speed test, +but with a twist: You cannot type with a normal keyboard. +You can only use the **wrong tool for the job**. + +Try different wrong methods of writing text, with a score at +the end if you would like to flex on your friends. + +Input methods: + +# Running the project +## Using `uv` (recommended) + +The recommended way to run the project is using `uv`. + +If you do not have `uv` installed, see https://docs.astral.sh/uv/getting-started/installation/ + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ uv run src/main.py +``` + +## Without `uv` + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ py -3.12 -m venv .venv +$ py -m pip install . +$ py src/main.py +``` + +# Contributing +## Setting up the project for development +### Using `uv` (recommended) +``` +$ uvx pre-commit install +``` + +### Without `uv` +If you do not have `pre-commit` installed, see https://pre-commit.com/#installation +``` +$ pre-commit install +``` + +## Development process +If the change you are making is large, open a new +issue and self-assign to make sure no duplicate work is done. + +When making a change: +1. Make a new branch on the main repository +2. Make commits to the branch +3. Open a PR from that branch to main + +You can run the pre-commit checks locally with: +``` +$ pre-commit run -a +``` From cc9f86871f1c6cad1c3e55fd10e97eaf12ad2789 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 07:42:31 -0700 Subject: [PATCH 011/196] Create new folder and __init__ for rpg-style input. Name may have to be changed later. --- src/rpg_text_input/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/rpg_text_input/__init__.py diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py new file mode 100644 index 00000000..e69de29b From 7459079c8d6733480f746365be0eb3d244bebf2c Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 07:47:45 -0700 Subject: [PATCH 012/196] Add basic page for the RPG style input to make sure other pages work. Currently requires a noqa since the action of importing causes the @ui.pages decorator to make the page, but that makes F401 (unused import) unhappy. --- src/main.py | 3 +++ src/rpg_text_input/__init__.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/main.py b/src/main.py index 56ad82ae..e308c88e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,8 @@ from nicegui import ui +# We probably want to figure out a more clean way to do this without the noqa. +import rpg_text_input as _ # noqa: F401 Importing creates the subpage. + ui.label("Hello NiceGUI!") ui.run() diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index e69de29b..204e3e09 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -0,0 +1,6 @@ +from nicegui import ui + + +@ui.page("/controller") +def sub_page(): + ui.label("test") From f125dac01b9bb065a98549c7e2c67cea89f9ed8f Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 07:58:07 -0700 Subject: [PATCH 013/196] Add basic on-screen keyboard rendering to RPG style input --- src/rpg_text_input/__init__.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 204e3e09..16b9b5b8 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -1,6 +1,34 @@ +from dataclasses import dataclass, field + from nicegui import ui +@dataclass +class Keyboard: + """On-screen keyboard input method.""" + + keys: tuple[str, ...] = ( + "ABCDEFGabcdefg", + "HIJKLMNhijklmn", + "OPQRSTUopqrstu", + "VWXYZ. vwxyz,\N{SQUARED OK}", + ) + + position: list[int] = field(default_factory=lambda: [0, 0]) + + def render(self) -> None: + """Render the keyboard to the page.""" + with ui.grid(columns=len(self.keys[0])): + for row_index, row in enumerate(self.keys): + for col_index, char in enumerate(row): + label = ui.label(char if char != "`" else "") + label.style("text-align: center") + if [col_index, row_index] == self.position: + label.style("background-color: lightblue") + + @ui.page("/controller") -def sub_page(): - ui.label("test") +def rpg_text_input_page() -> None: + """Page for the RPG style keyboard.""" + keyboard = Keyboard() + keyboard.render() From 31f037c219b21d3877168237c5582c80c9289ee9 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:06:09 -0700 Subject: [PATCH 014/196] Add selector movement to RPG style input --- src/rpg_text_input/__init__.py | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 16b9b5b8..5d68621c 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -1,6 +1,27 @@ from dataclasses import dataclass, field from nicegui import ui +from nicegui.events import KeyEventArguments + + +def wrap_to_range(num: int, num_min: int, num_max: int) -> int: + """Ensure num is in the half-open interval [min, max), wrapping as needed. Min must be less than max. + + Returns: + The input num wrapped to the given range. + + Raises: + ValueError: If min is greater than or equal to max. + + """ + if num_min < num_max: + msg = f"Wrapping doesn't make sense if min >= max, got {num_min=} and {num_max=}." + raise ValueError(msg) + while num < num_min: + num += num_max + while num >= num_max: + num -= num_max + return num @dataclass @@ -16,6 +37,7 @@ class Keyboard: position: list[int] = field(default_factory=lambda: [0, 0]) + @ui.refreshable_method def render(self) -> None: """Render the keyboard to the page.""" with ui.grid(columns=len(self.keys[0])): @@ -26,9 +48,32 @@ def render(self) -> None: if [col_index, row_index] == self.position: label.style("background-color: lightblue") + def move(self, x: int, y: int) -> None: + """Move the keyboard selected character in the given directions.""" + self.position[0] = wrap_to_range(self.position[0] + x, 0, len(self.keys[0])) + self.position[1] = wrap_to_range(self.position[1] + y, 0, len(self.keys)) + self.render.refresh() + @ui.page("/controller") def rpg_text_input_page() -> None: """Page for the RPG style keyboard.""" keyboard = Keyboard() keyboard.render() + + def handle_key(e: KeyEventArguments) -> None: + """Input handler for the RPG style keyboard.""" + if not e.action.keydown: + return + + # Done using a for loop to minimize copy/paste errors + for key_codes, direction in ( + ({"KeyW", "ArrowUp"}, (0, -1)), + ({"KeyS", "ArrowDown"}, (0, 1)), + ({"KeyA", "ArrowLeft"}, (-1, 0)), + ({"KeyD", "ArrowRight"}, (1, 0)), + ): + if e.key.code in key_codes: + keyboard.move(*direction) + + ui.keyboard(on_key=handle_key) From e7c991a4bd5d944e8636c8f7b5956341d6494fb2 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:09:10 -0700 Subject: [PATCH 015/196] Add template input sending to RPG style input --- src/rpg_text_input/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 5d68621c..c9a416e4 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -54,6 +54,12 @@ def move(self, x: int, y: int) -> None: self.position[1] = wrap_to_range(self.position[1] + y, 0, len(self.keys)) self.render.refresh() + def send_selected(self) -> None: + """Send the selected character to the input view.""" + print( + f"Selected {self.keys[self.position[1]][self.position[0]]!r}", + ) # TODO(GiGaGon): Add communication with input view once it exists + @ui.page("/controller") def rpg_text_input_page() -> None: @@ -76,4 +82,7 @@ def handle_key(e: KeyEventArguments) -> None: if e.key.code in key_codes: keyboard.move(*direction) + if e.key.code in {"Space", "Enter"}: + keyboard.send_selected() + ui.keyboard(on_key=handle_key) From 2112f5c7f73c4e3eb571b9eac9f2fc8ce2ef4f52 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:16:17 -0700 Subject: [PATCH 016/196] Fix logic error in movement wrapping in RPG style input --- src/rpg_text_input/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index c9a416e4..fe126373 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -5,7 +5,7 @@ def wrap_to_range(num: int, num_min: int, num_max: int) -> int: - """Ensure num is in the half-open interval [min, max), wrapping as needed. Min must be less than max. + """Ensure num is in the half-open interval [min, max), wrapping as needed. Returns: The input num wrapped to the given range. @@ -14,7 +14,7 @@ def wrap_to_range(num: int, num_min: int, num_max: int) -> int: ValueError: If min is greater than or equal to max. """ - if num_min < num_max: + if num_min >= num_max: msg = f"Wrapping doesn't make sense if min >= max, got {num_min=} and {num_max=}." raise ValueError(msg) while num < num_min: From a8bcce4c1874c6ba966feb2bdb68da80aeef4e00 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:21:30 -0700 Subject: [PATCH 017/196] Improve keyboard documentation and add post_init for internal invariants to RPG style input --- src/rpg_text_input/__init__.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index fe126373..82d08fe6 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -26,7 +26,17 @@ def wrap_to_range(num: int, num_min: int, num_max: int) -> int: @dataclass class Keyboard: - """On-screen keyboard input method.""" + r"""A RPG-style keyboard where characters are selected by navigating with wasd/the arror keys. + + Positions are stored internally as (col, row). + + In the default keys list, \N{Squared Ok} is intended for once typing is complete, + having to be pressed to complete the challenge. + + Raises: + ValueError: If input keys is non-rectangular (jagged) or starting position is outside keys. + + """ keys: tuple[str, ...] = ( "ABCDEFGabcdefg", @@ -37,6 +47,25 @@ class Keyboard: position: list[int] = field(default_factory=lambda: [0, 0]) + def __post_init__(self) -> None: + if not self.keys: + msg = "Keyboard keys must not be empty." + raise ValueError(msg) + first_row_len = len(self.keys[0]) + for row in self.keys[1:]: + if len(row) != first_row_len: + msg = ( + "All rows must be the same length, got" + f" {row!r} with length {len(row)} while expecting {first_row_len}." + ) + raise ValueError(msg) + if not (0 <= self.position[0] < len(self.keys[0])) or not (0 <= self.position[1] < len(self.keys)): + msg = ( + f"Starting position {self.position} is outside bounds" + f" (0, 0) to ({len(self.keys[0]) - 1}, {len(self.keys) - 1})" + ) + raise ValueError(msg) + @ui.refreshable_method def render(self) -> None: """Render the keyboard to the page.""" From c0f417d79e9906efe593f76482ca2bef9b3e4279 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 08:35:56 -0700 Subject: [PATCH 018/196] Fix some pre-commit related errors in README.md --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2436b130..5cfd18f7 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,11 @@ $ py src/main.py # Contributing ## Setting up the project for development -### Using `uv` (recommended) -``` -$ uvx pre-commit install -``` - -### Without `uv` If you do not have `pre-commit` installed, see https://pre-commit.com/#installation + +You can also use `uvx pre-commit` to run `pre-commit` commands without permanently installing it. + +Once you have `pre-commit` installed, run this command to set up the commit hooks. ``` $ pre-commit install ``` @@ -57,3 +55,4 @@ You can run the pre-commit checks locally with: ``` $ pre-commit run -a ``` +If you installed the commit hook in the previous step, they should also be run locally on commits. From f9a3dc55b5c8d494177eb236d9cc74226292dcbd Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 10 Aug 2025 16:15:55 -0500 Subject: [PATCH 019/196] Copy input_view from original repo --- src/input_view.py | 96 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/input_view.py diff --git a/src/input_view.py b/src/input_view.py new file mode 100644 index 00000000..65f4fb88 --- /dev/null +++ b/src/input_view.py @@ -0,0 +1,96 @@ +from collections.abc import Iterator + +from nicegui import ui + + +class input_view(ui.element): # noqa: N801 this is the nicegui convention + """The component which will display the user's input. + + :param full_text: The original text displayed under the user's input. This can be changed later. + """ + + CSS = """ +.input-view { + display: block; + position: relative; + padding: 8px; + border-radius: 5px; + background-color: var(--q-dark); + font-family: monospace; + color: var(--q-secondary); + word-break: break-all; +} + +.input-view-fg { + position: absolute; + inset: 0; + margin: 8px; +} + +.input-view-fg div { + display: inline; +} + +.input-view-fg span.c, .input-view-fg div.c { + background-color: var(--q-positive); + color: var(--q-dark); +} + +.input-view-fg span.w, .input-view-fg div.w { + background-color: var(--q-negative); +} + +.input-view-fg span.cursor, .input-view-fg div.cursor { + color: white; + display: inline-block; +} +""" + + def __init__(self, full_text: str) -> None: + super().__init__("div") + ui.add_css(self.CSS) + + self.classes("input-view") + with self: + self.full_text_label = ui.label(full_text).classes("input-view-bg") + self.text_input = ui.element().classes("input-view-fg") + + self.full_text = full_text + self.value = "" + + def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: + """Get a token list of string slices and whether they are correct.""" + if len(user_text) == 0: + return iter(()) + + mask = bytearray(user_text[i] == self.full_text[i] for i in range(min(len(self.full_text), len(user_text)))) + index = 0 + cur_v = mask[0] + + while index < len(mask): + next_change_at = mask.find(cur_v ^ 1, index) + if next_change_at == -1: + next_change_at = len(mask) + yield (user_text[index:next_change_at], bool(cur_v)) + index = next_change_at + cur_v ^= 1 + + def set_original_text(self, value: str) -> None: + """Reset the background text (full_text).""" + self.full_text = value + self.full_text_label.set_text(value) + + def set_text(self, value: str) -> None: + """Set the current input. + + Sets the foreground value to the user's input, and adds some highlighting based on where + the user's input is right and wrong. + """ + self.text_input.clear() + if value == "": + return + parsed = self._parse_text(value) + with self.text_input: + for tok in parsed: + ui.label(tok[0]).classes("c" if tok[1] else "w") + ui.label("_").classes("cursor") From 5240b3786bafe67951bdf969aa8de1011bd73a1e Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 10 Aug 2025 14:38:59 -0700 Subject: [PATCH 020/196] Switch out OK for backspace symbol --- src/rpg_text_input/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 82d08fe6..218c1c0a 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -30,9 +30,6 @@ class Keyboard: Positions are stored internally as (col, row). - In the default keys list, \N{Squared Ok} is intended for once typing is complete, - having to be pressed to complete the challenge. - Raises: ValueError: If input keys is non-rectangular (jagged) or starting position is outside keys. @@ -42,7 +39,7 @@ class Keyboard: "ABCDEFGabcdefg", "HIJKLMNhijklmn", "OPQRSTUopqrstu", - "VWXYZ. vwxyz,\N{SQUARED OK}", + "VWXYZ. vwxyz,\N{SYMBOL FOR BACKSPACE}", ) position: list[int] = field(default_factory=lambda: [0, 0]) From 5638dd8bec6133232156c1be45d0af8997ba1a4a Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 10 Aug 2025 17:30:53 -0500 Subject: [PATCH 021/196] Improve docstrings a lot --- src/input_view.py | 51 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/input_view.py b/src/input_view.py index 65f4fb88..3bd09518 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -6,7 +6,27 @@ class input_view(ui.element): # noqa: N801 this is the nicegui convention """The component which will display the user's input. - :param full_text: The original text displayed under the user's input. This can be changed later. + Usage: + First, in the page handler, create the view -- do NOT put this in a refreshable + method, as the constructor adds CSS and will duplicate stuff. The `full_text` argument is + the **original** text, the thing that the user needs to type -- the user's input starts empty. + + Then, whenever your text updates, call `set_text` on the input view. This updates the + **user input** of the input view. If you need to change the background text for whatever + reason, use `set_original_text`. + + Example: + ```python + @ui.page("/input") + def page(): + state = State() + iv = input_view.input_view("The quick brown fox jumps over the lazy dog.") + def on_key(key): + state.text += key + iv.set_text(state.text) + input_method.on_key(on_key) + ``` + """ CSS = """ @@ -76,15 +96,36 @@ def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: cur_v ^= 1 def set_original_text(self, value: str) -> None: - """Reset the background text (full_text).""" + """Reset the **background** text. You're probably looking for `set_text`. + + Example: + ```python + # `text` is what the user is supposed to type. + def new_txt_selected_handler(text): + iv.set_original_text(text) + ... + ``` + + """ self.full_text = value self.full_text_label.set_text(value) def set_text(self, value: str) -> None: - """Set the current input. + """Set the current **user** input -- what should be displayed in the foreground. + + Additionally, it adds highlighting based on where the user types a correct character and + where the user types an incorrect character. + + Example: + ```python + state = State() + def on_key(key): + state.text += key + iv.set_text(state.text) # here + ... + input_method.on_key(on_key) + ``` - Sets the foreground value to the user's input, and adds some highlighting based on where - the user's input is right and wrong. """ self.text_input.clear() if value == "": From 69f587da7e049477bec6612837c821470a2146b6 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 10 Aug 2025 19:31:21 -0500 Subject: [PATCH 022/196] Make cursor visible before first set_text call --- src/input_view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/input_view.py b/src/input_view.py index 3bd09518..44cd5a11 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -74,6 +74,8 @@ def __init__(self, full_text: str) -> None: with self: self.full_text_label = ui.label(full_text).classes("input-view-bg") self.text_input = ui.element().classes("input-view-fg") + with self.text_input: + ui.label("_").classes("cursor") self.full_text = full_text self.value = "" From 12bc5cf2358f72df9fa145726526aeb148a12d78 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 10 Aug 2025 20:42:55 -0500 Subject: [PATCH 023/196] Fixed weird overflow bug This time instead of using absolute positioning hacks it uses grid hacks to overlay the foreground over the background --- src/input_view.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/input_view.py b/src/input_view.py index 44cd5a11..0679b22e 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -31,20 +31,21 @@ def on_key(key): CSS = """ .input-view { - display: block; - position: relative; padding: 8px; border-radius: 5px; background-color: var(--q-dark); font-family: monospace; color: var(--q-secondary); word-break: break-all; + display: grid; +} + +.input-view-bg { + grid-area: 1 / 1 / 2 / 2; } .input-view-fg { - position: absolute; - inset: 0; - margin: 8px; + grid-area: 1 / 1 / 2 / 2; } .input-view-fg div { From a9e90cd99a8348a91b5f5111538773284b4979c0 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 10 Aug 2025 19:33:33 -0700 Subject: [PATCH 024/196] Added images and sounds for audio style input page --- static/images/record.png | Bin 0 -> 70671 bytes static/sounds/fast_forward.mp3 | Bin 0 -> 15360 bytes static/sounds/rewind.mp3 | Bin 0 -> 18432 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/record.png create mode 100644 static/sounds/fast_forward.mp3 create mode 100644 static/sounds/rewind.mp3 diff --git a/static/images/record.png b/static/images/record.png new file mode 100644 index 0000000000000000000000000000000000000000..453a0852991ec91a0fc2408e2312c1aa9adc4ef2 GIT binary patch literal 70671 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

&#v5q7{+Z$&a@BhO#9}YGAQTqh{BQRd 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 literal 0 HcmV?d00001 diff --git a/static/sounds/rewind.mp3 b/static/sounds/rewind.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..574597bc711b752a5e1255e8cdf63ff5f174bc25 GIT binary patch literal 18432 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 Date: Sun, 10 Aug 2025 21:49:10 -0500 Subject: [PATCH 025/196] Hide cursor if input is completed length --- src/input_view.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/input_view.py b/src/input_view.py index 0679b22e..eb7b67cc 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -137,4 +137,5 @@ def on_key(key): with self.text_input: for tok in parsed: ui.label(tok[0]).classes("c" if tok[1] else "w") - ui.label("_").classes("cursor") + if len(value) < len(self.full_text): + ui.label("_").classes("cursor") From 09742f905bf007f5975087a2b90097050a1468aa Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 10 Aug 2025 20:18:46 -0700 Subject: [PATCH 026/196] fake homepage and initial audio page --- src/audio_style_input/__init__.py | 22 ++++++++++++++++++++++ src/main.py | 13 +++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/audio_style_input/__init__.py diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py new file mode 100644 index 00000000..3df04d3f --- /dev/null +++ b/src/audio_style_input/__init__.py @@ -0,0 +1,22 @@ +from nicegui import ui + + +@ui.page("/audio_editor") +def audio_editor_page() -> None: + """Render the audio editor intro page.""" + with ( + ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950") as intro_card, + ui.card().classes("no-shadow justify-center items-center"), + ): + ui.label("WPM Battle: DJ Edition").classes("text-[48px]") + ui.label("Use an audio editor to test your typing skills").classes("text-[20px]") + start_button = ui.button("Get started!") + + def start_audio_editor() -> None: + """Hide the intro card.""" + intro_card.style("display:none") + + start_button.on("click", start_audio_editor) + + +ui.run() diff --git a/src/main.py b/src/main.py index 56ad82ae..cd33b845 100644 --- a/src/main.py +++ b/src/main.py @@ -2,4 +2,17 @@ ui.label("Hello NiceGUI!") + +@ui.page("/") +def main_page() -> None: + """Render the main page.""" + with ( + ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950"), + ui.card().classes("no-shadow justify-center items-center"), + ): + ui.label("Audio Editor Of The Future").classes("text-[48px]") + ui.label("Use an audio editor to test your typing skills").classes("text-[20px]") + ui.button("Go to Audio Editor", on_click=lambda: ui.navigate.to("/audio_editor")) + + ui.run() From 36294d63354d08b0ae4e793610c86a6a2c3c4b27 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 10 Aug 2025 20:34:42 -0700 Subject: [PATCH 027/196] added image and a lable --- src/audio_style_input/__init__.py | 16 ++++++++++++++-- src/main.py | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 3df04d3f..942e28d6 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,4 +1,9 @@ -from nicegui import ui +from pathlib import Path + +from nicegui import app, ui + +media = Path("./static") +app.add_media_files("/media", media) @ui.page("/audio_editor") @@ -12,9 +17,16 @@ def audio_editor_page() -> None: ui.label("Use an audio editor to test your typing skills").classes("text-[20px]") start_button = ui.button("Get started!") + main_content = ui.column().classes("items-center gap-4").style("display:none") + + with main_content, ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950"): + ui.image("/media/images/record.png").style("width: 500px;") + ui.label("Current letter: A") + def start_audio_editor() -> None: - """Hide the intro card.""" + """Switch from intro card to main content.""" intro_card.style("display:none") + main_content.style("display:flex") start_button.on("click", start_audio_editor) diff --git a/src/main.py b/src/main.py index cd33b845..2c3d6825 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,7 @@ from nicegui import ui +import audio_style_input as _ # noqa: F401 + ui.label("Hello NiceGUI!") From 2140d98b2c7caaa1eb7c6068a414db4e4aef21dd Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 10 Aug 2025 22:50:12 -0700 Subject: [PATCH 028/196] Added play, pause, skip, rewind, and select buttons with working actions --- src/audio_style_input/__init__.py | 161 +++++++++++++++++++++++++++--- src/main.py | 4 +- 2 files changed, 147 insertions(+), 18 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 942e28d6..89f0c01b 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from nicegui import app, ui @@ -5,30 +6,158 @@ media = Path("./static") app.add_media_files("/media", media) +letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] -@ui.page("/audio_editor") -def audio_editor_page() -> None: - """Render the audio editor intro page.""" + +async def spin_continuous( + rotation_container: list[int], + spin_direction_container: list[int], + spin_speed_container: list[int], + record: ui.image, +) -> None: + """Continuously rotate the record image based on spin direction and speed.""" + while True: + rotation_container[0] += spin_direction_container[0] * spin_speed_container[0] + record.style(f"transform: rotate({rotation_container[0]}deg);") + await asyncio.sleep(0.05) + + +def create_intro_card() -> tuple[ui.card, ui.button]: + """Create the intro card with title and start button.""" + intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#d18b2b]") + with intro_card, ui.card().classes("no-shadow justify-center items-center"): + ui.label("WPM Battle: DJ Edition").classes("text-[86px]") + ui.label("Use an audio editor to test your typing skills").classes("text-[28px]") + start_button = ui.button("Get started!", color="#ff9900") + + return intro_card, start_button + + +def create_main_content() -> tuple[ui.column, ui.image, ui.label, ui.row]: + """Create the main content area with record image, letter label, and buttons inside the same card.""" + main_content = ui.column().classes("items-center gap-4 #2b87d1").style("display:none") with ( - ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950") as intro_card, - ui.card().classes("no-shadow justify-center items-center"), + main_content, + ui.card().classes( + "gap-8 w-[100vw] h-[50vh] flex flex-col justify-center items-center bg-[#2b87d1]", + ), ): - ui.label("WPM Battle: DJ Edition").classes("text-[48px]") - ui.label("Use an audio editor to test your typing skills").classes("text-[20px]") - start_button = ui.button("Get started!") + record = ui.image( + "/media/images/record.png", + ).style("width: 300px; transition: transform 0.05s linear;") + label = ui.label("Current letter: A") + buttons_row = ui.row().style("gap: 10px") + return main_content, record, label, buttons_row + + +@ui.page("/audio_editor") +def audio_editor_page() -> None: # noqa: C901 , PLR0915 + """Render the audio editor page with spinning record and letter spinner.""" + current_letter_index_container: list[int] = [0] + rotation_container: list[int] = [0] + normal_spin_speed = 5 + boosted_spin_speed = 10 + spin_speed_container: list[int] = [normal_spin_speed] + spin_direction_container: list[int] = [1] + + timer_task: asyncio.Task | None = None + spin_task: asyncio.Task | None = None + + main_track = ( + ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") + .props(remove="controls") + .props("loop") + ) + rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") + fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") + + intro_card, start_button = create_intro_card() + main_content, record, label, buttons_row = create_main_content() - main_content = ui.column().classes("items-center gap-4").style("display:none") + async def letter_spinner_task() -> None: + while True: + label.set_text(f"Current letter: {letters[current_letter_index_container[0]]}") + current_letter_index_container[0] = (current_letter_index_container[0] + 1) % len(letters) + await asyncio.sleep(0.5) - with main_content, ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950"): - ui.image("/media/images/record.png").style("width: 500px;") - ui.label("Current letter: A") + def start_spinning(*, clockwise: bool = True) -> None: + nonlocal spin_task + spin_direction_container[0] = 1 if clockwise else -1 + if spin_task is None or spin_task.done(): + spin_task = asyncio.create_task( + spin_continuous( + rotation_container, + spin_direction_container, + spin_speed_container, + record, + ), + ) + + def stop_spinning() -> None: + nonlocal spin_task + if spin_task: + spin_task.cancel() + + async def speed_boost(final_direction: int = 1) -> None: + spin_speed_container[0] = boosted_spin_speed + await asyncio.sleep(1) + spin_speed_container[0] = normal_spin_speed + spin_direction_container[0] = final_direction + + def on_play() -> None: + nonlocal timer_task + if timer_task is None or timer_task.done(): + timer_task = asyncio.create_task(letter_spinner_task()) + start_spinning(clockwise=True) + + def on_pause() -> None: + nonlocal timer_task + if timer_task: + timer_task.cancel() + stop_spinning() + + def play_rewind_sound() -> None: + rewind_sound.play() + + def play_fast_forward_sound() -> None: + fast_forward_sound.play() + + def forward_3() -> None: + current_letter_index_container[0] = (current_letter_index_container[0] + 3) % len(letters) + play_fast_forward_sound() + start_spinning(clockwise=True) + task = asyncio.create_task(speed_boost(final_direction=1)) + _ = task + + def rewind_3() -> None: + current_letter_index_container[0] = (current_letter_index_container[0] - 3) % len(letters) + play_rewind_sound() + start_spinning(clockwise=False) + task = asyncio.create_task(speed_boost(final_direction=1)) + _ = task + + handlers = { + "on_play": on_play, + "on_pause": on_pause, + "rewind_3": rewind_3, + "forward_3": forward_3, + } + + with buttons_row: + ui.button("Play", color="#d18b2b", on_click=lambda: [main_track.play(), handlers["on_play"]()]) + ui.button("Pause", color="#d18b2b", on_click=lambda: [main_track.pause(), handlers["on_pause"]()]) + ui.button("Rewind 3 Seconds", color="#d18b2b", on_click=handlers["rewind_3"]) + ui.button("Forward 3 Seconds", color="#d18b2b", on_click=handlers["forward_3"]) + ui.button( + "Select Letter", + color="green", + on_click=lambda: ui.notify( + f"You selected: {letters[current_letter_index_container[0] - 1]}", + ), + ) def start_audio_editor() -> None: - """Switch from intro card to main content.""" intro_card.style("display:none") main_content.style("display:flex") start_button.on("click", start_audio_editor) - - -ui.run() diff --git a/src/main.py b/src/main.py index 2c3d6825..588591f5 100644 --- a/src/main.py +++ b/src/main.py @@ -12,8 +12,8 @@ def main_page() -> None: ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950"), ui.card().classes("no-shadow justify-center items-center"), ): - ui.label("Audio Editor Of The Future").classes("text-[48px]") - ui.label("Use an audio editor to test your typing skills").classes("text-[20px]") + ui.label("Terrible Typing").classes("text-[48px]") + ui.label("Test Your Typing Skills").classes("text-[20px]") ui.button("Go to Audio Editor", on_click=lambda: ui.navigate.to("/audio_editor")) From a1cb15f9c8ae7d6930a4e048e91ee0406c0f6023 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 11 Aug 2025 02:26:05 -0500 Subject: [PATCH 029/196] Add WPM-test page. This commit firstly makes the input_method_proto module, which defines an interface for input methods to follow. The interface is to allow the WPM-test page module to only have to worry about one implementation of input method rather than having special handling for each one. --- src/input_method_proto.py | 9 +++++++ src/main.py | 4 ++- src/wpm_tester.py | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/input_method_proto.py create mode 100644 src/wpm_tester.py diff --git a/src/input_method_proto.py b/src/input_method_proto.py new file mode 100644 index 00000000..e5825cce --- /dev/null +++ b/src/input_method_proto.py @@ -0,0 +1,9 @@ +import typing + + +class IInputMethod(typing.Protocol): + """An interface for any input method renderable in the WPM test page.""" + + def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + """Call `callback` every time the user input changes.""" + raise NotImplementedError diff --git a/src/main.py b/src/main.py index 56ad82ae..13d58769 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,7 @@ from nicegui import ui -ui.label("Hello NiceGUI!") +import wpm_tester + +ui.page("/test/{method}")(wpm_tester.wpm_tester_page) ui.run() diff --git a/src/wpm_tester.py b/src/wpm_tester.py new file mode 100644 index 00000000..affea699 --- /dev/null +++ b/src/wpm_tester.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass + +from nicegui import ui + +import input_method_proto +import input_view + + +def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: # noqa: ARG001 temporary + """Get an input method class by it's name. + + :returns: `type[IInputMethod]` on success, `None` on failure. + """ + return None + + +@dataclass +class WpmTesterPageState: + """The page state.""" + + """Useless for now, may be useful later?""" + text: str + + +async def wpm_tester_page(method: str) -> None: + """Create the actual page which tests the wpm. + + Usage: + In main.py, use @ui.page("/test/{method}")(this) then this takes + the method from the url + """ + state = WpmTesterPageState("") + + input_method_def = get_input_method_by_name(method) + if input_method_def is None: + ui.navigate.to("/") + return + + with ui.header(elevated=True).classes("align-center justify-center"): + ui.label(f"test: {method}").classes("text-center text-lg") + + # TODO: get og text from babbler module + iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") + + input_method = input_method_def() + + def on_text_update(txt: str) -> None: + iv.set_text(txt) + state.text = txt + + input_method.on_text_update(on_text_update) From 326eebe5315d7fe9ca8aea420e083d9c4b7dcb48 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 11 Aug 2025 02:44:36 -0500 Subject: [PATCH 030/196] Create sample input method module --- src/sample_input_method/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/sample_input_method/__init__.py diff --git a/src/sample_input_method/__init__.py b/src/sample_input_method/__init__.py new file mode 100644 index 00000000..2ca6c8a6 --- /dev/null +++ b/src/sample_input_method/__init__.py @@ -0,0 +1,23 @@ +import typing + +from nicegui import ui + +import input_method_proto + + +class SampleInputMethod(input_method_proto.IInputMethod): + """A sample input method for basic reference. + + Consider using a dataclass instead with any complex state. + """ + + callbacks: list[typing.Callable[[str], None]] + + def __init__(self) -> None: + self.callbacks = [] + self.inp = ui.input("input here") + self.inp.on_value_change(lambda event: [x(event.value) for x in self.callbacks]) + + def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + """Call `callback` every time the user input changes.""" + self.callbacks.append(callback) From 61cf35b453c9797b17e95bbe6f2a46c974b2e557 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 11 Aug 2025 02:45:00 -0500 Subject: [PATCH 031/196] Add sample input method to wpm-test module method list --- src/wpm_tester.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index affea699..1afee11d 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -4,13 +4,16 @@ import input_method_proto import input_view +import sample_input_method -def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: # noqa: ARG001 temporary +def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: """Get an input method class by it's name. :returns: `type[IInputMethod]` on success, `None` on failure. """ + if inmth == "sample": + return sample_input_method.SampleInputMethod return None From a3cff30afdcfd70327bb05e9c5e3fd13f68a6b1e Mon Sep 17 00:00:00 2001 From: afx Date: Mon, 11 Aug 2025 02:49:53 -0500 Subject: [PATCH 032/196] Update main.py i forgot to import --- src/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.py b/src/main.py index d7150cc2..4ac777af 100644 --- a/src/main.py +++ b/src/main.py @@ -1,8 +1,10 @@ from nicegui import ui # We probably want to figure out a more clean way to do this without the noqa. import rpg_text_input as _ # noqa: F401 Importing creates the subpage. +import wpm_tester ui.label("Hello NiceGUI!") ui.page("/test/{method}")(wpm_tester.wpm_tester_page) ui.run() + From e0d19623025c17a796af5a83d94dfbde142b4e05 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 11 Aug 2025 02:52:58 -0500 Subject: [PATCH 033/196] Fix formatting in imports --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 4ac777af..2075609b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ from nicegui import ui + # We probably want to figure out a more clean way to do this without the noqa. import rpg_text_input as _ # noqa: F401 Importing creates the subpage. import wpm_tester @@ -7,4 +8,3 @@ ui.page("/test/{method}")(wpm_tester.wpm_tester_page) ui.run() - From 09c470c06f057a4008bd79b029989c7cbaf9ab4d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:01:22 -0700 Subject: [PATCH 034/196] Create homepage outline --- src/homepage.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/homepage.py diff --git a/src/homepage.py b/src/homepage.py new file mode 100644 index 00000000..b77673c4 --- /dev/null +++ b/src/homepage.py @@ -0,0 +1,13 @@ +from nicegui import ui + + +NAME: str = "PLACEHOLDER NAME" +DESCRIPTION: str = "Placeholder Description" + + +@ui.page('/') +def home() -> None: + pass + + +ui.run() \ No newline at end of file From 016d17faf1b12b9433c004aceadf0160813c2b39 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:13:48 -0700 Subject: [PATCH 035/196] Define color theme --- src/homepage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/homepage.py b/src/homepage.py index b77673c4..6cba7ea5 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -7,6 +7,7 @@ @ui.page('/') def home() -> None: + ui.colors(primary="#20A39E", secondary="#8E7DBE", dark="#393D3F", accent="#E9ECF5") pass From 548db4b33058843122e357964097bc5ed98396f4 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:20:04 -0700 Subject: [PATCH 036/196] Revert "Define color theme" This reverts commit 882cebdf9ddfaafae846aa076e7928fa04c4b8b7. --- src/homepage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index 6cba7ea5..b77673c4 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -7,7 +7,6 @@ @ui.page('/') def home() -> None: - ui.colors(primary="#20A39E", secondary="#8E7DBE", dark="#393D3F", accent="#E9ECF5") pass From d3d3597d91b50a7794383567d3a67e3d4697a124 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:22:35 -0700 Subject: [PATCH 037/196] Change background color --- src/homepage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index b77673c4..cecd825c 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -7,7 +7,7 @@ @ui.page('/') def home() -> None: - pass + ui.query('body').style('background-color: #E9ECF5;') ui.run() \ No newline at end of file From d695faeffdf7f986c6d5a2ec9565ddbed3879358 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:23:56 -0700 Subject: [PATCH 038/196] Add page header --- src/homepage.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/homepage.py b/src/homepage.py index cecd825c..1fbe4dc9 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -7,7 +7,29 @@ @ui.page('/') def home() -> None: + ui.add_css(''' + .thick-header { + height: 350px; + justify-content: center; + } + .site-title { + font-family: Arial; + font-weight: bold; + text-align: center; + font-size: 70px; + .site-subtitle { + font-family: Arial; + text-align: center; + font-size: 20px; + } + ''') + ui.query('body').style('background-color: #E9ECF5;') + with ui.header().style('background-color: #20A39E').classes('items-center thick-header'): + with ui.column(align_items="center").style('gap: 0px;'): + ui.label(NAME).classes('site-title') + ui.label(DESCRIPTION).classes('site-subtitle') + ui.run() \ No newline at end of file From 7216a441d5ad32a52100660850b27c1e35c6242b Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:25:26 -0700 Subject: [PATCH 039/196] Add input method buttons --- src/homepage.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index 1fbe4dc9..ebd60450 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -22,14 +22,44 @@ def home() -> None: text-align: center; font-size: 20px; } + .page-div { + position: absolute; + left: 50%; + transform: translate(-50%); + top: 30px; + } + .heading { + font-family: Arial; + font-size: 25px; + font-weight: bold; + text-align: center; + color: #393D3F; + } + .input-box { + height: 300px; + width: 300px; + padding: 20px; + text-color: 393D3F; + } + .input-grid { + justify-content: center; + } ''') - + ui.query('body').style('background-color: #E9ECF5;') with ui.header().style('background-color: #20A39E').classes('items-center thick-header'): with ui.column(align_items="center").style('gap: 0px;'): ui.label(NAME).classes('site-title') ui.label(DESCRIPTION).classes('site-subtitle') + + with ui.element('div').classes('page-div'): + with ui.column(align_items="center").style("gap: 30px;"): + ui.label("CHOOSE YOUR INPUT METHOD").classes('heading') + ui.separator() + with ui.row(align_items="center", wrap=False).style('gap: 50px;'): + for i in range(4): + ui.button(text=f"Input method {i+1}", color="#F9F9F9").classes('input-box') ui.run() \ No newline at end of file From 72e831b133c6684a91818673d99938a9c6a29d74 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 20:40:16 -0700 Subject: [PATCH 040/196] Fix page formatting error --- src/homepage.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index ebd60450..9f659e3f 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -17,17 +17,12 @@ def home() -> None: font-weight: bold; text-align: center; font-size: 70px; + } .site-subtitle { font-family: Arial; text-align: center; font-size: 20px; } - .page-div { - position: absolute; - left: 50%; - transform: translate(-50%); - top: 30px; - } .heading { font-family: Arial; font-size: 25px; @@ -44,6 +39,12 @@ def home() -> None: .input-grid { justify-content: center; } + .page-div { + position: absolute; + left: 50%; + transform: translate(-50%); + top: 30px; + } ''') ui.query('body').style('background-color: #E9ECF5;') @@ -52,7 +53,7 @@ def home() -> None: with ui.column(align_items="center").style('gap: 0px;'): ui.label(NAME).classes('site-title') ui.label(DESCRIPTION).classes('site-subtitle') - + with ui.element('div').classes('page-div'): with ui.column(align_items="center").style("gap: 30px;"): ui.label("CHOOSE YOUR INPUT METHOD").classes('heading') From ceba59f21d7ed97fb177b029270e691d599ca6e2 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 22:47:02 -0700 Subject: [PATCH 041/196] Fix Ruff formatting errors --- src/homepage.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 9f659e3f..dda7049d 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -1,13 +1,13 @@ from nicegui import ui - NAME: str = "PLACEHOLDER NAME" DESCRIPTION: str = "Placeholder Description" -@ui.page('/') +@ui.page("/") def home() -> None: - ui.add_css(''' + """Render the home page.""" + ui.add_css(""" .thick-header { height: 350px; justify-content: center; @@ -45,22 +45,23 @@ def home() -> None: transform: translate(-50%); top: 30px; } - ''') + """) - ui.query('body').style('background-color: #E9ECF5;') + ui.query("body").style("background-color: #E9ECF5;") - with ui.header().style('background-color: #20A39E').classes('items-center thick-header'): - with ui.column(align_items="center").style('gap: 0px;'): - ui.label(NAME).classes('site-title') - ui.label(DESCRIPTION).classes('site-subtitle') + with ( + ui.header().style("background-color: #20A39E").classes("items-center thick-header"), + ui.column(align_items="center").style("gap: 0px;"), + ): + ui.label(NAME).classes("site-title") + ui.label(DESCRIPTION).classes("site-subtitle") - with ui.element('div').classes('page-div'): - with ui.column(align_items="center").style("gap: 30px;"): - ui.label("CHOOSE YOUR INPUT METHOD").classes('heading') - ui.separator() - with ui.row(align_items="center", wrap=False).style('gap: 50px;'): - for i in range(4): - ui.button(text=f"Input method {i+1}", color="#F9F9F9").classes('input-box') + with ui.element("div").classes("page-div"), ui.column(align_items="center").style("gap: 30px;"): + ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") + ui.separator() + with ui.row(align_items="center", wrap=False).style("gap: 50px;"): + for i in range(4): + ui.button(text=f"Input method {i + 1}", color="#F9F9F9").classes("input-box") -ui.run() \ No newline at end of file +ui.run() From e9031db34bbe63bd7474a53e1db3a84abef8131a Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 22:53:23 -0700 Subject: [PATCH 042/196] Remove redundant semi-colon --- src/homepage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index dda7049d..11ba489c 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -47,7 +47,7 @@ def home() -> None: } """) - ui.query("body").style("background-color: #E9ECF5;") + ui.query("body").style("background-color: #E9ECF5") with ( ui.header().style("background-color: #20A39E").classes("items-center thick-header"), From 8fa1929b87073f08301e6936e46e3e5424155d90 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 11 Aug 2025 22:57:33 -0700 Subject: [PATCH 043/196] Remove ui.run() --- src/homepage.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 11ba489c..e2123302 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -62,6 +62,3 @@ def home() -> None: with ui.row(align_items="center", wrap=False).style("gap: 50px;"): for i in range(4): ui.button(text=f"Input method {i + 1}", color="#F9F9F9").classes("input-box") - - -ui.run() From 4ba1235e0fbf360d631c4af087bbcc36f0d113ce Mon Sep 17 00:00:00 2001 From: afx8732 Date: Tue, 12 Aug 2025 01:20:06 -0500 Subject: [PATCH 044/196] Import the homepage module and render it in main. --- src/homepage.py | 1 - src/main.py | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index e2123302..7351e120 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -4,7 +4,6 @@ DESCRIPTION: str = "Placeholder Description" -@ui.page("/") def home() -> None: """Render the home page.""" ui.add_css(""" diff --git a/src/main.py b/src/main.py index e9fd81ce..07297542 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,7 @@ from nicegui import ui import audio_style_input as _audio # noqa: F401 +import homepage # We probably want to figure out a more clean way to do this without the noqa. import rpg_text_input as _ # noqa: F401 Importing creates the subpage. @@ -10,16 +11,7 @@ ui.page("/test/{method}")(wpm_tester.wpm_tester_page) -@ui.page("/") -def main_page() -> None: - """Render the main page.""" - with ( - ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-sky-950"), - ui.card().classes("no-shadow justify-center items-center"), - ): - ui.label("Terrible Typing").classes("text-[48px]") - ui.label("Test Your Typing Skills").classes("text-[20px]") - ui.button("Go to Audio Editor", on_click=lambda: ui.navigate.to("/audio_editor")) +ui.page("/")(homepage.home) ui.run() From 1ef9ef95d6e64a4432f8dcbbe6f565628a748eea Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 11 Aug 2025 23:50:20 -0700 Subject: [PATCH 045/196] added audio files back --- src/audio_style_input/__init__.py | 176 +++++------------------------- 1 file changed, 26 insertions(+), 150 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 89f0c01b..d7aa4804 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,163 +1,39 @@ -import asyncio from pathlib import Path +from typing import TYPE_CHECKING from nicegui import app, ui -media = Path("./static") -app.add_media_files("/media", media) - -letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] - - -async def spin_continuous( - rotation_container: list[int], - spin_direction_container: list[int], - spin_speed_container: list[int], - record: ui.image, -) -> None: - """Continuously rotate the record image based on spin direction and speed.""" - while True: - rotation_container[0] += spin_direction_container[0] * spin_speed_container[0] - record.style(f"transform: rotate({rotation_container[0]}deg);") - await asyncio.sleep(0.05) - +if TYPE_CHECKING: + from collections.abc import Callable -def create_intro_card() -> tuple[ui.card, ui.button]: - """Create the intro card with title and start button.""" - intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#d18b2b]") - with intro_card, ui.card().classes("no-shadow justify-center items-center"): - ui.label("WPM Battle: DJ Edition").classes("text-[86px]") - ui.label("Use an audio editor to test your typing skills").classes("text-[28px]") - start_button = ui.button("Get started!", color="#ff9900") - return intro_card, start_button +import input_method_proto +media = Path("./static") +app.add_media_files("/media", media) -def create_main_content() -> tuple[ui.column, ui.image, ui.label, ui.row]: - """Create the main content area with record image, letter label, and buttons inside the same card.""" - main_content = ui.column().classes("items-center gap-4 #2b87d1").style("display:none") - with ( - main_content, - ui.card().classes( - "gap-8 w-[100vw] h-[50vh] flex flex-col justify-center items-center bg-[#2b87d1]", - ), - ): - record = ui.image( - "/media/images/record.png", - ).style("width: 300px; transition: transform 0.05s linear;") - label = ui.label("Current letter: A") - buttons_row = ui.row().style("gap: 10px") - return main_content, record, label, buttons_row +letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] -@ui.page("/audio_editor") -def audio_editor_page() -> None: # noqa: C901 , PLR0915 +class AudioEditorComponent(input_method_proto.IInputMethod): """Render the audio editor page with spinning record and letter spinner.""" - current_letter_index_container: list[int] = [0] - rotation_container: list[int] = [0] - normal_spin_speed = 5 - boosted_spin_speed = 10 - spin_speed_container: list[int] = [normal_spin_speed] - spin_direction_container: list[int] = [1] - - timer_task: asyncio.Task | None = None - spin_task: asyncio.Task | None = None - - main_track = ( - ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") - .props(remove="controls") - .props("loop") - ) - rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") - fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") - - intro_card, start_button = create_intro_card() - main_content, record, label, buttons_row = create_main_content() - async def letter_spinner_task() -> None: - while True: - label.set_text(f"Current letter: {letters[current_letter_index_container[0]]}") - current_letter_index_container[0] = (current_letter_index_container[0] + 1) % len(letters) - await asyncio.sleep(0.5) - - def start_spinning(*, clockwise: bool = True) -> None: - nonlocal spin_task - spin_direction_container[0] = 1 if clockwise else -1 - if spin_task is None or spin_task.done(): - spin_task = asyncio.create_task( - spin_continuous( - rotation_container, - spin_direction_container, - spin_speed_container, - record, - ), - ) - - def stop_spinning() -> None: - nonlocal spin_task - if spin_task: - spin_task.cancel() - - async def speed_boost(final_direction: int = 1) -> None: - spin_speed_container[0] = boosted_spin_speed - await asyncio.sleep(1) - spin_speed_container[0] = normal_spin_speed - spin_direction_container[0] = final_direction - - def on_play() -> None: - nonlocal timer_task - if timer_task is None or timer_task.done(): - timer_task = asyncio.create_task(letter_spinner_task()) - start_spinning(clockwise=True) - - def on_pause() -> None: - nonlocal timer_task - if timer_task: - timer_task.cancel() - stop_spinning() - - def play_rewind_sound() -> None: - rewind_sound.play() - - def play_fast_forward_sound() -> None: - fast_forward_sound.play() - - def forward_3() -> None: - current_letter_index_container[0] = (current_letter_index_container[0] + 3) % len(letters) - play_fast_forward_sound() - start_spinning(clockwise=True) - task = asyncio.create_task(speed_boost(final_direction=1)) - _ = task - - def rewind_3() -> None: - current_letter_index_container[0] = (current_letter_index_container[0] - 3) % len(letters) - play_rewind_sound() - start_spinning(clockwise=False) - task = asyncio.create_task(speed_boost(final_direction=1)) - _ = task - - handlers = { - "on_play": on_play, - "on_pause": on_pause, - "rewind_3": rewind_3, - "forward_3": forward_3, - } - - with buttons_row: - ui.button("Play", color="#d18b2b", on_click=lambda: [main_track.play(), handlers["on_play"]()]) - ui.button("Pause", color="#d18b2b", on_click=lambda: [main_track.pause(), handlers["on_pause"]()]) - ui.button("Rewind 3 Seconds", color="#d18b2b", on_click=handlers["rewind_3"]) - ui.button("Forward 3 Seconds", color="#d18b2b", on_click=handlers["forward_3"]) - ui.button( - "Select Letter", - color="green", - on_click=lambda: ui.notify( - f"You selected: {letters[current_letter_index_container[0] - 1]}", - ), + def __init__(self) -> None: + self._text_update_callback: Callable[[str], None] | None = None + self.current_letter_index_container = [0] + self.rotation_container = [0] + self.normal_spin_speed = 5 + self.boosted_spin_speed = 10 + self.spin_speed_container = [self.normal_spin_speed] + self.spin_direction_container = [1] + + self.timer_task = None + self.spin_task = None + + self.main_track = ( + ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") + .props(remove="controls") + .props("loop") ) - - def start_audio_editor() -> None: - intro_card.style("display:none") - main_content.style("display:flex") - - start_button.on("click", start_audio_editor) + self.rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") + self.fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") From 57977201aede61a7cd6146d97029b1156d914d15 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 11 Aug 2025 23:57:18 -0700 Subject: [PATCH 046/196] refactored intro_card and main_content --- src/audio_style_input/__init__.py | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d7aa4804..d1c326fc 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -30,6 +30,9 @@ def __init__(self) -> None: self.timer_task = None self.spin_task = None + self.intro_card, self.start_button = self.create_intro_card() + self.main_content, self.record, self.label, self.buttons_row = self.create_main_content() + self.main_track = ( ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") .props(remove="controls") @@ -37,3 +40,38 @@ def __init__(self) -> None: ) self.rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") self.fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") + + def create_intro_card(self) -> tuple[ui.card, ui.button]: + """Create the intro card with title and start button. + + Returns: + tuple: (intro_card, start_button) + + """ + intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#d18b2b]") + with intro_card, ui.card().classes("no-shadow justify-center items-center"): + ui.label("WPM Battle: DJ Edition").classes("text-[86px]") + ui.label("Use an audio editor to test your typing skills").classes("text-[28px]") + start_button = ui.button("Get started!", color="#ff9900") + return intro_card, start_button + + def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: + """Create main content with record image, letter label, and button row. + + Returns: + tuple: (main_content container, record image, label, buttons row) + + """ + main_content = ui.column().classes("items-center gap-4 #2b87d1").style("display:none") + with ( + main_content, + ui.card().classes( + "gap-8 w-[100vw] h-[50vh] flex flex-col justify-center items-center bg-[#2b87d1]", + ), + ): + record = ui.image( + "/media/images/record.png", + ).style("width: 300px; transition: transform 0.05s linear;") + label = ui.label("Current letter: A") + buttons_row = ui.row().style("gap: 10px") + return main_content, record, label, buttons_row From 21a358d68e222c23b763ebf82ddfbb6d7efe5e55 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 11 Aug 2025 23:59:56 -0700 Subject: [PATCH 047/196] refactored buttons --- src/audio_style_input/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d1c326fc..d7a63eeb 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -41,6 +41,9 @@ def __init__(self) -> None: self.rewind_sound = ui.audio("/media/sounds/rewind.mp3").style("display:none") self.fast_forward_sound = ui.audio("/media/sounds/fast_forward.mp3").style("display:none") + self.setup_buttons() + self.start_button.on("click", self.start_audio_editor) + def create_intro_card(self) -> tuple[ui.card, ui.button]: """Create the intro card with title and start button. @@ -75,3 +78,16 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: label = ui.label("Current letter: A") buttons_row = ui.row().style("gap: 10px") return main_content, record, label, buttons_row + + def setup_buttons(self) -> None: + """Create UI buttons with their event handlers.""" + with self.buttons_row: + ui.button("Play", color="#d18b2b", on_click=lambda: [self.main_track.play(), self.on_play()]) + ui.button("Pause", color="#d18b2b", on_click=lambda: [self.main_track.pause(), self.on_pause()]) + ui.button("Rewind 3 Seconds", color="#d18b2b", on_click=self.rewind_3) + ui.button("Forward 3 Seconds", color="#d18b2b", on_click=self.forward_3) + ui.button( + "Select Letter", + color="green", + on_click=self._select_letter_handler, + ) From 02ed88ae9bbc5a1c0d74318abae62cfa8d3b9587 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Tue, 12 Aug 2025 00:26:15 -0700 Subject: [PATCH 048/196] refactored logic funtions --- src/audio_style_input/__init__.py | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d7a63eeb..351559c5 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from typing import TYPE_CHECKING @@ -79,6 +80,82 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: buttons_row = ui.row().style("gap: 10px") return main_content, record, label, buttons_row + async def spin_continuous(self) -> None: + """Continuously rotate the record image based on spin speed and direction.""" + while True: + self.rotation_container[0] += self.spin_direction_container[0] * self.spin_speed_container[0] + self.record.style(f"transform: rotate({self.rotation_container[0]}deg);") + await asyncio.sleep(0.05) + + async def letter_spinner_task(self) -> None: + """Continuously update the label with the current letter, cycling through letters.""" + while True: + self.label.set_text(f"Current letter: {letters[self.current_letter_index_container[0]]}") + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len(letters) + await asyncio.sleep(0.5) + + def start_spinning(self, *, clockwise: bool = True) -> None: + """Start spinning the record image. + + Args: + clockwise (bool): Direction of spin; True for clockwise, False for counterclockwise. + + """ + self.spin_direction_container[0] = 1 if clockwise else -1 + if self.spin_task is None or self.spin_task.done(): + self.spin_task = asyncio.create_task(self.spin_continuous()) + + def stop_spinning(self) -> None: + """Stop spinning the record image.""" + if self.spin_task: + self.spin_task.cancel() + + async def speed_boost(self, final_direction: int = 1) -> None: + """Temporarily increase spin speed and then restore it. + + Args: + final_direction (int): Direction to set after boost (1 or -1). + + """ + self.spin_speed_container[0] = self.boosted_spin_speed + await asyncio.sleep(1) + self.spin_speed_container[0] = self.normal_spin_speed + self.spin_direction_container[0] = final_direction + + def on_play(self) -> None: + """Start letter spinner and spinning.""" + if self.timer_task is None or self.timer_task.done(): + self.timer_task = asyncio.create_task(self.letter_spinner_task()) + self.start_spinning(clockwise=True) + + def on_pause(self) -> None: + """Stop letter spinner and spinning.""" + if self.timer_task: + self.timer_task.cancel() + self.stop_spinning() + + def play_rewind_sound(self) -> None: + """Play rewind sound effect.""" + self.rewind_sound.play() + + def play_fast_forward_sound(self) -> None: + """Play fast forward sound effect.""" + self.fast_forward_sound.play() + + def forward_3(self) -> None: + """Skip forward 3 letters with sound and speed boost.""" + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 3) % len(letters) + self.play_fast_forward_sound() + self.start_spinning(clockwise=True) + self._forward_3_task = asyncio.create_task(self.speed_boost(final_direction=1)) + + def rewind_3(self) -> None: + """Skip backward 3 letters with sound and speed boost.""" + self.current_letter_index_container[0] = (self.current_letter_index_container[0] - 3) % len(letters) + self.play_rewind_sound() + self.start_spinning(clockwise=False) + self._speed_boost_task = asyncio.create_task(self.speed_boost(final_direction=1)) + def setup_buttons(self) -> None: """Create UI buttons with their event handlers.""" with self.buttons_row: From 6616a78a1ff4b6570276058aeeebfd38f107aaa9 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Tue, 12 Aug 2025 00:37:22 -0700 Subject: [PATCH 049/196] added interface functions and some refactoring --- src/audio_style_input/__init__.py | 36 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 351559c5..d3ff9575 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,13 +1,9 @@ import asyncio +from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING from nicegui import app, ui -if TYPE_CHECKING: - from collections.abc import Callable - - import input_method_proto media = Path("./static") @@ -168,3 +164,33 @@ def setup_buttons(self) -> None: color="green", on_click=self._select_letter_handler, ) + + def _select_letter_handler(self) -> None: + """Notify selected letter and trigger text update callback.""" + letter = letters[self.current_letter_index_container[0] - 1] + ui.notify(f"You selected: {letter}") + self.select_letter(letter) + + def start_audio_editor(self) -> None: + """Hide intro card and show main content.""" + self.intro_card.style("display:none") + self.main_content.style("display:flex") + + def on_text_update(self, callback: Callable[[str], None]) -> None: + """Register a callback to be called whenever the text updates. + + Args: + callback (Callable[[str], None]): Function called with updated text. + + """ + self._text_update_callback = callback + + def select_letter(self, letter: str) -> None: + """Call the registered callback with the selected letter. + + Args: + letter (str): The letter selected by the user. + + """ + if self._text_update_callback: + self._text_update_callback(letter) From de50c7e36a4f824439a04b60e143c2c6f7306a33 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Tue, 12 Aug 2025 01:06:20 -0700 Subject: [PATCH 050/196] edited main.py and test page in order to see audio input tool --- src/main.py | 14 ++++++-------- src/wpm_tester.py | 9 ++++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main.py b/src/main.py index 07297542..018c450f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,17 +1,15 @@ from nicegui import ui -import audio_style_input as _audio # noqa: F401 -import homepage +from homepage import home +from wpm_tester import wpm_tester_page -# We probably want to figure out a more clean way to do this without the noqa. -import rpg_text_input as _ # noqa: F401 Importing creates the subpage. -import wpm_tester +###from rpg_text_input import rpg_text_input_page so it doesnt think it's code -ui.label("Hello NiceGUI!") -ui.page("/test/{method}")(wpm_tester.wpm_tester_page) +ui.page("/")(home) +ui.page("/test/{method}")(wpm_tester_page) -ui.page("/")(homepage.home) +# http://localhost:8080/test/audio_input ui.run() diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 1afee11d..a8d42eeb 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -4,7 +4,8 @@ import input_method_proto import input_view -import sample_input_method +from audio_style_input import AudioEditorComponent +from sample_input_method import SampleInputMethod def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: @@ -12,8 +13,10 @@ def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod :returns: `type[IInputMethod]` on success, `None` on failure. """ - if inmth == "sample": - return sample_input_method.SampleInputMethod + input_method_dict = {"audio_input": AudioEditorComponent, "sample": SampleInputMethod} + + return input_method_dict.get(inmth) + return None From 567cd70a8d5d1f74cce84ed5b4bf537200bf1252 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Tue, 12 Aug 2025 17:32:45 -0700 Subject: [PATCH 051/196] Fix header to top of page --- src/homepage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index 7351e120..4b8687e1 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -49,7 +49,7 @@ def home() -> None: ui.query("body").style("background-color: #E9ECF5") with ( - ui.header().style("background-color: #20A39E").classes("items-center thick-header"), + ui.header(fixed=False).style("background-color: #20A39E").classes("items-center thick-header"), ui.column(align_items="center").style("gap: 0px;"), ): ui.label(NAME).classes("site-title") From 2edad881efd1ec37d33bab86b4c4083970a0e495 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Tue, 12 Aug 2025 18:12:55 -0700 Subject: [PATCH 052/196] Add button screen size support --- src/homepage.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 4b8687e1..123da7f0 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -28,6 +28,7 @@ def home() -> None: font-weight: bold; text-align: center; color: #393D3F; + padding: 30px; } .input-box { height: 300px; @@ -40,9 +41,17 @@ def home() -> None: } .page-div { position: absolute; + width: 90vw; left: 50%; transform: translate(-50%); - top: 30px; + } + .button-parent { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; + padding-top: 30px; + padding-bottom: 60px; } """) @@ -55,9 +64,9 @@ def home() -> None: ui.label(NAME).classes("site-title") ui.label(DESCRIPTION).classes("site-subtitle") - with ui.element("div").classes("page-div"), ui.column(align_items="center").style("gap: 30px;"): + with ui.element("div").classes("page-div"): ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") ui.separator() - with ui.row(align_items="center", wrap=False).style("gap: 50px;"): + with ui.element("div").classes("button-parent"): for i in range(4): ui.button(text=f"Input method {i + 1}", color="#F9F9F9").classes("input-box") From a93479d3c7875f27ff6ae2f098bc5aab69256db2 Mon Sep 17 00:00:00 2001 From: jks85 Date: Wed, 13 Aug 2025 01:13:40 -0700 Subject: [PATCH 053/196] Adding file with color input method implementation --- __init__.py | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 __init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..b7f67c38 --- /dev/null +++ b/__init__.py @@ -0,0 +1,222 @@ +# noqa: N999 +from functools import partial + +from nicegui import ui +from nicegui.events import ColorPickEventArguments + +# Note: Most onscreen updates include pop-up notifications via ui.notify(). These were used to assist debugging +# but can/will be removed later + + +class ColorInputManager: + """Implements the color-based typing input page. + + Allows user to type using the color palette for letters, spaces, and backspaces, and UI buttons/switches for + special characters ('.', '!', Shift, etc.) + """ + + def __init__(self) -> None: + self.typed_text = "" + self.typed_char = None + self.selected_color = None + self.shift_key_on = False + self.color_label = ui.label("None") + self.input_label = ui.label("None") + self.text_label = ui.label("None") + self.color_dict = { + "aqua": "#00FFFF", + "blue": "#0000FF", + "cyan": "#00FFFF", + "darkblue": "#00008B", + "emerald": "00674F", + "fuchsia": "#FF00FF", + "gray": "#7F7F7F", + "hotpink": "#FF69B4", + "indigo": "#4B0082", + "jasmine": "#F8DE7E", + "khaki": "#F0E68C", + "lime": "#00FF00", + "maroon": "#800000", + "navy": "#000080", + "orange": "#FFA500", + "purple": "#800080", + "quicksilver": "#A6A6A6", + "red": "#FF0000", + "salmon": "#FA8072", + "teal": "#008080", + "ube": "#8878C3", + "viridian": "#40826D", + "walnut": "#773F1A", + "xanadu": "#738678", + "yellow": "#FFFF00", + "zara": "#B48784", + "black": "#000000", + "white": "#FFFFFF", + } + + def special_character_handler(self, char: str) -> None: + """Handle special character events. + + This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button + elements. + """ + self.typed_text += char + self.typed_char = char + ui.notify(f"Previous input: {self.typed_char} , typed text: {self.typed_text}") + self.update_text(self.typed_text, "None", char) + + def color_handler(self, element: ColorPickEventArguments) -> None: + """Handle events when user selects a color. + + Identifies closest color in dictionary and maps that color to a text output that is displayed to the user. + Black maps to backspace and white maps to space. Otherwise, colors map to the first letter of their name in the + color dictionary. + """ + print(type(element)) + selected_color_hex = element.color + self.selected_color = self.find_closest_member(selected_color_hex) + if self.selected_color == "black": + self.typed_char = "backspace" + if len(self.typed_text) > 0: + self.typed_text = self.typed_text[:-1] + elif self.selected_color == "white": + self.typed_char = "space" + self.typed_text += " " + else: + if self.shift_key_on: + self.typed_char = self.selected_color[0].upper() + else: + self.typed_char = self.selected_color[0] + self.typed_text += self.typed_char + ui.notify(f"Color: {self.selected_color}, Previous input: {self.typed_char}, typed text: {self.typed_text}") + self.update_text(self.typed_text, self.selected_color, self.typed_char) + + def shift_handler(self) -> None: + """Switch shift key on/off. The color_handler() method deals with capitalizing output.""" + self.shift_key_on = not self.shift_key_on + + def update_text(self, typed_txt: str, color_txt: str, input_txt: str) -> None: + """Update text on page. + + Page displays the last color selected, the last character input, and the current string user has typed. If a + special character was selected the last color selected is "None." + + :param typed_txt: string representing what user has typed so far + :param color_txt: last color selected by user + :param input_txt: last character input by user + :return: None + """ + self.color_label.text = f"Selected Color: {color_txt}" + self.input_label.text = f"Previous Input: {input_txt}" + self.text_label.text = f"Typed Text: {typed_txt}" + self.color_label.update() + self.input_label.update() + self.text_label.update() + + @ui.page("/color_input") + def color_input_page(self) -> None: + """Create page displaying color_picker, character buttons, and text.""" + with ui.header(): + ui.label("Title text here?") + + with ui.left_drawer(): + ui.label("Description or instructions here?") + ui.separator().props("color = black") + ui.switch("SHIFT", on_change=self.shift_handler) + ui.separator().props("color = black") + + # creating wrappers to pass callback functions with parameters to buttons below + callback_with_period = partial(self.special_character_handler, ".") + callback_with_exclamation = partial(self.special_character_handler, "!") + callback_with_comma = partial(self.special_character_handler, ",") + callback_with_question_mark = partial(self.special_character_handler, "?") + + ui.label("Special Characters:") + with ui.grid(columns=2): + ui.button(".", on_click=callback_with_period) + ui.button("!", on_click=callback_with_exclamation) + ui.button(",", on_click=callback_with_comma) + ui.button("?", on_click=callback_with_question_mark) + + with ui.right_drawer(): + ui.label("Something could go here also") + + # ui labels displaying selected color, last input character, and text typed by user + with ui.row(): # .classes('w-full border') + self.color_label.text = f"Selected Color: {self.selected_color}" + self.input_label.text = f"Previous Input: {self.typed_char}" + self.text_label.text = f"Typed Text: {self.typed_text}" + + with ui.row(), ui.button(icon="colorize"): + # color picker currently disappears if user clicks outside of palette + # no-parent-event toggle does not fix this as it only applies to immediate parent not entire screen + # as is user can reopen color palette by pressing the button again + ui.color_picker(on_pick=self.color_handler, value=True) # .props('no-parent-event') + + ui.run() + + def find_closest_member(self, color_hex: str) -> str: + """Compare color hexcode to each color in class dicitionary. Return closest color. + + Takes a color hexcode and compares it to all colors in the class dictionary, finding the "closest" color + in the dictionary. Uses the Euclidean distance metric on the RGB values of the hexcode to compute distance. + The function returns the key of the most similar dict entry, which is the name of a color name (string). + + :param color_hex: a color hexcode + :return: name (string) of the color with the closest hexcode + """ + color_dists = [ + (key, ColorInputManager.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict + ] + color_dists = sorted(color_dists, key=lambda e: e[1]) + + return color_dists[0][0] + + # Static methods below + + @staticmethod + def hex_to_rgb(color_hex: str) -> dict[str, int]: + """Return dictionary of RGB color values from color hexcode. + + Takes a color hexcode and returns a dictionary with (color,intensity) + key/value pairs for Red, Green, and Blue + + :param color_hex: string representing a color hexcode + :return: dictionary of color:intensity pairs + """ + hex_code_length = 6 + invalid_code = "Invalid color code" + if color_hex[0] == "#": + color_hex = color_hex[1:] + if len(color_hex) != hex_code_length: + raise ValueError(invalid_code) + + red_val = int(color_hex[0:2], 16) + green_val = int(color_hex[2:4], 16) + blue_val = int(color_hex[4:6], 16) + return {"red": red_val, "green": green_val, "blue": blue_val} + + @staticmethod + def color_dist(color_code1: str, color_code2: str) -> float: + """Return distance between two colors using their RGB values. + + Takes two hex_color codes and returns the "distance" between the colors. The distance is computed using the + Euclidean distance metric by treating the colors 3-tuples (RGB). Rounds to two decimal places. + + :param color_code1: string representing a color hexcode + :param color_code2: string representing a color hexcode + :return: float representing Euclidean distance between colors + """ + color_tuple_1 = ColorInputManager.hex_to_rgb(color_code1) + color_tuple_2 = ColorInputManager.hex_to_rgb(color_code2) + + red_delta = color_tuple_1["red"] - color_tuple_2["red"] + green_delta = color_tuple_1["green"] - color_tuple_2["green"] + blue_delta = color_tuple_1["blue"] - color_tuple_2["blue"] + + return round((red_delta**2 + green_delta**2 + blue_delta**2) ** 0.5, 2) + + +# Create page using code below +color_page = ColorInputManager() +color_page.color_input_page() From 4d29b62dd8d14850bc35d78d89db479b2b5cd584 Mon Sep 17 00:00:00 2001 From: jks85 Date: Wed, 13 Aug 2025 10:53:58 -0700 Subject: [PATCH 054/196] Adding directory with color input method implementation --- src/color_mixer_input/__init__.py | 216 ++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/color_mixer_input/__init__.py diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py new file mode 100644 index 00000000..7d042462 --- /dev/null +++ b/src/color_mixer_input/__init__.py @@ -0,0 +1,216 @@ +from functools import partial + +from nicegui import ui +from nicegui.events import ColorPickEventArguments + + +class ColorInputManager: + """Implements the color-based typing input page. + + Allows user to type using the color palette for letters, spaces, and backspaces, and UI buttons/switches for + special characters ('.', '!', Shift, etc.) + """ + + def __init__(self) -> None: + self.typed_text = "" + self.typed_char = None + self.selected_color = None + self.shift_key_on = False + self.color_label = ui.label("None") + self.input_label = ui.label("None") + self.text_label = ui.label("None") + self.color_dict = { + "aqua": "#00FFFF", + "blue": "#0000FF", + "cyan": "#00FFFF", + "darkblue": "#00008B", + "emerald": "00674F", + "fuchsia": "#FF00FF", + "gray": "#7F7F7F", + "hotpink": "#FF69B4", + "indigo": "#4B0082", + "jasmine": "#F8DE7E", + "khaki": "#F0E68C", + "lime": "#00FF00", + "maroon": "#800000", + "navy": "#000080", + "orange": "#FFA500", + "purple": "#800080", + "quicksilver": "#A6A6A6", + "red": "#FF0000", + "salmon": "#FA8072", + "teal": "#008080", + "ube": "#8878C3", + "viridian": "#40826D", + "walnut": "#773F1A", + "xanadu": "#738678", + "yellow": "#FFFF00", + "zara": "#B48784", + "black": "#000000", + "white": "#FFFFFF", + } + + def special_character_handler(self, char: str) -> None: + """Handle special character events. + + This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button + elements. + """ + self.typed_text += char + self.typed_char = char + self.update_text(self.typed_text, "None", char) + + def color_handler(self, element: ColorPickEventArguments) -> None: + """Handle events when user selects a color. + + Identifies closest color in dictionary and maps that color to a text output that is displayed to the user. + Black maps to backspace and white maps to space. Otherwise, colors map to the first letter of their name in the + color dictionary. + """ + print(type(element)) + selected_color_hex = element.color + self.selected_color = self.find_closest_member(selected_color_hex) + if self.selected_color == "black": + self.typed_char = "backspace" + if len(self.typed_text) > 0: + self.typed_text = self.typed_text[:-1] + elif self.selected_color == "white": + self.typed_char = "space" + self.typed_text += " " + else: + if self.shift_key_on: + self.typed_char = self.selected_color[0].upper() + else: + self.typed_char = self.selected_color[0] + self.typed_text += self.typed_char + self.update_text(self.typed_text, self.selected_color, self.typed_char) + + def shift_handler(self) -> None: + """Switch shift key on/off. The color_handler() method deals with capitalizing output.""" + self.shift_key_on = not self.shift_key_on + + def update_text(self, typed_txt: str, color_txt: str, input_txt: str) -> None: + """Update text on page. + + Page displays the last color selected, the last character input, and the current string user has typed. If a + special character was selected the last color selected is "None." + + :param typed_txt: string representing what user has typed so far + :param color_txt: last color selected by user + :param input_txt: last character input by user + :return: None + """ + self.color_label.text = f"Selected Color: {color_txt}" + self.input_label.text = f"Previous Input: {input_txt}" + self.text_label.text = f"Typed Text: {typed_txt}" + self.color_label.update() + self.input_label.update() + self.text_label.update() + + @ui.page("/color_input") + def color_input_page(self) -> None: + """Create page displaying color_picker, character buttons, and text.""" + with ui.header(): + ui.label("Title text here?") + + with ui.left_drawer(): + ui.label("Description or instructions here?") + ui.separator().props("color = black") + ui.switch("SHIFT", on_change=self.shift_handler) + ui.separator().props("color = black") + + # creating wrappers to pass callback functions with parameters to buttons below + callback_with_period = partial(self.special_character_handler, ".") + callback_with_exclamation = partial(self.special_character_handler, "!") + callback_with_comma = partial(self.special_character_handler, ",") + callback_with_question_mark = partial(self.special_character_handler, "?") + + ui.label("Special Characters:") + with ui.grid(columns=2): + ui.button(".", on_click=callback_with_period) + ui.button("!", on_click=callback_with_exclamation) + ui.button(",", on_click=callback_with_comma) + ui.button("?", on_click=callback_with_question_mark) + + with ui.right_drawer(): + ui.label("Something could go here also") + + # ui labels displaying selected color, last input character, and text typed by user + with ui.row(): # .classes('w-full border') + self.color_label.text = f"Selected Color: {self.selected_color}" + self.input_label.text = f"Previous Input: {self.typed_char}" + self.text_label.text = f"Typed Text: {self.typed_text}" + + with ui.row(), ui.button(icon="colorize"): + # color picker currently disappears if user clicks outside of palette + # no-parent-event toggle does not fix this as it only applies to immediate parent not entire screen + # as is user can reopen color palette by pressing the button again + ui.color_picker(on_pick=self.color_handler, value=True) # .props('no-parent-event') + + ui.run() + + def find_closest_member(self, color_hex: str) -> str: + """Compare color hexcode to each color in class dicitionary. Return closest color. + + Takes a color hexcode and compares it to all colors in the class dictionary, finding the "closest" color + in the dictionary. Uses the Euclidean distance metric on the RGB values of the hexcode to compute distance. + The function returns the key of the most similar dict entry, which is the name of a color name (string). + + :param color_hex: a color hexcode + :return: name (string) of the color with the closest hexcode + """ + color_dists = [ + (key, ColorInputManager.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict + ] + color_dists = sorted(color_dists, key=lambda e: e[1]) + + return color_dists[0][0] + + # Static methods below + + @staticmethod + def hex_to_rgb(color_hex: str) -> dict[str, int]: + """Return dictionary of RGB color values from color hexcode. + + Takes a color hexcode and returns a dictionary with (color,intensity) + key/value pairs for Red, Green, and Blue + + :param color_hex: string representing a color hexcode + :return: dictionary of color:intensity pairs + """ + hex_code_length = 6 + invalid_code = "Invalid color code" + if color_hex[0] == "#": + color_hex = color_hex[1:] + if len(color_hex) != hex_code_length: + raise ValueError(invalid_code) + + red_val = int(color_hex[0:2], 16) + green_val = int(color_hex[2:4], 16) + blue_val = int(color_hex[4:6], 16) + return {"red": red_val, "green": green_val, "blue": blue_val} + + @staticmethod + def color_dist(color_code1: str, color_code2: str) -> float: + """Return distance between two colors using their RGB values. + + Takes two hex_color codes and returns the "distance" between the colors. The distance is computed using the + Euclidean distance metric by treating the colors 3-tuples (RGB). Rounds to two decimal places. + + :param color_code1: string representing a color hexcode + :param color_code2: string representing a color hexcode + :return: float representing Euclidean distance between colors + """ + color_tuple_1 = ColorInputManager.hex_to_rgb(color_code1) + color_tuple_2 = ColorInputManager.hex_to_rgb(color_code2) + + red_delta = color_tuple_1["red"] - color_tuple_2["red"] + green_delta = color_tuple_1["green"] - color_tuple_2["green"] + blue_delta = color_tuple_1["blue"] - color_tuple_2["blue"] + + return round((red_delta**2 + green_delta**2 + blue_delta**2) ** 0.5, 2) + + +# Create page using code below +color_page = ColorInputManager() +color_page.color_input_page() From b68255ad6e73e64a320b013f121931951c9003ee Mon Sep 17 00:00:00 2001 From: Julian Simington Date: Wed, 13 Aug 2025 19:33:10 -0700 Subject: [PATCH 055/196] Delete __init__.py Pushed file instead of directory containing file --- __init__.py | 222 ---------------------------------------------------- 1 file changed, 222 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index b7f67c38..00000000 --- a/__init__.py +++ /dev/null @@ -1,222 +0,0 @@ -# noqa: N999 -from functools import partial - -from nicegui import ui -from nicegui.events import ColorPickEventArguments - -# Note: Most onscreen updates include pop-up notifications via ui.notify(). These were used to assist debugging -# but can/will be removed later - - -class ColorInputManager: - """Implements the color-based typing input page. - - Allows user to type using the color palette for letters, spaces, and backspaces, and UI buttons/switches for - special characters ('.', '!', Shift, etc.) - """ - - def __init__(self) -> None: - self.typed_text = "" - self.typed_char = None - self.selected_color = None - self.shift_key_on = False - self.color_label = ui.label("None") - self.input_label = ui.label("None") - self.text_label = ui.label("None") - self.color_dict = { - "aqua": "#00FFFF", - "blue": "#0000FF", - "cyan": "#00FFFF", - "darkblue": "#00008B", - "emerald": "00674F", - "fuchsia": "#FF00FF", - "gray": "#7F7F7F", - "hotpink": "#FF69B4", - "indigo": "#4B0082", - "jasmine": "#F8DE7E", - "khaki": "#F0E68C", - "lime": "#00FF00", - "maroon": "#800000", - "navy": "#000080", - "orange": "#FFA500", - "purple": "#800080", - "quicksilver": "#A6A6A6", - "red": "#FF0000", - "salmon": "#FA8072", - "teal": "#008080", - "ube": "#8878C3", - "viridian": "#40826D", - "walnut": "#773F1A", - "xanadu": "#738678", - "yellow": "#FFFF00", - "zara": "#B48784", - "black": "#000000", - "white": "#FFFFFF", - } - - def special_character_handler(self, char: str) -> None: - """Handle special character events. - - This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button - elements. - """ - self.typed_text += char - self.typed_char = char - ui.notify(f"Previous input: {self.typed_char} , typed text: {self.typed_text}") - self.update_text(self.typed_text, "None", char) - - def color_handler(self, element: ColorPickEventArguments) -> None: - """Handle events when user selects a color. - - Identifies closest color in dictionary and maps that color to a text output that is displayed to the user. - Black maps to backspace and white maps to space. Otherwise, colors map to the first letter of their name in the - color dictionary. - """ - print(type(element)) - selected_color_hex = element.color - self.selected_color = self.find_closest_member(selected_color_hex) - if self.selected_color == "black": - self.typed_char = "backspace" - if len(self.typed_text) > 0: - self.typed_text = self.typed_text[:-1] - elif self.selected_color == "white": - self.typed_char = "space" - self.typed_text += " " - else: - if self.shift_key_on: - self.typed_char = self.selected_color[0].upper() - else: - self.typed_char = self.selected_color[0] - self.typed_text += self.typed_char - ui.notify(f"Color: {self.selected_color}, Previous input: {self.typed_char}, typed text: {self.typed_text}") - self.update_text(self.typed_text, self.selected_color, self.typed_char) - - def shift_handler(self) -> None: - """Switch shift key on/off. The color_handler() method deals with capitalizing output.""" - self.shift_key_on = not self.shift_key_on - - def update_text(self, typed_txt: str, color_txt: str, input_txt: str) -> None: - """Update text on page. - - Page displays the last color selected, the last character input, and the current string user has typed. If a - special character was selected the last color selected is "None." - - :param typed_txt: string representing what user has typed so far - :param color_txt: last color selected by user - :param input_txt: last character input by user - :return: None - """ - self.color_label.text = f"Selected Color: {color_txt}" - self.input_label.text = f"Previous Input: {input_txt}" - self.text_label.text = f"Typed Text: {typed_txt}" - self.color_label.update() - self.input_label.update() - self.text_label.update() - - @ui.page("/color_input") - def color_input_page(self) -> None: - """Create page displaying color_picker, character buttons, and text.""" - with ui.header(): - ui.label("Title text here?") - - with ui.left_drawer(): - ui.label("Description or instructions here?") - ui.separator().props("color = black") - ui.switch("SHIFT", on_change=self.shift_handler) - ui.separator().props("color = black") - - # creating wrappers to pass callback functions with parameters to buttons below - callback_with_period = partial(self.special_character_handler, ".") - callback_with_exclamation = partial(self.special_character_handler, "!") - callback_with_comma = partial(self.special_character_handler, ",") - callback_with_question_mark = partial(self.special_character_handler, "?") - - ui.label("Special Characters:") - with ui.grid(columns=2): - ui.button(".", on_click=callback_with_period) - ui.button("!", on_click=callback_with_exclamation) - ui.button(",", on_click=callback_with_comma) - ui.button("?", on_click=callback_with_question_mark) - - with ui.right_drawer(): - ui.label("Something could go here also") - - # ui labels displaying selected color, last input character, and text typed by user - with ui.row(): # .classes('w-full border') - self.color_label.text = f"Selected Color: {self.selected_color}" - self.input_label.text = f"Previous Input: {self.typed_char}" - self.text_label.text = f"Typed Text: {self.typed_text}" - - with ui.row(), ui.button(icon="colorize"): - # color picker currently disappears if user clicks outside of palette - # no-parent-event toggle does not fix this as it only applies to immediate parent not entire screen - # as is user can reopen color palette by pressing the button again - ui.color_picker(on_pick=self.color_handler, value=True) # .props('no-parent-event') - - ui.run() - - def find_closest_member(self, color_hex: str) -> str: - """Compare color hexcode to each color in class dicitionary. Return closest color. - - Takes a color hexcode and compares it to all colors in the class dictionary, finding the "closest" color - in the dictionary. Uses the Euclidean distance metric on the RGB values of the hexcode to compute distance. - The function returns the key of the most similar dict entry, which is the name of a color name (string). - - :param color_hex: a color hexcode - :return: name (string) of the color with the closest hexcode - """ - color_dists = [ - (key, ColorInputManager.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict - ] - color_dists = sorted(color_dists, key=lambda e: e[1]) - - return color_dists[0][0] - - # Static methods below - - @staticmethod - def hex_to_rgb(color_hex: str) -> dict[str, int]: - """Return dictionary of RGB color values from color hexcode. - - Takes a color hexcode and returns a dictionary with (color,intensity) - key/value pairs for Red, Green, and Blue - - :param color_hex: string representing a color hexcode - :return: dictionary of color:intensity pairs - """ - hex_code_length = 6 - invalid_code = "Invalid color code" - if color_hex[0] == "#": - color_hex = color_hex[1:] - if len(color_hex) != hex_code_length: - raise ValueError(invalid_code) - - red_val = int(color_hex[0:2], 16) - green_val = int(color_hex[2:4], 16) - blue_val = int(color_hex[4:6], 16) - return {"red": red_val, "green": green_val, "blue": blue_val} - - @staticmethod - def color_dist(color_code1: str, color_code2: str) -> float: - """Return distance between two colors using their RGB values. - - Takes two hex_color codes and returns the "distance" between the colors. The distance is computed using the - Euclidean distance metric by treating the colors 3-tuples (RGB). Rounds to two decimal places. - - :param color_code1: string representing a color hexcode - :param color_code2: string representing a color hexcode - :return: float representing Euclidean distance between colors - """ - color_tuple_1 = ColorInputManager.hex_to_rgb(color_code1) - color_tuple_2 = ColorInputManager.hex_to_rgb(color_code2) - - red_delta = color_tuple_1["red"] - color_tuple_2["red"] - green_delta = color_tuple_1["green"] - color_tuple_2["green"] - blue_delta = color_tuple_1["blue"] - color_tuple_2["blue"] - - return round((red_delta**2 + green_delta**2 + blue_delta**2) ** 0.5, 2) - - -# Create page using code below -color_page = ColorInputManager() -color_page.color_input_page() From b91da401814fdb45e3483e6503c7f8e02f4aa3ed Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Wed, 13 Aug 2025 20:23:47 -0700 Subject: [PATCH 056/196] changed input to return text thus far instead of single letter --- src/audio_style_input/__init__.py | 74 ++++++++++++++----- src/code_jam_template.egg-info/PKG-INFO | 69 +++++++++++++++++ src/code_jam_template.egg-info/SOURCES.txt | 16 ++++ .../dependency_links.txt | 0 src/code_jam_template.egg-info/requires.txt | 1 + src/code_jam_template.egg-info/top_level.txt | 8 ++ 6 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 src/code_jam_template.egg-info/PKG-INFO create mode 100644 src/code_jam_template.egg-info/SOURCES.txt create mode 100644 src/code_jam_template.egg-info/dependency_links.txt create mode 100644 src/code_jam_template.egg-info/requires.txt create mode 100644 src/code_jam_template.egg-info/top_level.txt diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d3ff9575..8d7672e0 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,4 +1,5 @@ import asyncio +import string from collections.abc import Callable from pathlib import Path @@ -9,7 +10,11 @@ media = Path("./static") app.add_media_files("/media", media) -letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] +capital_letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] +lowercase_letters = [chr(i) for i in range(ord("a"), ord("z") + 1)] +special_chars = list(string.punctuation) + +char_selection = [capital_letters, lowercase_letters, special_chars] class AudioEditorComponent(input_method_proto.IInputMethod): @@ -17,12 +22,15 @@ class AudioEditorComponent(input_method_proto.IInputMethod): def __init__(self) -> None: self._text_update_callback: Callable[[str], None] | None = None + self.current_char_selection_index_container = [0] + self.current_chars_selected = char_selection[0] self.current_letter_index_container = [0] self.rotation_container = [0] self.normal_spin_speed = 5 self.boosted_spin_speed = 10 self.spin_speed_container = [self.normal_spin_speed] self.spin_direction_container = [1] + self.user_text_container = "" self.timer_task = None self.spin_task = None @@ -76,6 +84,16 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: buttons_row = ui.row().style("gap: 10px") return main_content, record, label, buttons_row + def cycle_char_select(self) -> None: + """Select character set from Capital, Lower, and Special characters.""" + self.current_char_selection_index_container[0] = (self.current_char_selection_index_container[0] + 1) % len( + char_selection, + ) + self.current_chars_selected = char_selection[self.current_char_selection_index_container[0]] + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len( + self.current_chars_selected, + ) + async def spin_continuous(self) -> None: """Continuously rotate the record image based on spin speed and direction.""" while True: @@ -86,8 +104,12 @@ async def spin_continuous(self) -> None: async def letter_spinner_task(self) -> None: """Continuously update the label with the current letter, cycling through letters.""" while True: - self.label.set_text(f"Current letter: {letters[self.current_letter_index_container[0]]}") - self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len(letters) + self.label.set_text( + f"Current letter: {self.current_chars_selected[self.current_letter_index_container[0]]}", + ) + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 1) % len( + self.current_chars_selected, + ) await asyncio.sleep(0.5) def start_spinning(self, *, clockwise: bool = True) -> None: @@ -140,36 +162,47 @@ def play_fast_forward_sound(self) -> None: def forward_3(self) -> None: """Skip forward 3 letters with sound and speed boost.""" - self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 3) % len(letters) + self.current_letter_index_container[0] = (self.current_letter_index_container[0] + 3) % len( + self.current_chars_selected, + ) self.play_fast_forward_sound() self.start_spinning(clockwise=True) self._forward_3_task = asyncio.create_task(self.speed_boost(final_direction=1)) def rewind_3(self) -> None: """Skip backward 3 letters with sound and speed boost.""" - self.current_letter_index_container[0] = (self.current_letter_index_container[0] - 3) % len(letters) + self.current_letter_index_container[0] = (self.current_letter_index_container[0] - 3) % len( + self.current_chars_selected, + ) self.play_rewind_sound() self.start_spinning(clockwise=False) self._speed_boost_task = asyncio.create_task(self.speed_boost(final_direction=1)) def setup_buttons(self) -> None: """Create UI buttons with their event handlers.""" - with self.buttons_row: - ui.button("Play", color="#d18b2b", on_click=lambda: [self.main_track.play(), self.on_play()]) - ui.button("Pause", color="#d18b2b", on_click=lambda: [self.main_track.pause(), self.on_pause()]) - ui.button("Rewind 3 Seconds", color="#d18b2b", on_click=self.rewind_3) - ui.button("Forward 3 Seconds", color="#d18b2b", on_click=self.forward_3) + with self.buttons_row, ui.button_group().classes("gap-1"): ui.button( - "Select Letter", - color="green", - on_click=self._select_letter_handler, + "Play", + color="#2bd157", + icon="play_arrow", + on_click=lambda: [self.main_track.play(), self.on_play()], ) + ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) + ui.button("Rewind 3 Seconds", color="#d18b2b", icon="fast_rewind", on_click=self.rewind_3) + ui.button( + "Pause", + color="#d18b2b", + icon="pause", + on_click=lambda: [self.main_track.pause(), self.on_pause()], + ) + ui.button("Forward 3 Seconds", color="#d18b2b", icon="fast_forward", on_click=self.forward_3) + ui.button("Next Set of Chars", icon="skip_next", on_click=self.cycle_char_select) def _select_letter_handler(self) -> None: """Notify selected letter and trigger text update callback.""" - letter = letters[self.current_letter_index_container[0] - 1] - ui.notify(f"You selected: {letter}") - self.select_letter(letter) + char = self.current_chars_selected[self.current_letter_index_container[0] - 1] + ui.notify(f"You selected: {char}") + self.select_letter(char) def start_audio_editor(self) -> None: """Hide intro card and show main content.""" @@ -185,12 +218,15 @@ def on_text_update(self, callback: Callable[[str], None]) -> None: """ self._text_update_callback = callback - def select_letter(self, letter: str) -> None: + def select_letter(self, char: str) -> None: """Call the registered callback with the selected letter. Args: - letter (str): The letter selected by the user. + char (str): The letter selected by the user. """ + if char != "back_space": + self.user_text_container += char + if self._text_update_callback: - self._text_update_callback(letter) + self._text_update_callback(self.user_text_container) diff --git a/src/code_jam_template.egg-info/PKG-INFO b/src/code_jam_template.egg-info/PKG-INFO new file mode 100644 index 00000000..e6f7ff69 --- /dev/null +++ b/src/code_jam_template.egg-info/PKG-INFO @@ -0,0 +1,69 @@ +Metadata-Version: 2.4 +Name: code-jam-template +Version: 0.1.0 +Summary: Add your description here +Author: Mannyvv, afx8732, enskyeing, husseinhirani, jks85, MeGaGiGaGon +Requires-Python: >=3.12 +Description-Content-Type: text/markdown +License-File: LICENSE.txt +Requires-Dist: nicegui~=2.22.2 +Dynamic: license-file + +# Monumental Monsteras CJ25 Project +Monumental Monsteras CJ25 Project is a typing speed test, +but with a twist: You cannot type with a normal keyboard. +You can only use the **wrong tool for the job**. + +Try different wrong methods of writing text, with a score at +the end if you would like to flex on your friends. + +Input methods: + +# Running the project +## Using `uv` (recommended) + +The recommended way to run the project is using `uv`. + +If you do not have `uv` installed, see https://docs.astral.sh/uv/getting-started/installation/ + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ uv run src/main.py +``` + +## Without `uv` + +``` +$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git +$ cd cj25-monumental-monsteras-team-repo.git +$ py -3.12 -m venv .venv +$ py -m pip install . +$ py src/main.py +``` + +# Contributing +## Setting up the project for development +If you do not have `pre-commit` installed, see https://pre-commit.com/#installation + +You can also use `uvx pre-commit` to run `pre-commit` commands without permanently installing it. + +Once you have `pre-commit` installed, run this command to set up the commit hooks. +``` +$ pre-commit install +``` + +## Development process +If the change you are making is large, open a new +issue and self-assign to make sure no duplicate work is done. + +When making a change: +1. Make a new branch on the main repository +2. Make commits to the branch +3. Open a PR from that branch to main + +You can run the pre-commit checks locally with: +``` +$ pre-commit run -a +``` +If you installed the commit hook in the previous step, they should also be run locally on commits. diff --git a/src/code_jam_template.egg-info/SOURCES.txt b/src/code_jam_template.egg-info/SOURCES.txt new file mode 100644 index 00000000..61f97cf2 --- /dev/null +++ b/src/code_jam_template.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +LICENSE.txt +README.md +pyproject.toml +src/homepage.py +src/input_method_proto.py +src/input_view.py +src/main.py +src/wpm_tester.py +src/audio_style_input/__init__.py +src/code_jam_template.egg-info/PKG-INFO +src/code_jam_template.egg-info/SOURCES.txt +src/code_jam_template.egg-info/dependency_links.txt +src/code_jam_template.egg-info/requires.txt +src/code_jam_template.egg-info/top_level.txt +src/rpg_text_input/__init__.py +src/sample_input_method/__init__.py diff --git a/src/code_jam_template.egg-info/dependency_links.txt b/src/code_jam_template.egg-info/dependency_links.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/code_jam_template.egg-info/requires.txt b/src/code_jam_template.egg-info/requires.txt new file mode 100644 index 00000000..d0e1609e --- /dev/null +++ b/src/code_jam_template.egg-info/requires.txt @@ -0,0 +1 @@ +nicegui~=2.22.2 diff --git a/src/code_jam_template.egg-info/top_level.txt b/src/code_jam_template.egg-info/top_level.txt new file mode 100644 index 00000000..b2912072 --- /dev/null +++ b/src/code_jam_template.egg-info/top_level.txt @@ -0,0 +1,8 @@ +audio_style_input +homepage +input_method_proto +input_view +main +rpg_text_input +sample_input_method +wpm_tester From 9bd111dc2a01cbfbc201aed3c528aab5298126fa Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Wed, 13 Aug 2025 20:45:18 -0700 Subject: [PATCH 057/196] added backspace feature and button --- src/audio_style_input/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 8d7672e0..d9984367 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -187,6 +187,7 @@ def setup_buttons(self) -> None: icon="play_arrow", on_click=lambda: [self.main_track.play(), self.on_play()], ) + ui.button("Eject", color="#d18b2b", icon="eject", on_click=self._delete_letter_handler) ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) ui.button("Rewind 3 Seconds", color="#d18b2b", icon="fast_rewind", on_click=self.rewind_3) ui.button( @@ -227,6 +228,17 @@ def select_letter(self, char: str) -> None: """ if char != "back_space": self.user_text_container += char + else: + self.user_text_container = self.user_text_container[:-1] if self._text_update_callback: self._text_update_callback(self.user_text_container) + + def _delete_letter_handler(self, char: str = "back_space") -> None: + """Delete the last letter in user string thus far. + + Args: + char(str): The code to delete last leter (back_space) + + """ + self.select_letter(char) From 835e6ce05aac7b4e13aa6b9a4f44f857a9a5d36b Mon Sep 17 00:00:00 2001 From: enskyeing Date: Wed, 13 Aug 2025 20:51:17 -0700 Subject: [PATCH 058/196] Update input method names --- src/homepage.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 123da7f0..ec8bc100 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -2,6 +2,7 @@ NAME: str = "PLACEHOLDER NAME" DESCRIPTION: str = "Placeholder Description" +INPUT_METHOD_NAMES: list = ["Record Player", "WASD", "Color Picker", "Circle Selector"] def home() -> None: @@ -68,5 +69,7 @@ def home() -> None: ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") ui.separator() with ui.element("div").classes("button-parent"): - for i in range(4): - ui.button(text=f"Input method {i + 1}", color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHOD_NAMES[0], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHOD_NAMES[1], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHOD_NAMES[2], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHOD_NAMES[3], color="#F9F9F9").classes("input-box") From 506dc6a7120ed6f9429cb71b559f3c4592d744d0 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Wed, 13 Aug 2025 20:59:20 -0700 Subject: [PATCH 059/196] Add list with input method info --- src/homepage.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index ec8bc100..1839c739 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -2,7 +2,12 @@ NAME: str = "PLACEHOLDER NAME" DESCRIPTION: str = "Placeholder Description" -INPUT_METHOD_NAMES: list = ["Record Player", "WASD", "Color Picker", "Circle Selector"] +INPUT_METHODS: list[tuple] = [ + ("Record Player", "/record-player"), + ("WASD", "/wasd"), + ("Color Picker", "/color-picker"), + ("Circle Selector", "/circle-selector"), +] # Tuple(name, path) def home() -> None: @@ -69,7 +74,7 @@ def home() -> None: ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") ui.separator() with ui.element("div").classes("button-parent"): - ui.button(text=INPUT_METHOD_NAMES[0], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHOD_NAMES[1], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHOD_NAMES[2], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHOD_NAMES[3], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHODS[0][0], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHODS[1][0], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHODS[2][0], color="#F9F9F9").classes("input-box") + ui.button(text=INPUT_METHODS[3][0], color="#F9F9F9").classes("input-box") From 378c802a19062932f885b86f88ffa3cfc412649d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Wed, 13 Aug 2025 21:40:02 -0700 Subject: [PATCH 060/196] Add button links --- src/homepage.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 1839c739..06c99d12 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -74,7 +74,23 @@ def home() -> None: ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") ui.separator() with ui.element("div").classes("button-parent"): - ui.button(text=INPUT_METHODS[0][0], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHODS[1][0], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHODS[2][0], color="#F9F9F9").classes("input-box") - ui.button(text=INPUT_METHODS[3][0], color="#F9F9F9").classes("input-box") + ui.button( + text=INPUT_METHODS[0][0], + color="#F9F9F9", + on_click=lambda: ui.navigate.to(INPUT_METHODS[0][1]), + ).classes("input-box") + ui.button( + text=INPUT_METHODS[1][0], + color="#F9F9F9", + on_click=lambda: ui.navigate.to(INPUT_METHODS[1][1]), + ).classes("input-box") + ui.button( + text=INPUT_METHODS[2][0], + color="#F9F9F9", + on_click=lambda: ui.navigate.to(INPUT_METHODS[2][1]), + ).classes("input-box") + ui.button( + text=INPUT_METHODS[3][0], + color="#F9F9F9", + on_click=lambda: ui.navigate.to(INPUT_METHODS[3][1]), + ).classes("input-box") From a42c30fb596f30ee8e3688987cf35fe69aa2661a Mon Sep 17 00:00:00 2001 From: enskyeing Date: Wed, 13 Aug 2025 21:41:37 -0700 Subject: [PATCH 061/196] Add homepage link support --- src/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.py b/src/main.py index 07297542..b9a61bda 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ ui.page("/")(homepage.home) +ui.page("/{method}")(wpm_tester.wpm_tester_page) # works with homepage links ui.run() From 8aaa2fa4b1d7126593ef0a31698bbce166b7e30b Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Wed, 13 Aug 2025 21:43:35 -0700 Subject: [PATCH 062/196] added space char and button --- src/audio_style_input/__init__.py | 38 ++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d9984367..6a059b49 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -36,7 +36,7 @@ def __init__(self) -> None: self.spin_task = None self.intro_card, self.start_button = self.create_intro_card() - self.main_content, self.record, self.label, self.buttons_row = self.create_main_content() + self.main_content, self.record, self.label, self.buttons_row, self.buttons_row_2 = self.create_main_content() self.main_track = ( ui.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3") @@ -82,7 +82,8 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: ).style("width: 300px; transition: transform 0.05s linear;") label = ui.label("Current letter: A") buttons_row = ui.row().style("gap: 10px") - return main_content, record, label, buttons_row + buttons_row_2 = ui.row().style("gap: 10x") + return main_content, record, label, buttons_row, buttons_row_2 def cycle_char_select(self) -> None: """Select character set from Capital, Lower, and Special characters.""" @@ -181,14 +182,6 @@ def rewind_3(self) -> None: def setup_buttons(self) -> None: """Create UI buttons with their event handlers.""" with self.buttons_row, ui.button_group().classes("gap-1"): - ui.button( - "Play", - color="#2bd157", - icon="play_arrow", - on_click=lambda: [self.main_track.play(), self.on_play()], - ) - ui.button("Eject", color="#d18b2b", icon="eject", on_click=self._delete_letter_handler) - ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) ui.button("Rewind 3 Seconds", color="#d18b2b", icon="fast_rewind", on_click=self.rewind_3) ui.button( "Pause", @@ -198,12 +191,22 @@ def setup_buttons(self) -> None: ) ui.button("Forward 3 Seconds", color="#d18b2b", icon="fast_forward", on_click=self.forward_3) ui.button("Next Set of Chars", icon="skip_next", on_click=self.cycle_char_select) + with self.buttons_row_2, ui.button_group().classes("gap-1"): + ui.button( + "Play", + color="#2bd157", + icon="play_arrow", + on_click=lambda: [self.main_track.play(), self.on_play()], + ) + ui.button("Eject", color="#d18b2b", icon="eject", on_click=self._delete_letter_handler) + ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) + ui.button("Mute", color="grey", icon="do_not_disturb", on_click=self._add_space_handler) def _select_letter_handler(self) -> None: """Notify selected letter and trigger text update callback.""" char = self.current_chars_selected[self.current_letter_index_container[0] - 1] ui.notify(f"You selected: {char}") - self.select_letter(char) + self.select_char(char) def start_audio_editor(self) -> None: """Hide intro card and show main content.""" @@ -219,7 +222,7 @@ def on_text_update(self, callback: Callable[[str], None]) -> None: """ self._text_update_callback = callback - def select_letter(self, char: str) -> None: + def select_char(self, char: str) -> None: """Call the registered callback with the selected letter. Args: @@ -241,4 +244,13 @@ def _delete_letter_handler(self, char: str = "back_space") -> None: char(str): The code to delete last leter (back_space) """ - self.select_letter(char) + self.select_char(char) + + def _add_space_handler(self, char: str = " ") -> None: + """Add a space in user string thus far. + + Args: + char(str): The space characer to add. + + """ + self.select_char(char) From c090c2f2249f4b990193627b88e6bda93670ea2d Mon Sep 17 00:00:00 2001 From: GiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:08:56 -0700 Subject: [PATCH 063/196] Update pyproject.toml to exclude COM812 --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 62cb9306..ae062bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,4 +66,6 @@ ignore = [ "TD002", "TD003", "FIX", + # Conflicts with formatter. + "COM812", ] From f2135a4ad2a3288fd9381e08c4304905c7e3200c Mon Sep 17 00:00:00 2001 From: jks85 Date: Fri, 15 Aug 2025 12:10:50 -0700 Subject: [PATCH 064/196] Implementing kam's fix to stop palette from minimizing. Other minor changes to layout. --- src/color_mixer_input/__init__.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index 7d042462..0f4a50c4 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -114,9 +114,9 @@ def color_input_page(self) -> None: ui.label("Title text here?") with ui.left_drawer(): - ui.label("Description or instructions here?") - ui.separator().props("color = black") - ui.switch("SHIFT", on_change=self.shift_handler) + ui.label("Special Keys:").style("font-weight:bold") + ui.separator().style("opacity:0") + ui.switch("CAPS LOCK", on_change=self.shift_handler) ui.separator().props("color = black") # creating wrappers to pass callback functions with parameters to buttons below @@ -125,7 +125,8 @@ def color_input_page(self) -> None: callback_with_comma = partial(self.special_character_handler, ",") callback_with_question_mark = partial(self.special_character_handler, "?") - ui.label("Special Characters:") + ui.label("Special Characters:").style("font-weight:bold") + ui.separator().style("opacity:0") with ui.grid(columns=2): ui.button(".", on_click=callback_with_period) ui.button("!", on_click=callback_with_exclamation) @@ -136,16 +137,12 @@ def color_input_page(self) -> None: ui.label("Something could go here also") # ui labels displaying selected color, last input character, and text typed by user - with ui.row(): # .classes('w-full border') - self.color_label.text = f"Selected Color: {self.selected_color}" - self.input_label.text = f"Previous Input: {self.typed_char}" - self.text_label.text = f"Typed Text: {self.typed_text}" + self.color_label.text = f"Selected Color: {self.selected_color}" + self.input_label.text = f"Previous Input: {self.typed_char}" + self.text_label.text = f"Typed Text: {self.typed_text}" - with ui.row(), ui.button(icon="colorize"): - # color picker currently disappears if user clicks outside of palette - # no-parent-event toggle does not fix this as it only applies to immediate parent not entire screen - # as is user can reopen color palette by pressing the button again - ui.color_picker(on_pick=self.color_handler, value=True) # .props('no-parent-event') + with ui.row(), ui.button(icon="colorize").style("opacity:0;pointer-events:none"): + ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") ui.run() @@ -209,8 +206,3 @@ def color_dist(color_code1: str, color_code2: str) -> float: blue_delta = color_tuple_1["blue"] - color_tuple_2["blue"] return round((red_delta**2 + green_delta**2 + blue_delta**2) ** 0.5, 2) - - -# Create page using code below -color_page = ColorInputManager() -color_page.color_input_page() From 321a2c986da31f250104e9ec8cc4ad07b1b1ab61 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Fri, 15 Aug 2025 19:19:20 -0700 Subject: [PATCH 065/196] combined play and pause button --- src/audio_style_input/__init__.py | 29 ++++++++++++++++++++--------- src/wpm_tester.py | 2 -- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 6a059b49..2da60fd3 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -25,6 +25,7 @@ def __init__(self) -> None: self.current_char_selection_index_container = [0] self.current_chars_selected = char_selection[0] self.current_letter_index_container = [0] + self.play_pause_toggle = [False] self.rotation_container = [0] self.normal_spin_speed = 5 self.boosted_spin_speed = 10 @@ -141,6 +142,21 @@ async def speed_boost(self, final_direction: int = 1) -> None: self.spin_speed_container[0] = self.normal_spin_speed self.spin_direction_container[0] = final_direction + def toggle_play_pause(self) -> None: + """Toggle play_pause state.""" + self.play_pause_toggle[0] = not self.play_pause_toggle[0] + self.play_pause_handler() + + def play_pause_handler(self) -> None: + """Play and puase the letter spinner and spinning.""" + toggle = self.play_pause_toggle[0] + + if toggle: + self.main_track.play() + self.on_play() + else: + self.main_track.pause(), self.on_pause() + def on_play(self) -> None: """Start letter spinner and spinning.""" if self.timer_task is None or self.timer_task.done(): @@ -183,20 +199,14 @@ def setup_buttons(self) -> None: """Create UI buttons with their event handlers.""" with self.buttons_row, ui.button_group().classes("gap-1"): ui.button("Rewind 3 Seconds", color="#d18b2b", icon="fast_rewind", on_click=self.rewind_3) - ui.button( - "Pause", - color="#d18b2b", - icon="pause", - on_click=lambda: [self.main_track.pause(), self.on_pause()], - ) ui.button("Forward 3 Seconds", color="#d18b2b", icon="fast_forward", on_click=self.forward_3) ui.button("Next Set of Chars", icon="skip_next", on_click=self.cycle_char_select) with self.buttons_row_2, ui.button_group().classes("gap-1"): ui.button( - "Play", + "Play/Pause", color="#2bd157", - icon="play_arrow", - on_click=lambda: [self.main_track.play(), self.on_play()], + icon="not_started", + on_click=lambda: [self.toggle_play_pause()], ) ui.button("Eject", color="#d18b2b", icon="eject", on_click=self._delete_letter_handler) ui.button("Record", color="red", icon="radio_button_checked", on_click=self._select_letter_handler) @@ -234,6 +244,7 @@ def select_char(self, char: str) -> None: else: self.user_text_container = self.user_text_container[:-1] + print("start!", self.user_text_container, "!end") if self._text_update_callback: self._text_update_callback(self.user_text_container) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index a8d42eeb..05b072b7 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -17,8 +17,6 @@ def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod return input_method_dict.get(inmth) - return None - @dataclass class WpmTesterPageState: From b62b0cea101569fd8d2a0bf6125d50ef3cecf568 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Fri, 15 Aug 2025 20:27:20 -0700 Subject: [PATCH 066/196] fixed issues with spaces and backspaces on input_view.py --- src/input_view.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/input_view.py b/src/input_view.py index eb7b67cc..26d0f8db 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -86,17 +86,23 @@ def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: if len(user_text) == 0: return iter(()) - mask = bytearray(user_text[i] == self.full_text[i] for i in range(min(len(self.full_text), len(user_text)))) + mask = [] + for i in range(len(user_text)): + if i < len(self.full_text): + mask.append(user_text[i] == self.full_text[i]) + else: + mask.append(False) + index = 0 cur_v = mask[0] while index < len(mask): - next_change_at = mask.find(cur_v ^ 1, index) - if next_change_at == -1: - next_change_at = len(mask) - yield (user_text[index:next_change_at], bool(cur_v)) + next_change_at = index + 1 + while next_change_at < len(mask) and mask[next_change_at] == cur_v: + next_change_at += 1 + yield (user_text[index:next_change_at], cur_v) index = next_change_at - cur_v ^= 1 + cur_v = not cur_v def set_original_text(self, value: str) -> None: """Reset the **background** text. You're probably looking for `set_text`. @@ -135,7 +141,8 @@ def on_key(key): return parsed = self._parse_text(value) with self.text_input: - for tok in parsed: - ui.label(tok[0]).classes("c" if tok[1] else "w") + for tok, correct in parsed: + ui.html(tok.replace(" ", " ")).classes("c" if correct else "w") + if len(value) < len(self.full_text): ui.label("_").classes("cursor") From 87dbaa725acc5434bac57e04db8679ebbcdf7a6c Mon Sep 17 00:00:00 2001 From: Skye <110286360+enskyeing@users.noreply.github.com> Date: Fri, 15 Aug 2025 22:16:52 -0700 Subject: [PATCH 067/196] Remove duplicate page link --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index b9a61bda..d2a5a23a 100644 --- a/src/main.py +++ b/src/main.py @@ -12,7 +12,7 @@ ui.page("/")(homepage.home) -ui.page("/{method}")(wpm_tester.wpm_tester_page) # works with homepage links ui.run() + From 2d0fd310f3ea462061cdc31220da6e604a9a8406 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:23:36 -0700 Subject: [PATCH 068/196] Add config.py --- config.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 config.py diff --git a/config.py b/config.py new file mode 100644 index 00000000..f3240459 --- /dev/null +++ b/config.py @@ -0,0 +1,34 @@ +PROJECT_NAME: str = "Dynamic Typing" +PROJECT_DESCRIPTION: str = "How fast can you type?" + +# INPUT METHODS +INPUT_METHODS: list[dict] = [ + { + "name": "Record Player", + "path": "/record-player", + "icon": "", + }, + { + "name": "WASD", + "path": "/wasd", + "icon": "", + }, + { + "name": "Color Picker", + "path": "/color-picker", + "icon": "", + }, + { + "name": "Circle Selector", + "path": "/circle-selector", + "icon": "", + }, +] + +# COLORS +COLOR_STYLE: dict = { + "PRIMARY": "", + "SECONDARY": "", + "PRIMARY_BG": "", + "SECONDARY_BG": "", +} From 4284e075b7adc39d8f2108fb39821e0cfe14566d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:29:13 -0700 Subject: [PATCH 069/196] Move input file to src --- config.py => src/config.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config.py => src/config.py (100%) diff --git a/config.py b/src/config.py similarity index 100% rename from config.py rename to src/config.py From 4fc6988b8f2d3eb035145e1510948291f030b534 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:30:18 -0700 Subject: [PATCH 070/196] Revert "Move input file to src" This reverts commit 4284e075b7adc39d8f2108fb39821e0cfe14566d. --- src/config.py => config.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/config.py => config.py (100%) diff --git a/src/config.py b/config.py similarity index 100% rename from src/config.py rename to config.py From ce176f1e620cf84bb03fc8971bad6467fcdafa95 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:31:15 -0700 Subject: [PATCH 071/196] Move config file to src --- config.py => src/config.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename config.py => src/config.py (100%) diff --git a/config.py b/src/config.py similarity index 100% rename from config.py rename to src/config.py From 1233b990c4de16844952fb73743bdc7d9117eb30 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:47:38 -0700 Subject: [PATCH 072/196] Remove hard-coded buttons --- src/homepage.py | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 06c99d12..dc85d4fe 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -1,13 +1,6 @@ from nicegui import ui -NAME: str = "PLACEHOLDER NAME" -DESCRIPTION: str = "Placeholder Description" -INPUT_METHODS: list[tuple] = [ - ("Record Player", "/record-player"), - ("WASD", "/wasd"), - ("Color Picker", "/color-picker"), - ("Circle Selector", "/circle-selector"), -] # Tuple(name, path) +from config import INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME def home() -> None: @@ -67,30 +60,16 @@ def home() -> None: ui.header(fixed=False).style("background-color: #20A39E").classes("items-center thick-header"), ui.column(align_items="center").style("gap: 0px;"), ): - ui.label(NAME).classes("site-title") - ui.label(DESCRIPTION).classes("site-subtitle") + ui.label(PROJECT_NAME).classes("site-title") + ui.label(PROJECT_DESCRIPTION).classes("site-subtitle") with ui.element("div").classes("page-div"): ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") ui.separator() with ui.element("div").classes("button-parent"): - ui.button( - text=INPUT_METHODS[0][0], - color="#F9F9F9", - on_click=lambda: ui.navigate.to(INPUT_METHODS[0][1]), - ).classes("input-box") - ui.button( - text=INPUT_METHODS[1][0], - color="#F9F9F9", - on_click=lambda: ui.navigate.to(INPUT_METHODS[1][1]), - ).classes("input-box") - ui.button( - text=INPUT_METHODS[2][0], - color="#F9F9F9", - on_click=lambda: ui.navigate.to(INPUT_METHODS[2][1]), - ).classes("input-box") - ui.button( - text=INPUT_METHODS[3][0], - color="#F9F9F9", - on_click=lambda: ui.navigate.to(INPUT_METHODS[3][1]), - ).classes("input-box") + for input in INPUT_METHODS: + ui.button( + text=input["name"], + color="#F9F9F9", + on_click=lambda _, path=input["path"]: ui.navigate.to(path), + ).classes("input-box") From c410ee26371c387f74e86c5be99521e80dfd166d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Fri, 15 Aug 2025 22:49:28 -0700 Subject: [PATCH 073/196] Remove extra line --- src/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.py b/src/main.py index d2a5a23a..07297542 100644 --- a/src/main.py +++ b/src/main.py @@ -15,4 +15,3 @@ ui.run() - From 92487506de2df821a2acd513c2710bc2d1cc4249 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 12:14:42 -0700 Subject: [PATCH 074/196] Change button navigation to /test --- src/homepage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/homepage.py b/src/homepage.py index dc85d4fe..99c52428 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -71,5 +71,5 @@ def home() -> None: ui.button( text=input["name"], color="#F9F9F9", - on_click=lambda _, path=input["path"]: ui.navigate.to(path), + on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path), ).classes("input-box") From d6621974b5889c37fde015b42654a76d087bd3f0 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 12:16:23 -0700 Subject: [PATCH 075/196] Update audio input path --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index f3240459..802600c6 100644 --- a/src/config.py +++ b/src/config.py @@ -5,7 +5,7 @@ INPUT_METHODS: list[dict] = [ { "name": "Record Player", - "path": "/record-player", + "path": "/audio-input", "icon": "", }, { From 54b9f7de52cc63e014e8face9d6f77e95c30f855 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 12:20:59 -0700 Subject: [PATCH 076/196] Add components to input methods --- src/config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config.py b/src/config.py index 802600c6..00b49ea6 100644 --- a/src/config.py +++ b/src/config.py @@ -1,3 +1,6 @@ +import audio_style_input +import rpg_text_input + PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" @@ -7,21 +10,25 @@ "name": "Record Player", "path": "/audio-input", "icon": "", + "component": audio_style_input, }, { "name": "WASD", "path": "/wasd", "icon": "", + "component": rpg_text_input, }, { "name": "Color Picker", "path": "/color-picker", "icon": "", + "component": "", }, { "name": "Circle Selector", "path": "/circle-selector", "icon": "", + "component": "", }, ] From 54ad25e6eff5d76368ebfbf850a95a451bd13c04 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 12:28:25 -0700 Subject: [PATCH 077/196] Remove forward slash in path --- src/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.py b/src/config.py index 00b49ea6..a018f6c1 100644 --- a/src/config.py +++ b/src/config.py @@ -8,25 +8,25 @@ INPUT_METHODS: list[dict] = [ { "name": "Record Player", - "path": "/audio-input", + "path": "audio-input", "icon": "", "component": audio_style_input, }, { "name": "WASD", - "path": "/wasd", + "path": "wasd", "icon": "", "component": rpg_text_input, }, { "name": "Color Picker", - "path": "/color-picker", + "path": "color-picker", "icon": "", "component": "", }, { "name": "Circle Selector", - "path": "/circle-selector", + "path": "circle-selector", "icon": "", "component": "", }, From b6d994c8fdea1290f7256ac817ab6cf0590d6be4 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sat, 16 Aug 2025 12:41:27 -0700 Subject: [PATCH 078/196] fixed imports --- src/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.py b/src/config.py index a018f6c1..a4a98c79 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,5 @@ -import audio_style_input -import rpg_text_input +from audio_style_input import AudioEditorComponent +from rpg_text_input import rpg_text_input_page PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" @@ -10,13 +10,13 @@ "name": "Record Player", "path": "audio-input", "icon": "", - "component": audio_style_input, + "component": AudioEditorComponent, }, { "name": "WASD", "path": "wasd", "icon": "", - "component": rpg_text_input, + "component": rpg_text_input_page, }, { "name": "Color Picker", From 16740fab4ef121375ff3e29bcc72237c6c16c33a Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sat, 16 Aug 2025 12:59:32 -0700 Subject: [PATCH 079/196] connected homepage to wpm_tester.py via INPUT_METHODS --- src/audio_style_input/__init__.py | 1 - src/config.py | 7 +++---- src/wpm_tester.py | 10 +++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 2da60fd3..1407df7a 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -244,7 +244,6 @@ def select_char(self, char: str) -> None: else: self.user_text_container = self.user_text_container[:-1] - print("start!", self.user_text_container, "!end") if self._text_update_callback: self._text_update_callback(self.user_text_container) diff --git a/src/config.py b/src/config.py index a4a98c79..eda55c1a 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,4 @@ from audio_style_input import AudioEditorComponent -from rpg_text_input import rpg_text_input_page PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" @@ -16,19 +15,19 @@ "name": "WASD", "path": "wasd", "icon": "", - "component": rpg_text_input_page, + "component": None, }, { "name": "Color Picker", "path": "color-picker", "icon": "", - "component": "", + "component": None, }, { "name": "Circle Selector", "path": "circle-selector", "icon": "", - "component": "", + "component": None, }, ] diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 05b072b7..b0d88f8a 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -4,8 +4,7 @@ import input_method_proto import input_view -from audio_style_input import AudioEditorComponent -from sample_input_method import SampleInputMethod +from config import INPUT_METHODS def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: @@ -13,9 +12,10 @@ def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod :returns: `type[IInputMethod]` on success, `None` on failure. """ - input_method_dict = {"audio_input": AudioEditorComponent, "sample": SampleInputMethod} - - return input_method_dict.get(inmth) + for input_method in INPUT_METHODS: + if inmth == input_method["path"]: + return input_method["component"] + return None @dataclass From 5e7988db651fee71f27d7e09ec3406c277bb876b Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sat, 16 Aug 2025 16:28:53 -0700 Subject: [PATCH 080/196] added timer --- src/audio_style_input/__init__.py | 4 ++-- src/input_view.py | 12 ++++++++++++ src/wpm_tester.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 1407df7a..3bda1369 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -57,7 +57,7 @@ def create_intro_card(self) -> tuple[ui.card, ui.button]: tuple: (intro_card, start_button) """ - intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#d18b2b]") + intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#2b87d1]") with intro_card, ui.card().classes("no-shadow justify-center items-center"): ui.label("WPM Battle: DJ Edition").classes("text-[86px]") ui.label("Use an audio editor to test your typing skills").classes("text-[28px]") @@ -75,7 +75,7 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: with ( main_content, ui.card().classes( - "gap-8 w-[100vw] h-[50vh] flex flex-col justify-center items-center bg-[#2b87d1]", + "gap-8 w-[100vw] h-[75vh] flex flex-col justify-center items-center bg-[#2b87d1]", ), ): record = ui.image( diff --git a/src/input_view.py b/src/input_view.py index 26d0f8db..42599087 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -81,6 +81,18 @@ def __init__(self, full_text: str) -> None: self.full_text = full_text self.value = "" + self.minutes, self.seconds = 0, 0 + + def update_timer(self) -> str: + """Update timer to get min and secs.""" + print(f"{self.minutes:02d}:{self.seconds:02d}", end="\r") + self.seconds += 1 + seconds_60 = 60 + if self.seconds == seconds_60: + self.seconds = 0 + self.minutes += 1 + return f"{self.minutes:02d}:{self.seconds:02d}" + def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: """Get a token list of string slices and whether they are correct.""" if len(user_text) == 0: diff --git a/src/wpm_tester.py b/src/wpm_tester.py index b0d88f8a..4f4ce238 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -34,6 +34,8 @@ async def wpm_tester_page(method: str) -> None: the method from the url """ state = WpmTesterPageState("") + timer_on = False + timer_container = None input_method_def = get_input_method_by_name(method) if input_method_def is None: @@ -46,10 +48,23 @@ async def wpm_tester_page(method: str) -> None: # TODO: get og text from babbler module iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") + timer_label = ui.label() + input_method = input_method_def() def on_text_update(txt: str) -> None: + nonlocal timer_on, timer_container + if not timer_on: + timer_container = ui.timer(1, lambda: timer_label.set_text(iv.update_timer())) + timer_on = True iv.set_text(txt) state.text = txt + def stop_timer() -> None: + nonlocal timer_container + if timer_container: + timer_container.deactivate() + input_method.on_text_update(on_text_update) + + ui.on("disconnect", stop_timer) From ffdbf67e27de838c42910c2efe2df638e7e6dbd0 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sat, 16 Aug 2025 17:12:50 -0700 Subject: [PATCH 081/196] added wpm and wph with buttons --- src/input_view.py | 3 +-- src/wpm_tester.py | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/input_view.py b/src/input_view.py index 42599087..630bd354 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -85,13 +85,12 @@ def __init__(self, full_text: str) -> None: def update_timer(self) -> str: """Update timer to get min and secs.""" - print(f"{self.minutes:02d}:{self.seconds:02d}", end="\r") self.seconds += 1 seconds_60 = 60 if self.seconds == seconds_60: self.seconds = 0 self.minutes += 1 - return f"{self.minutes:02d}:{self.seconds:02d}" + return f"TIMER: {self.minutes:02d}:{self.seconds:02d}" def _parse_text(self, user_text: str) -> Iterator[tuple[str, bool]]: """Get a token list of string slices and whether they are correct.""" diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 4f4ce238..f34e3795 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -1,3 +1,4 @@ +import time from dataclasses import dataclass from nicegui import ui @@ -36,6 +37,7 @@ async def wpm_tester_page(method: str) -> None: state = WpmTesterPageState("") timer_on = False timer_container = None + start_time = None input_method_def = get_input_method_by_name(method) if input_method_def is None: @@ -46,20 +48,33 @@ async def wpm_tester_page(method: str) -> None: ui.label(f"test: {method}").classes("text-center text-lg") # TODO: get og text from babbler module - iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") - - timer_label = ui.label() + text_to_use = "the quick brown fox jumps over the lazy dog" + iv = input_view.input_view(text_to_use).classes("w-full") + with ui.row().classes("w-full justify-center items-center gap-4"): + timer_label = ui.button("Timer: 0:00", color="#00FF00") + wpm_label = ui.button("WPM: --", color="#e5e5e5") + wph_label = ui.button("WPH: --", color="#e5e5e5") input_method = input_method_def() def on_text_update(txt: str) -> None: - nonlocal timer_on, timer_container + nonlocal timer_on, timer_container, text_to_use, start_time if not timer_on: timer_container = ui.timer(1, lambda: timer_label.set_text(iv.update_timer())) timer_on = True + start_time = time.time() iv.set_text(txt) state.text = txt + if len(txt) == len(text_to_use): + elapsed_seconds = time.time() - start_time + if elapsed_seconds > 0: + chars_typed = len(txt) + wpm = (chars_typed / 5) / (elapsed_seconds / 60) + wpm_label.set_text(f"Finished! WPM: {int(wpm)}") + wph_label.set_text(f"Finished! WPH: {int(wpm * 60)}") + stop_timer() + def stop_timer() -> None: nonlocal timer_container if timer_container: From 04697fde2fce03c7e5df22a172c2e3759ab519d1 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sat, 16 Aug 2025 17:27:56 -0700 Subject: [PATCH 082/196] styled to timer,wpm,wph --- src/wpm_tester.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index f34e3795..ca8fc453 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -51,9 +51,9 @@ async def wpm_tester_page(method: str) -> None: text_to_use = "the quick brown fox jumps over the lazy dog" iv = input_view.input_view(text_to_use).classes("w-full") with ui.row().classes("w-full justify-center items-center gap-4"): - timer_label = ui.button("Timer: 0:00", color="#00FF00") - wpm_label = ui.button("WPM: --", color="#e5e5e5") - wph_label = ui.button("WPH: --", color="#e5e5e5") + timer_label = ui.chip("TIMER: 0:00", color="#6AC251", icon="timer") + wpm_label = ui.chip("WPM: --", color="#e5e5e5", icon="watch") + wph_label = ui.chip("WPH: --", color="#e5e5e5", icon="hourglass_top") input_method = input_method_def() From cc7ad36067c0539004b170ed1d56e7332cfede0d Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sat, 16 Aug 2025 20:56:15 -0500 Subject: [PATCH 083/196] Basic movement and rendering --- src/platformer_input/__init__.py | 51 +++++++++++++++ src/platformer_input/platformer_constants.py | 41 ++++++++++++ src/platformer_input/platformer_scene_cmp.py | 64 +++++++++++++++++++ src/platformer_input/platformer_simulation.py | 38 +++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/platformer_input/__init__.py create mode 100644 src/platformer_input/platformer_constants.py create mode 100644 src/platformer_input/platformer_scene_cmp.py create mode 100644 src/platformer_input/platformer_simulation.py diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py new file mode 100644 index 00000000..335fce9e --- /dev/null +++ b/src/platformer_input/__init__.py @@ -0,0 +1,51 @@ +import typing + +import nicegui.events +from nicegui import ui + +import input_method_proto +from platformer_input.platformer_scene_cmp import PlatformerSceneComponent +from platformer_input.platformer_simulation import PlatformerPhysicsSimulation + +ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") +INITIAL_POS = (0, 10) + + +class PlatformerInputMethod(input_method_proto.IInputMethod): + """The platformer input method. + + Users will control a 2D platformer player to move around and bump into blocks + to enter characters. + """ + + callbacks: list[typing.Callable[[str], None]] + scene: PlatformerSceneComponent + held_keys: set[str] + + def __init__(self) -> None: + self.callbacks = [] + self.inp = PlatformerSceneComponent(INITIAL_POS) + self.physics = PlatformerPhysicsSimulation(INITIAL_POS) + self.held_keys = set() + ui.keyboard(lambda e: self.keyboard_handler(e)) + + def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: + """Call with the nicegui keyboard callback.""" + evk = str(event.key) + if evk == "Enter" and event.action.keyup and not event.action.repeat: + self.physics.tick() + self.inp.draw_scene(self.physics.player_x, self.physics.player_y) + return + + if event.action.repeat or evk not in ALLOWED_KEYS: + return + + if event.action.keydown: + self.held_keys.add(evk) + elif event.action.keyup and evk in self.held_keys: + self.held_keys.remove(evk) + self.physics.set_held_keys(self.held_keys) + + def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + """Call `callback` every time the user input changes.""" + self.callbacks.append(callback) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py new file mode 100644 index 00000000..dd1dd55c --- /dev/null +++ b/src/platformer_input/platformer_constants.py @@ -0,0 +1,41 @@ +TILE_SIZE = 10 +SCENE_WIDTH = 45 +SCENE_HEIGHT = 30 +TILE_SIZE_ML = 3 + +JUMP_FORCE = 5 +MOV_SPEED = 10 + +COLOR_BG = "skyblue" +COLOR_PLAYER = "purple" +COLOR_GROUND = "#181818" + + +SCENE = """###################################### + u v w x y z . ! , + + ############ ############## + + k l m n o p q r s t + + ############## ################ + + a b c d e f g h i j + +###################################### +""" + + +def world_grid() -> list[list[str]]: + """Get a grid of one-character `str`s representing the scene as a grid.""" + lines = SCENE.splitlines() + max_length = max(len(ln) for ln in lines) + + grid = [] + for line in lines: + lst = list(line) + if len(line) < max_length: + lst.extend(" " for _ in range(max_length - len(line))) + grid.append(lst) + + return grid diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py new file mode 100644 index 00000000..34fc9114 --- /dev/null +++ b/src/platformer_input/platformer_scene_cmp.py @@ -0,0 +1,64 @@ +import math + +from nicegui import ui + +import platformer_input.platformer_constants as c + + +class PlatformerSceneComponent(ui.element): + """Displays the characters and scene within the game.""" + + e: ui.element + world: list[list[str]] + + def __init__(self, position: tuple[int, int]) -> None: + super().__init__("div") + + self.e = ui.element("div") + self.e.style( + f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" + f"background-color: {c.COLOR_BG}; position: relative" + ) + + self.world = c.world_grid() + self.player_center_x_offset = math.floor((c.SCENE_WIDTH - c.TILE_SIZE_ML) / 2) + self.player_center_y_offset = c.TILE_SIZE_ML + + self.draw_scene(*position) + + def draw_scene(self, player_x: int, player_y: int) -> None: + """Draw the scene, player, etc.""" + self.e.clear() + with self.e: + self._dynctx_draw_scene((player_x, player_y)) + + def _dynctx_draw_scene(self, player_pos: tuple[int, int]) -> None: + """Draws a scene in any context.""" + for ypos, row in enumerate(self.world): + for xpos, cell in enumerate(row): + if cell == " ": + continue + + color = "black" if cell == "#" else "red" + tile_x = (xpos - player_pos[0]) * c.TILE_SIZE_ML + self.player_center_x_offset + tile_y = (ypos - (round(c.SCENE_HEIGHT / c.TILE_SIZE_ML) - player_pos[1])) * c.TILE_SIZE_ML + self._sc_create_sq_tile(tile_x, tile_y, color) + self._sc_draw_player() + + def _sc_draw_player(self) -> None: + """Draws the player.""" + player_x = self.player_center_x_offset + player_y = self.player_center_y_offset + + self._sc_create_sq_tile(player_x, player_y, c.COLOR_PLAYER) + + def _sc_create_sq_tile(self, x: int, y: int, col: str) -> None: + """Draw a full-color tile onto the scene.""" + for offset_x in range(c.TILE_SIZE_ML): + for offset_y in range(c.TILE_SIZE_ML): + nx = x + offset_x + ny = y + offset_y + ui.element("div").style( + f"""background-color: {col}; width: {c.TILE_SIZE}px; height: {c.TILE_SIZE}px; + position: absolute; left: {nx * c.TILE_SIZE}px; bottom: {ny * c.TILE_SIZE}px;""" + ) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py new file mode 100644 index 00000000..462d6263 --- /dev/null +++ b/src/platformer_input/platformer_simulation.py @@ -0,0 +1,38 @@ +import platformer_input.platformer_constants as constants + + +class PlatformerPhysicsSimulation: + """The physics simulation.""" + + player_x: int + player_y: int + _keys: set[str] + _world: list[list[str]] + + def __init__(self, initial: tuple[int, int]) -> None: + self.player_x, self.player_y = initial + self._keys = set() + self._world = constants.world_grid() + self._world.reverse() + + def set_held_keys(self, keys: set[str]) -> None: + """Set the current player-held keys.""" + self._keys = keys + + def tick(self) -> None: + """Run a tick of the simulation.""" + print(self.player_x, self.player_y) + if "ArrowRight" in self._keys: + self.player_x += 1 + if "ArrowLeft" in self._keys: + self.player_x -= 1 + if "ArrowUp" in self._keys: + self.player_y -= 1 + if "ArrowDown" in self._keys: + self.player_y += 1 + + print(self.player_x, self.player_y) + + def _collide(self, world: tuple[int, int]) -> bool: + """Check if a target cell contains a wall.""" + return self._world[world[1]][world[0]] == "#" From c31385eeb32734a4d7bc3375f1d2d356a1603b6e Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sat, 16 Aug 2025 21:39:49 -0500 Subject: [PATCH 084/196] import platformer input method in config --- src/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config.py b/src/config.py index eda55c1a..e76f9799 100644 --- a/src/config.py +++ b/src/config.py @@ -1,4 +1,5 @@ from audio_style_input import AudioEditorComponent +from platformer_input import PlatformerInputMethod PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" @@ -29,6 +30,7 @@ "icon": "", "component": None, }, + {"name": "Platformer", "path": "platformer", "icon": "", "component": PlatformerInputMethod}, ] # COLORS From 10d94bf6b5e079032a74cb98cbdbbbc7905ac7cf Mon Sep 17 00:00:00 2001 From: jks85 Date: Sat, 16 Aug 2025 20:22:33 -0700 Subject: [PATCH 085/196] Adding button so user can confirm letters separately from selecting them via color clicks --- src/color_mixer_input/__init__.py | 74 +++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index 0f4a50c4..8b5f08eb 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -1,3 +1,4 @@ +import string from functools import partial from nicegui import ui @@ -12,12 +13,16 @@ class ColorInputManager: """ def __init__(self) -> None: + # typed_char is input displayed to user based on selected color + # confirmed_char is character typed after user confirmation self.typed_text = "" self.typed_char = None + self.confirmed_char = None self.selected_color = None self.shift_key_on = False self.color_label = ui.label("None") self.input_label = ui.label("None") + self.confirm_label = ui.label("None") self.text_label = ui.label("None") self.color_dict = { "aqua": "#00FFFF", @@ -56,9 +61,23 @@ def special_character_handler(self, char: str) -> None: This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button elements. """ + self.selected_color = None self.typed_text += char self.typed_char = char - self.update_text(self.typed_text, "None", char) + self.confirmed_char = char + self.update_helper_text() + self.update_confirmation_text() + + def confirm_letter_handler(self) -> None: + """Handle event when user clicks 'Confirm Letter'. + + After user clicks confirm, letter is typed and text displays update. + """ + alphabet = string.ascii_letters + if self.typed_char in alphabet: + self.confirmed_char = self.typed_char + self.typed_text += self.confirmed_char + self.update_confirmation_text() def color_handler(self, element: ColorPickEventArguments) -> None: """Handle events when user selects a color. @@ -70,41 +89,48 @@ def color_handler(self, element: ColorPickEventArguments) -> None: print(type(element)) selected_color_hex = element.color self.selected_color = self.find_closest_member(selected_color_hex) + if self.selected_color == "black": self.typed_char = "backspace" if len(self.typed_text) > 0: self.typed_text = self.typed_text[:-1] + self.confirmed_char = self.typed_char elif self.selected_color == "white": - self.typed_char = "space" self.typed_text += " " + self.typed_char = "space" + self.confirmed_char = self.typed_char + elif self.shift_key_on: + self.typed_char = self.selected_color[0].upper() else: - if self.shift_key_on: - self.typed_char = self.selected_color[0].upper() - else: - self.typed_char = self.selected_color[0] - self.typed_text += self.typed_char - self.update_text(self.typed_text, self.selected_color, self.typed_char) + self.typed_char = self.selected_color[0] + + if self.typed_char in ["backspace", "space"]: + self.update_confirmation_text() + self.update_helper_text() def shift_handler(self) -> None: """Switch shift key on/off. The color_handler() method deals with capitalizing output.""" self.shift_key_on = not self.shift_key_on - def update_text(self, typed_txt: str, color_txt: str, input_txt: str) -> None: - """Update text on page. - - Page displays the last color selected, the last character input, and the current string user has typed. If a - special character was selected the last color selected is "None." + def update_helper_text(self) -> None: + """Update helper text on page. - :param typed_txt: string representing what user has typed so far - :param color_txt: last color selected by user - :param input_txt: last character input by user - :return: None + Displays the color and current character selected by the user based on an event." """ - self.color_label.text = f"Selected Color: {color_txt}" - self.input_label.text = f"Previous Input: {input_txt}" - self.text_label.text = f"Typed Text: {typed_txt}" + self.color_label.text = f"Color Selected: {self.selected_color}" + self.input_label.text = f"Character Selected: {self.typed_char}" self.color_label.update() self.input_label.update() + + def update_confirmation_text(self) -> None: + """Update confirmed text on page. + + Display the confirmed character selected and all confirmed text typed thus far." + + """ + self.confirm_label.text = f"Character Typed: {self.confirmed_char}" + self.text_label.text = f"Text Typed: {self.typed_text}" + self.confirm_label.update() self.text_label.update() @ui.page("/color_input") @@ -117,6 +143,7 @@ def color_input_page(self) -> None: ui.label("Special Keys:").style("font-weight:bold") ui.separator().style("opacity:0") ui.switch("CAPS LOCK", on_change=self.shift_handler) + ui.button("Confirm Letter", on_click=self.confirm_letter_handler) ui.separator().props("color = black") # creating wrappers to pass callback functions with parameters to buttons below @@ -137,9 +164,10 @@ def color_input_page(self) -> None: ui.label("Something could go here also") # ui labels displaying selected color, last input character, and text typed by user - self.color_label.text = f"Selected Color: {self.selected_color}" - self.input_label.text = f"Previous Input: {self.typed_char}" - self.text_label.text = f"Typed Text: {self.typed_text}" + self.color_label.text = f"Color Selected: {self.selected_color}" + self.input_label.text = f"Character Selected: {self.typed_char}" + self.confirm_label.text = f"Character Typed: {self.confirmed_char}" + self.text_label.text = f"Text Typed: {self.typed_text}" with ui.row(), ui.button(icon="colorize").style("opacity:0;pointer-events:none"): ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") From a91595aefa961aaf4fb3b86d953c5354af9e3916 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:02:19 -0700 Subject: [PATCH 086/196] Update audio_style_input to use builtin string constants more --- src/audio_style_input/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 1407df7a..04b53779 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -10,11 +10,7 @@ media = Path("./static") app.add_media_files("/media", media) -capital_letters = [chr(i) for i in range(ord("A"), ord("Z") + 1)] -lowercase_letters = [chr(i) for i in range(ord("a"), ord("z") + 1)] -special_chars = list(string.punctuation) - -char_selection = [capital_letters, lowercase_letters, special_chars] +char_selection = [string.ascii_uppercase, string.ascii_lowercase, string.punctuation] class AudioEditorComponent(input_method_proto.IInputMethod): From 8a5a7fa766c25e71c715e5ba64460435db4d92ab Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:14:11 -0700 Subject: [PATCH 087/196] Add egg files to gitignore --- .gitignore | 4 ++ src/code_jam_template.egg-info/PKG-INFO | 69 ------------------- src/code_jam_template.egg-info/SOURCES.txt | 16 ----- .../dependency_links.txt | 0 src/code_jam_template.egg-info/requires.txt | 1 - src/code_jam_template.egg-info/top_level.txt | 8 --- 6 files changed, 4 insertions(+), 94 deletions(-) delete mode 100644 src/code_jam_template.egg-info/PKG-INFO delete mode 100644 src/code_jam_template.egg-info/SOURCES.txt delete mode 100644 src/code_jam_template.egg-info/dependency_links.txt delete mode 100644 src/code_jam_template.egg-info/requires.txt delete mode 100644 src/code_jam_template.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore index 5c4b2d11..69b17648 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ build/ # Since uv is not required for the project and all dependancies are pinned, # ignore the lock file for the convenience of people using uv uv.lock + +# Useless build-specific files generated if you run the project a certain way +*.egg-info/ +*.egg diff --git a/src/code_jam_template.egg-info/PKG-INFO b/src/code_jam_template.egg-info/PKG-INFO deleted file mode 100644 index e6f7ff69..00000000 --- a/src/code_jam_template.egg-info/PKG-INFO +++ /dev/null @@ -1,69 +0,0 @@ -Metadata-Version: 2.4 -Name: code-jam-template -Version: 0.1.0 -Summary: Add your description here -Author: Mannyvv, afx8732, enskyeing, husseinhirani, jks85, MeGaGiGaGon -Requires-Python: >=3.12 -Description-Content-Type: text/markdown -License-File: LICENSE.txt -Requires-Dist: nicegui~=2.22.2 -Dynamic: license-file - -# Monumental Monsteras CJ25 Project -Monumental Monsteras CJ25 Project is a typing speed test, -but with a twist: You cannot type with a normal keyboard. -You can only use the **wrong tool for the job**. - -Try different wrong methods of writing text, with a score at -the end if you would like to flex on your friends. - -Input methods: - -# Running the project -## Using `uv` (recommended) - -The recommended way to run the project is using `uv`. - -If you do not have `uv` installed, see https://docs.astral.sh/uv/getting-started/installation/ - -``` -$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git -$ cd cj25-monumental-monsteras-team-repo.git -$ uv run src/main.py -``` - -## Without `uv` - -``` -$ git clone https://github.com/Mannyvv/cj25-monumental-monsteras-team-repo.git -$ cd cj25-monumental-monsteras-team-repo.git -$ py -3.12 -m venv .venv -$ py -m pip install . -$ py src/main.py -``` - -# Contributing -## Setting up the project for development -If you do not have `pre-commit` installed, see https://pre-commit.com/#installation - -You can also use `uvx pre-commit` to run `pre-commit` commands without permanently installing it. - -Once you have `pre-commit` installed, run this command to set up the commit hooks. -``` -$ pre-commit install -``` - -## Development process -If the change you are making is large, open a new -issue and self-assign to make sure no duplicate work is done. - -When making a change: -1. Make a new branch on the main repository -2. Make commits to the branch -3. Open a PR from that branch to main - -You can run the pre-commit checks locally with: -``` -$ pre-commit run -a -``` -If you installed the commit hook in the previous step, they should also be run locally on commits. diff --git a/src/code_jam_template.egg-info/SOURCES.txt b/src/code_jam_template.egg-info/SOURCES.txt deleted file mode 100644 index 61f97cf2..00000000 --- a/src/code_jam_template.egg-info/SOURCES.txt +++ /dev/null @@ -1,16 +0,0 @@ -LICENSE.txt -README.md -pyproject.toml -src/homepage.py -src/input_method_proto.py -src/input_view.py -src/main.py -src/wpm_tester.py -src/audio_style_input/__init__.py -src/code_jam_template.egg-info/PKG-INFO -src/code_jam_template.egg-info/SOURCES.txt -src/code_jam_template.egg-info/dependency_links.txt -src/code_jam_template.egg-info/requires.txt -src/code_jam_template.egg-info/top_level.txt -src/rpg_text_input/__init__.py -src/sample_input_method/__init__.py diff --git a/src/code_jam_template.egg-info/dependency_links.txt b/src/code_jam_template.egg-info/dependency_links.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/src/code_jam_template.egg-info/requires.txt b/src/code_jam_template.egg-info/requires.txt deleted file mode 100644 index d0e1609e..00000000 --- a/src/code_jam_template.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -nicegui~=2.22.2 diff --git a/src/code_jam_template.egg-info/top_level.txt b/src/code_jam_template.egg-info/top_level.txt deleted file mode 100644 index b2912072..00000000 --- a/src/code_jam_template.egg-info/top_level.txt +++ /dev/null @@ -1,8 +0,0 @@ -audio_style_input -homepage -input_method_proto -input_view -main -rpg_text_input -sample_input_method -wpm_tester From 2f872971252524bb4bb84afc8119cbd46bf81313 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:32:30 -0700 Subject: [PATCH 088/196] Improve the typing in config.py --- src/config.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/config.py b/src/config.py index eda55c1a..7d1c5bb8 100644 --- a/src/config.py +++ b/src/config.py @@ -1,10 +1,23 @@ +from typing import TypedDict + from audio_style_input import AudioEditorComponent +from input_method_proto import IInputMethod PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" + +class InputMethodSpec(TypedDict): + """Specifications for an input method to be added to the main page.""" + + name: str + path: str + icon: str + component: type[IInputMethod] | None + + # INPUT METHODS -INPUT_METHODS: list[dict] = [ +INPUT_METHODS: list[InputMethodSpec] = [ { "name": "Record Player", "path": "audio-input", @@ -32,7 +45,7 @@ ] # COLORS -COLOR_STYLE: dict = { +COLOR_STYLE: dict[str, str] = { "PRIMARY": "", "SECONDARY": "", "PRIMARY_BG": "", From 94f7bcc3c3a20b6696d9c005c60598c1a0a7d18e Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 00:34:36 -0500 Subject: [PATCH 089/196] remove debug print from simulation --- src/platformer_input/platformer_simulation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 462d6263..6995543b 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -31,8 +31,6 @@ def tick(self) -> None: if "ArrowDown" in self._keys: self.player_y += 1 - print(self.player_x, self.player_y) - def _collide(self, world: tuple[int, int]) -> bool: """Check if a target cell contains a wall.""" return self._world[world[1]][world[0]] == "#" From 68d78b207130ab327dbe5dc24159b4d746b33051 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 16 Aug 2025 22:52:15 -0700 Subject: [PATCH 090/196] Name text callback type --- src/audio_style_input/__init__.py | 7 +++---- src/input_method_proto.py | 9 ++++++--- src/sample_input_method/__init__.py | 6 ++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index 1407df7a..9c2e9fd5 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,6 +1,5 @@ import asyncio import string -from collections.abc import Callable from pathlib import Path from nicegui import app, ui @@ -21,7 +20,7 @@ class AudioEditorComponent(input_method_proto.IInputMethod): """Render the audio editor page with spinning record and letter spinner.""" def __init__(self) -> None: - self._text_update_callback: Callable[[str], None] | None = None + self._text_update_callback: input_method_proto.TextUpdateCallback | None = None self.current_char_selection_index_container = [0] self.current_chars_selected = char_selection[0] self.current_letter_index_container = [0] @@ -223,11 +222,11 @@ def start_audio_editor(self) -> None: self.intro_card.style("display:none") self.main_content.style("display:flex") - def on_text_update(self, callback: Callable[[str], None]) -> None: + def on_text_update(self, callback: input_method_proto.TextUpdateCallback) -> None: """Register a callback to be called whenever the text updates. Args: - callback (Callable[[str], None]): Function called with updated text. + callback (TextUpdateCallback): Function called with updated text. """ self._text_update_callback = callback diff --git a/src/input_method_proto.py b/src/input_method_proto.py index e5825cce..2ab6fcbc 100644 --- a/src/input_method_proto.py +++ b/src/input_method_proto.py @@ -1,9 +1,12 @@ -import typing +from collections.abc import Callable +from typing import Protocol +type TextUpdateCallback = Callable[[str], None] -class IInputMethod(typing.Protocol): + +class IInputMethod(Protocol): """An interface for any input method renderable in the WPM test page.""" - def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + def on_text_update(self, callback: TextUpdateCallback) -> None: """Call `callback` every time the user input changes.""" raise NotImplementedError diff --git a/src/sample_input_method/__init__.py b/src/sample_input_method/__init__.py index 2ca6c8a6..08b900fd 100644 --- a/src/sample_input_method/__init__.py +++ b/src/sample_input_method/__init__.py @@ -1,5 +1,3 @@ -import typing - from nicegui import ui import input_method_proto @@ -11,13 +9,13 @@ class SampleInputMethod(input_method_proto.IInputMethod): Consider using a dataclass instead with any complex state. """ - callbacks: list[typing.Callable[[str], None]] + callbacks: list[input_method_proto.TextUpdateCallback] def __init__(self) -> None: self.callbacks = [] self.inp = ui.input("input here") self.inp.on_value_change(lambda event: [x(event.value) for x in self.callbacks]) - def on_text_update(self, callback: typing.Callable[[str], None]) -> None: + def on_text_update(self, callback: input_method_proto.TextUpdateCallback) -> None: """Call `callback` every time the user input changes.""" self.callbacks.append(callback) From 458ff9cd00eedcc4841c0d4e6ffbe6237a85895f Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 01:19:18 -0500 Subject: [PATCH 091/196] fix rendering on y axis -- it was upside-down --- src/platformer_input/platformer_scene_cmp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 34fc9114..b78cd9ed 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -22,7 +22,7 @@ def __init__(self, position: tuple[int, int]) -> None: self.world = c.world_grid() self.player_center_x_offset = math.floor((c.SCENE_WIDTH - c.TILE_SIZE_ML) / 2) - self.player_center_y_offset = c.TILE_SIZE_ML + self.player_center_y_offset = c.SCENE_HEIGHT - (2 * c.TILE_SIZE_ML) self.draw_scene(*position) @@ -41,7 +41,7 @@ def _dynctx_draw_scene(self, player_pos: tuple[int, int]) -> None: color = "black" if cell == "#" else "red" tile_x = (xpos - player_pos[0]) * c.TILE_SIZE_ML + self.player_center_x_offset - tile_y = (ypos - (round(c.SCENE_HEIGHT / c.TILE_SIZE_ML) - player_pos[1])) * c.TILE_SIZE_ML + tile_y = (ypos - player_pos[1]) * c.TILE_SIZE_ML + self.player_center_y_offset self._sc_create_sq_tile(tile_x, tile_y, color) self._sc_draw_player() @@ -60,5 +60,5 @@ def _sc_create_sq_tile(self, x: int, y: int, col: str) -> None: ny = y + offset_y ui.element("div").style( f"""background-color: {col}; width: {c.TILE_SIZE}px; height: {c.TILE_SIZE}px; - position: absolute; left: {nx * c.TILE_SIZE}px; bottom: {ny * c.TILE_SIZE}px;""" + position: absolute; left: {nx * c.TILE_SIZE}px; top: {ny * c.TILE_SIZE}px;""" ) From 491a310ad4aa57f5e97be9f6a94146531d5b9d17 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sat, 16 Aug 2025 23:24:51 -0700 Subject: [PATCH 092/196] Switch input_method_proto imports to froms --- src/audio_style_input/__init__.py | 8 ++++---- src/sample_input_method/__init__.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index c990f726..a33713b2 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -4,7 +4,7 @@ from nicegui import app, ui -import input_method_proto +from input_method_proto import IInputMethod, TextUpdateCallback media = Path("./static") app.add_media_files("/media", media) @@ -12,11 +12,11 @@ char_selection = [string.ascii_uppercase, string.ascii_lowercase, string.punctuation] -class AudioEditorComponent(input_method_proto.IInputMethod): +class AudioEditorComponent(IInputMethod): """Render the audio editor page with spinning record and letter spinner.""" def __init__(self) -> None: - self._text_update_callback: input_method_proto.TextUpdateCallback | None = None + self._text_update_callback: TextUpdateCallback | None = None self.current_char_selection_index_container = [0] self.current_chars_selected = char_selection[0] self.current_letter_index_container = [0] @@ -218,7 +218,7 @@ def start_audio_editor(self) -> None: self.intro_card.style("display:none") self.main_content.style("display:flex") - def on_text_update(self, callback: input_method_proto.TextUpdateCallback) -> None: + def on_text_update(self, callback: TextUpdateCallback) -> None: """Register a callback to be called whenever the text updates. Args: diff --git a/src/sample_input_method/__init__.py b/src/sample_input_method/__init__.py index 08b900fd..58aad36e 100644 --- a/src/sample_input_method/__init__.py +++ b/src/sample_input_method/__init__.py @@ -1,21 +1,21 @@ from nicegui import ui -import input_method_proto +from input_method_proto import IInputMethod, TextUpdateCallback -class SampleInputMethod(input_method_proto.IInputMethod): +class SampleInputMethod(IInputMethod): """A sample input method for basic reference. Consider using a dataclass instead with any complex state. """ - callbacks: list[input_method_proto.TextUpdateCallback] + callbacks: list[TextUpdateCallback] def __init__(self) -> None: self.callbacks = [] self.inp = ui.input("input here") self.inp.on_value_change(lambda event: [x(event.value) for x in self.callbacks]) - def on_text_update(self, callback: input_method_proto.TextUpdateCallback) -> None: + def on_text_update(self, callback: TextUpdateCallback) -> None: """Call `callback` every time the user input changes.""" self.callbacks.append(callback) From fc6f7d0da78e261479673573f24ffbef29d3a19d Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 01:53:30 -0500 Subject: [PATCH 093/196] only render visible tiles --- src/platformer_input/platformer_scene_cmp.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index b78cd9ed..36f29cf8 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -34,8 +34,17 @@ def draw_scene(self, player_x: int, player_y: int) -> None: def _dynctx_draw_scene(self, player_pos: tuple[int, int]) -> None: """Draws a scene in any context.""" + xv_min = player_pos[0] - (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 + xv_max = player_pos[0] + (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 + yv_min = player_pos[1] - self.player_center_y_offset / c.TILE_SIZE_ML + yv_max = player_pos[1] + 2 + for ypos, row in enumerate(self.world): + if not (yv_min <= ypos < yv_max): + continue for xpos, cell in enumerate(row): + if not (xv_min < xpos < xv_max): + continue if cell == " ": continue From 7596aecec2a5ba87cb85f97f14eb6efbaf37a8d2 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Wed, 13 Aug 2025 22:55:15 -0700 Subject: [PATCH 094/196] Create frame page function outline --- src/input_method_frame.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/input_method_frame.py diff --git a/src/input_method_frame.py b/src/input_method_frame.py new file mode 100644 index 00000000..cc643292 --- /dev/null +++ b/src/input_method_frame.py @@ -0,0 +1,15 @@ +from nicegui import ui + +import input_method_proto + + +def input_method_page(input_method: input_method_proto.IInputMethod) -> None: + """User interface frame for input method pages. + + Args: + input_method: The input method to be generated on the page. + + """ + + +ui.run() From 548525811f3887e30515d53e2f1c9bf3afc5141e Mon Sep 17 00:00:00 2001 From: enskyeing Date: Thu, 14 Aug 2025 00:01:54 -0700 Subject: [PATCH 095/196] Create header layout --- src/input_method_frame.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index cc643292..3ad3b73f 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -1,15 +1,42 @@ from nicegui import ui -import input_method_proto +NAME: str = "PLACEHOLDER NAME" +DESCRIPTION: str = "Placeholder Description" -def input_method_page(input_method: input_method_proto.IInputMethod) -> None: +@ui.page("/") +def input_method_page() -> None: """User interface frame for input method pages. Args: input_method: The input method to be generated on the page. """ + ui.add_css(""" + .header { + height: 8vh; + align-items: center; + } + .h1 { + font-family: Arial; + font-size: 35px; + font-weight: bold; + } + """) + with ui.header(wrap=False).style("background-color: #20A39E").classes("items-center justify-between header"): + with ui.card().props("flat"): # small logo placeholder + pass + ui.label(NAME).classes("h1") + ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") + with ( + ui.right_drawer( + value=False, + fixed=False, + ) + .style("background-color: #ebf1fa") + .props("bordered overlay") as right_drawer + ): + ui.label("HOME") ui.run() From d3eb51c4c2aeb947c6c60045b6813ab1566484d4 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Thu, 14 Aug 2025 23:05:47 -0700 Subject: [PATCH 096/196] Add header with sidebar --- src/input_method_frame.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 3ad3b73f..a6301fd1 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -34,9 +34,19 @@ def input_method_page() -> None: fixed=False, ) .style("background-color: #ebf1fa") - .props("bordered overlay") as right_drawer + .props("overlay") + .classes("p-0") as right_drawer, + ui.element("q-scroll-area").classes("fit"), ): - ui.label("HOME") + with ui.list().classes("fit"), ui.item().props("clickable"), ui.item_section(): + ui.label("HOME") + with ui.list().classes("fit"): + ui.separator() + with ui.list().classes("fit"): + with ui.item().props("clickable"), ui.item_section(): + ui.label("RECORD PLAYER") + with ui.item().props("clickable"), ui.item_section(): + ui.label("COLOR PICKER") ui.run() From 2812c26e24541aac733a39e61ccfabecb3fe6f22 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Thu, 14 Aug 2025 23:08:41 -0700 Subject: [PATCH 097/196] Add labelling comments --- src/input_method_frame.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index a6301fd1..0b82afed 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -23,11 +23,14 @@ def input_method_page() -> None: font-weight: bold; } """) + with ui.header(wrap=False).style("background-color: #20A39E").classes("items-center justify-between header"): with ui.card().props("flat"): # small logo placeholder pass ui.label(NAME).classes("h1") ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") + + # Sidebar with ( ui.right_drawer( value=False, From a846a8d5c2cf6e0d48de2a32f5074b60f2d5db70 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Thu, 14 Aug 2025 23:41:05 -0700 Subject: [PATCH 098/196] Add config file support --- src/input_method_frame.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 0b82afed..2258e074 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -1,7 +1,6 @@ from nicegui import ui -NAME: str = "PLACEHOLDER NAME" -DESCRIPTION: str = "Placeholder Description" +from config import INPUT_METHODS, PROJECT_NAME @ui.page("/") @@ -27,7 +26,7 @@ def input_method_page() -> None: with ui.header(wrap=False).style("background-color: #20A39E").classes("items-center justify-between header"): with ui.card().props("flat"): # small logo placeholder pass - ui.label(NAME).classes("h1") + ui.label(PROJECT_NAME.upper()).classes("h1") ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") # Sidebar @@ -46,10 +45,9 @@ def input_method_page() -> None: with ui.list().classes("fit"): ui.separator() with ui.list().classes("fit"): - with ui.item().props("clickable"), ui.item_section(): - ui.label("RECORD PLAYER") - with ui.item().props("clickable"), ui.item_section(): - ui.label("COLOR PICKER") + for input in INPUT_METHODS: + with ui.item().props("clickable"), ui.item_section(): + ui.label(input["name"].upper()) ui.run() From 67403a84bae7d06c44e71ac9eba10a6b41e00554 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 13:40:00 -0700 Subject: [PATCH 099/196] Add input path support to sidebar --- src/input_method_frame.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 2258e074..cc15994c 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -46,7 +46,10 @@ def input_method_page() -> None: ui.separator() with ui.list().classes("fit"): for input in INPUT_METHODS: - with ui.item().props("clickable"), ui.item_section(): + with ( + ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)).props("clickable"), + ui.item_section(), + ): ui.label(input["name"].upper()) From 72aad93f21f45b51e01e61bf5e6f6d6a31e3b385 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 13:42:05 -0700 Subject: [PATCH 100/196] Link home on sidebar --- src/input_method_frame.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index cc15994c..002747e8 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -40,7 +40,11 @@ def input_method_page() -> None: .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), ): - with ui.list().classes("fit"), ui.item().props("clickable"), ui.item_section(): + with ( + ui.list().classes("fit"), + ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable"), + ui.item_section(), + ): ui.label("HOME") with ui.list().classes("fit"): ui.separator() From fd49753aa10294ebb8645bc290fbfb9811d2ac4d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 15:30:17 -0700 Subject: [PATCH 101/196] Add color palette --- src/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/config.py b/src/config.py index 7d1c5bb8..161abf6b 100644 --- a/src/config.py +++ b/src/config.py @@ -46,8 +46,9 @@ class InputMethodSpec(TypedDict): # COLORS COLOR_STYLE: dict[str, str] = { - "PRIMARY": "", - "SECONDARY": "", - "PRIMARY_BG": "", - "SECONDARY_BG": "", + "primary": "#048A81", + "secondary": "#7D53DE", + "primary_bg": "#111111", + "secondary_bg": "#1B1B1B", + "contrast": "#F9F9F9", } From d24451c468828f5833027122ba8828eeb45fbcaf Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 15:55:28 -0700 Subject: [PATCH 102/196] Change primary color --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index 161abf6b..d7a6dff2 100644 --- a/src/config.py +++ b/src/config.py @@ -46,7 +46,7 @@ class InputMethodSpec(TypedDict): # COLORS COLOR_STYLE: dict[str, str] = { - "primary": "#048A81", + "primary": "#00C74C", "secondary": "#7D53DE", "primary_bg": "#111111", "secondary_bg": "#1B1B1B", From e579c81e883caad7d5e1222ea8b970a65f36bfcc Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 15:56:24 -0700 Subject: [PATCH 103/196] Add color style dict support --- src/input_method_frame.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 002747e8..9145f2d8 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -1,6 +1,6 @@ from nicegui import ui -from config import INPUT_METHODS, PROJECT_NAME +from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME @ui.page("/") @@ -23,10 +23,16 @@ def input_method_page() -> None: } """) - with ui.header(wrap=False).style("background-color: #20A39E").classes("items-center justify-between header"): + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']}") + + with ( + ui.header(wrap=False) + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes("items-center justify-between header") + ): with ui.card().props("flat"): # small logo placeholder pass - ui.label(PROJECT_NAME.upper()).classes("h1") + ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("h1") ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") # Sidebar @@ -35,7 +41,7 @@ def input_method_page() -> None: value=False, fixed=False, ) - .style("background-color: #ebf1fa") + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") .props("overlay") .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), @@ -45,7 +51,7 @@ def input_method_page() -> None: ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable"), ui.item_section(), ): - ui.label("HOME") + ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") with ui.list().classes("fit"): ui.separator() with ui.list().classes("fit"): @@ -54,7 +60,7 @@ def input_method_page() -> None: ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)).props("clickable"), ui.item_section(), ): - ui.label(input["name"].upper()) + ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") ui.run() From e28b68df8d6d01f9d038792c57a4e1701ba8ccf5 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 17:17:36 -0700 Subject: [PATCH 104/196] Add input method container --- src/input_method_frame.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 9145f2d8..68efc6c1 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -21,9 +21,24 @@ def input_method_page() -> None: font-size: 35px; font-weight: bold; } + .input-method-container { + position: absolute; + width: 90vw; + height: 85vh; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 20px + } + body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + } """) - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']}") + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") with ( ui.header(wrap=False) @@ -62,5 +77,8 @@ def input_method_page() -> None: ): ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") + with ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"): + pass + ui.run() From f659b2538fecda73723c510eaa260f157c08f2c4 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 17:45:04 -0700 Subject: [PATCH 105/196] Add hover color to sidebar --- src/input_method_frame.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 68efc6c1..5a0eb0d1 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -63,16 +63,18 @@ def input_method_page() -> None: ): with ( ui.list().classes("fit"), - ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable"), + ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable").classes("hover:bg-[#12E7B2]"), ui.item_section(), ): ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") - with ui.list().classes("fit"): - ui.separator() + with ui.list().classes("fit"), ui.column().classes("w-full items-center"): + ui.separator().style("background-color: #313131; width: 95%;") with ui.list().classes("fit"): for input in INPUT_METHODS: with ( - ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)).props("clickable"), + ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)) + .props("clickable") + .classes("hover:bg-[#12E7B2]"), ui.item_section(), ): ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") From 22d8d7eba0d8fea15c3e5ab4b5dd2b0c26bd8b6a Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 17:48:13 -0700 Subject: [PATCH 106/196] Make hover color dynamic --- src/input_method_frame.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 5a0eb0d1..9ce46b2d 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -63,7 +63,9 @@ def input_method_page() -> None: ): with ( ui.list().classes("fit"), - ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable").classes("hover:bg-[#12E7B2]"), + ui.item(on_click=lambda: ui.navigate.to("/")) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), ui.item_section(), ): ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") @@ -74,7 +76,7 @@ def input_method_page() -> None: with ( ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)) .props("clickable") - .classes("hover:bg-[#12E7B2]"), + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), ui.item_section(), ): ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") From f014405a2f46158eda2c34f74cf772b435b7c569 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 17:51:47 -0700 Subject: [PATCH 107/196] Update color style --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index d7a6dff2..d2312fb8 100644 --- a/src/config.py +++ b/src/config.py @@ -46,7 +46,7 @@ class InputMethodSpec(TypedDict): # COLORS COLOR_STYLE: dict[str, str] = { - "primary": "#00C74C", + "primary": "#12E7B2", "secondary": "#7D53DE", "primary_bg": "#111111", "secondary_bg": "#1B1B1B", From 440b8886ba044a2e21cd0d06eb073cc399343478 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 17:53:50 -0700 Subject: [PATCH 108/196] Add comments and organization --- src/input_method_frame.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/input_method_frame.py b/src/input_method_frame.py index 9ce46b2d..7bad2191 100644 --- a/src/input_method_frame.py +++ b/src/input_method_frame.py @@ -38,8 +38,7 @@ def input_method_page() -> None: } """) - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") - + # Header with ( ui.header(wrap=False) .style(f"background-color: {COLOR_STYLE['secondary_bg']}") @@ -61,6 +60,7 @@ def input_method_page() -> None: .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), ): + # Home Button with ( ui.list().classes("fit"), ui.item(on_click=lambda: ui.navigate.to("/")) @@ -69,8 +69,11 @@ def input_method_page() -> None: ui.item_section(), ): ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") + with ui.list().classes("fit"), ui.column().classes("w-full items-center"): ui.separator().style("background-color: #313131; width: 95%;") + + # Input method buttons with ui.list().classes("fit"): for input in INPUT_METHODS: with ( @@ -81,6 +84,9 @@ def input_method_page() -> None: ): ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") + # Main body + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") + with ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"): pass From e2d892fed112c06ecce4b29cc09635f46878af28 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 23:19:02 -0700 Subject: [PATCH 109/196] Add styling into wpm tester --- src/wpm_tester.py | 97 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index b0d88f8a..06cc03b1 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -4,7 +4,7 @@ import input_method_proto import input_view -from config import INPUT_METHODS +from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: @@ -40,13 +40,94 @@ async def wpm_tester_page(method: str) -> None: ui.navigate.to("/") return - with ui.header(elevated=True).classes("align-center justify-center"): - ui.label(f"test: {method}").classes("text-center text-lg") - - # TODO: get og text from babbler module - iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") - - input_method = input_method_def() + ui.add_css(""" + .header { + height: 8vh; + align-items: center; + } + .h1 { + font-family: Arial; + font-size: 35px; + font-weight: bold; + } + .input-method-container { + position: absolute; + width: 90vw; + height: 85vh; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 20px + } + body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + } + """) + + # Header + with ( + ui.header(wrap=False) + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes("items-center justify-between header") + ): + with ui.card().props("flat"): # small logo placeholder + pass + ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("h1") + ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") + + # Sidebar + with ( + ui.right_drawer( + value=False, + fixed=False, + ) + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .props("overlay") + .classes("p-0") as right_drawer, + ui.element("q-scroll-area").classes("fit"), + ): + # Home Button + with ( + ui.list().classes("fit"), + ui.item(on_click=lambda: ui.navigate.to("/")) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + ui.item_section(), + ): + ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") + + with ui.list().classes("fit"), ui.column().classes("w-full items-center"): + ui.separator().style("background-color: #313131; width: 95%;") + + # Input method buttons + with ui.list().classes("fit"): + for input in INPUT_METHODS: + with ( + ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + ui.item_section(), + ): + ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") + + # Main body + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") + + with ( + ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"), + ui.column().classes("w-full items-center p-5 gap-4"), + ): + # Sentence & timer div + with ui.element("div"): + # TODO: get og text from babbler module + iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") + + # Input method div + with ui.element("div"): + input_method = input_method_def() def on_text_update(txt: str) -> None: iv.set_text(txt) From f234c835a40ea36550722277bc0e341a39ddfb3d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 23:19:24 -0700 Subject: [PATCH 110/196] Delete input_method_frame --- src/input_method_frame.py | 94 --------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 src/input_method_frame.py diff --git a/src/input_method_frame.py b/src/input_method_frame.py deleted file mode 100644 index 7bad2191..00000000 --- a/src/input_method_frame.py +++ /dev/null @@ -1,94 +0,0 @@ -from nicegui import ui - -from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME - - -@ui.page("/") -def input_method_page() -> None: - """User interface frame for input method pages. - - Args: - input_method: The input method to be generated on the page. - - """ - ui.add_css(""" - .header { - height: 8vh; - align-items: center; - } - .h1 { - font-family: Arial; - font-size: 35px; - font-weight: bold; - } - .input-method-container { - position: absolute; - width: 90vw; - height: 85vh; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - border-radius: 20px - } - body { - margin: 0; - padding: 0; - height: 100%; - width: 100%; - } - """) - - # Header - with ( - ui.header(wrap=False) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") - .classes("items-center justify-between header") - ): - with ui.card().props("flat"): # small logo placeholder - pass - ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("h1") - ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") - - # Sidebar - with ( - ui.right_drawer( - value=False, - fixed=False, - ) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") - .props("overlay") - .classes("p-0") as right_drawer, - ui.element("q-scroll-area").classes("fit"), - ): - # Home Button - with ( - ui.list().classes("fit"), - ui.item(on_click=lambda: ui.navigate.to("/")) - .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), - ui.item_section(), - ): - ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") - - with ui.list().classes("fit"), ui.column().classes("w-full items-center"): - ui.separator().style("background-color: #313131; width: 95%;") - - # Input method buttons - with ui.list().classes("fit"): - for input in INPUT_METHODS: - with ( - ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)) - .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), - ui.item_section(), - ): - ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") - - # Main body - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") - - with ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"): - pass - - -ui.run() From eb6653e5797b8347e1c2d327b6e2cfa8cc9cea6c Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 23:33:58 -0700 Subject: [PATCH 111/196] Increase font size --- src/input_view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/input_view.py b/src/input_view.py index 26d0f8db..4704beac 100644 --- a/src/input_view.py +++ b/src/input_view.py @@ -41,10 +41,12 @@ def on_key(key): } .input-view-bg { + font-size: 20pt; grid-area: 1 / 1 / 2 / 2; } .input-view-fg { + font-size: 20pt; grid-area: 1 / 1 / 2 / 2; } From afba4b702fdd2ac78c55c8b668f204126c157cd8 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sat, 16 Aug 2025 23:45:07 -0700 Subject: [PATCH 112/196] Evenly distribute items in column --- src/wpm_tester.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 06cc03b1..7e110d61 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -51,6 +51,9 @@ async def wpm_tester_page(method: str) -> None: font-weight: bold; } .input-method-container { + display: flex; + flex-direction: column; + justify-content: space-evenly; position: absolute; width: 90vw; height: 85vh; @@ -117,8 +120,9 @@ async def wpm_tester_page(method: str) -> None: ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") with ( - ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"), - ui.column().classes("w-full items-center p-5 gap-4"), + ui.element("div") + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes("input-method-container items-center") ): # Sentence & timer div with ui.element("div"): From e7fa1f8354d1237623c2b9eecbbec5eef72ba9b1 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 17 Aug 2025 00:01:01 -0700 Subject: [PATCH 113/196] Update rpg_text_input to work with new input scheme --- src/config.py | 3 +- src/rpg_text_input/__init__.py | 102 +++++++++++++++++---------------- 2 files changed, 55 insertions(+), 50 deletions(-) diff --git a/src/config.py b/src/config.py index 7d1c5bb8..4132821d 100644 --- a/src/config.py +++ b/src/config.py @@ -2,6 +2,7 @@ from audio_style_input import AudioEditorComponent from input_method_proto import IInputMethod +from rpg_text_input import Keyboard PROJECT_NAME: str = "Dynamic Typing" PROJECT_DESCRIPTION: str = "How fast can you type?" @@ -28,7 +29,7 @@ class InputMethodSpec(TypedDict): "name": "WASD", "path": "wasd", "icon": "", - "component": None, + "component": Keyboard, }, { "name": "Color Picker", diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 218c1c0a..4cab7aa2 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -1,8 +1,11 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass +from typing import override from nicegui import ui from nicegui.events import KeyEventArguments +from input_method_proto import IInputMethod, TextUpdateCallback + def wrap_to_range(num: int, num_min: int, num_max: int) -> int: """Ensure num is in the half-open interval [min, max), wrapping as needed. @@ -25,7 +28,30 @@ def wrap_to_range(num: int, num_min: int, num_max: int) -> int: @dataclass -class Keyboard: +class WrappingPosition: + """X and Y position with wrapping addition.""" + + x: int + y: int + max_x: int + max_y: int + + def wrapping_add(self, x: int, y: int) -> "WrappingPosition": + """Add an X and a Y to self, wrapping as needed.""" + new_x = wrap_to_range(self.x + x, 0, self.max_x) + new_y = wrap_to_range(self.y + y, 0, self.max_y) + return WrappingPosition(new_x, new_y, self.max_x, self.max_y) + + +KEYBOARD_KEYS: tuple[str, ...] = ( + "ABCDEFGabcdefg", + "HIJKLMNhijklmn", + "OPQRSTUopqrstu", + "VWXYZ. vwxyz,\N{SYMBOL FOR BACKSPACE}", +) + + +class Keyboard(IInputMethod): r"""A RPG-style keyboard where characters are selected by navigating with wasd/the arror keys. Positions are stored internally as (col, row). @@ -35,65 +61,41 @@ class Keyboard: """ - keys: tuple[str, ...] = ( - "ABCDEFGabcdefg", - "HIJKLMNhijklmn", - "OPQRSTUopqrstu", - "VWXYZ. vwxyz,\N{SYMBOL FOR BACKSPACE}", - ) - - position: list[int] = field(default_factory=lambda: [0, 0]) - - def __post_init__(self) -> None: - if not self.keys: - msg = "Keyboard keys must not be empty." - raise ValueError(msg) - first_row_len = len(self.keys[0]) - for row in self.keys[1:]: - if len(row) != first_row_len: - msg = ( - "All rows must be the same length, got" - f" {row!r} with length {len(row)} while expecting {first_row_len}." - ) - raise ValueError(msg) - if not (0 <= self.position[0] < len(self.keys[0])) or not (0 <= self.position[1] < len(self.keys)): - msg = ( - f"Starting position {self.position} is outside bounds" - f" (0, 0) to ({len(self.keys[0]) - 1}, {len(self.keys) - 1})" - ) - raise ValueError(msg) + def __init__(self) -> None: + self.position: WrappingPosition = WrappingPosition(0, 0, len(KEYBOARD_KEYS[0]), len(KEYBOARD_KEYS)) + self.callbacks: list[TextUpdateCallback] = [] + self.text: str = "" + + self.render() + ui.keyboard(on_key=self.handle_key) @ui.refreshable_method def render(self) -> None: """Render the keyboard to the page.""" - with ui.grid(columns=len(self.keys[0])): - for row_index, row in enumerate(self.keys): + with ui.grid(columns=len(KEYBOARD_KEYS[0])): + for row_index, row in enumerate(KEYBOARD_KEYS): for col_index, char in enumerate(row): - label = ui.label(char if char != "`" else "") - label.style("text-align: center") - if [col_index, row_index] == self.position: + label = ui.label(char).style("text-align: center") + if (col_index, row_index) == (self.position.x, self.position.y): label.style("background-color: lightblue") def move(self, x: int, y: int) -> None: """Move the keyboard selected character in the given directions.""" - self.position[0] = wrap_to_range(self.position[0] + x, 0, len(self.keys[0])) - self.position[1] = wrap_to_range(self.position[1] + y, 0, len(self.keys)) + self.position = self.position.wrapping_add(x, y) self.render.refresh() def send_selected(self) -> None: """Send the selected character to the input view.""" - print( - f"Selected {self.keys[self.position[1]][self.position[0]]!r}", - ) # TODO(GiGaGon): Add communication with input view once it exists - + key = KEYBOARD_KEYS[self.position.y][self.position.x] + if key != "\N{SYMBOL FOR BACKSPACE}": + self.text += key + else: + self.text = self.text[:-1] -@ui.page("/controller") -def rpg_text_input_page() -> None: - """Page for the RPG style keyboard.""" - keyboard = Keyboard() - keyboard.render() + for callback in self.callbacks: + callback(self.text) - def handle_key(e: KeyEventArguments) -> None: + def handle_key(self, e: KeyEventArguments) -> None: """Input handler for the RPG style keyboard.""" if not e.action.keydown: return @@ -106,9 +108,11 @@ def handle_key(e: KeyEventArguments) -> None: ({"KeyD", "ArrowRight"}, (1, 0)), ): if e.key.code in key_codes: - keyboard.move(*direction) + self.move(*direction) if e.key.code in {"Space", "Enter"}: - keyboard.send_selected() + self.send_selected() - ui.keyboard(on_key=handle_key) + @override + def on_text_update(self, callback: TextUpdateCallback) -> None: + self.callbacks.append(callback) From b2f49fee409a1f27ac2fa77287d4a789b798a34c Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 02:02:18 -0500 Subject: [PATCH 114/196] document constants --- src/platformer_input/platformer_constants.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index dd1dd55c..a6a95170 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -1,6 +1,12 @@ +"""Constants for the platformer input method rendering & simulator.""" + +"""Size in pixels of each "tile".""" TILE_SIZE = 10 +"""Width in tiles of the whole scene.""" SCENE_WIDTH = 45 +"""Height in tiles of the whole scene.""" SCENE_HEIGHT = 30 +"""Tile size multiplier.""" TILE_SIZE_ML = 3 JUMP_FORCE = 5 From 2e140691dd45da9461a9f26db44a94316516e719 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 02:08:05 -0500 Subject: [PATCH 115/196] Realtime rendering (set-FPS-based) --- src/platformer_input/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 335fce9e..19ae951e 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -9,6 +9,7 @@ ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") INITIAL_POS = (0, 10) +FPS = 15 class PlatformerInputMethod(input_method_proto.IInputMethod): @@ -24,19 +25,15 @@ class PlatformerInputMethod(input_method_proto.IInputMethod): def __init__(self) -> None: self.callbacks = [] - self.inp = PlatformerSceneComponent(INITIAL_POS) + self.scene = PlatformerSceneComponent(INITIAL_POS) self.physics = PlatformerPhysicsSimulation(INITIAL_POS) self.held_keys = set() ui.keyboard(lambda e: self.keyboard_handler(e)) + ui.timer(1 / FPS, lambda: self._hinterv()) def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: """Call with the nicegui keyboard callback.""" evk = str(event.key) - if evk == "Enter" and event.action.keyup and not event.action.repeat: - self.physics.tick() - self.inp.draw_scene(self.physics.player_x, self.physics.player_y) - return - if event.action.repeat or evk not in ALLOWED_KEYS: return @@ -46,6 +43,11 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: self.held_keys.remove(evk) self.physics.set_held_keys(self.held_keys) + def _hinterv(self) -> None: + """Run every game tick.""" + self.physics.tick() + self.scene.draw_scene(self.physics.player_x, self.physics.player_y) + def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" self.callbacks.append(callback) From 2eb9b4d5e18317450fb195a07a2ce701991c62c0 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 02:08:39 -0500 Subject: [PATCH 116/196] remove debug print --- src/platformer_input/platformer_simulation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 6995543b..42063e24 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -21,7 +21,6 @@ def set_held_keys(self, keys: set[str]) -> None: def tick(self) -> None: """Run a tick of the simulation.""" - print(self.player_x, self.player_y) if "ArrowRight" in self._keys: self.player_x += 1 if "ArrowLeft" in self._keys: From 62270f32a774da78b9c9edbc5f7c62979cb3bf6c Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 02:18:08 -0500 Subject: [PATCH 117/196] FLOATy movement! Rendering edges is buggy, needs delta timing, and perf sucks. But we got velocity working! --- src/platformer_input/platformer_constants.py | 5 ++++ src/platformer_input/platformer_scene_cmp.py | 6 ++--- src/platformer_input/platformer_simulation.py | 24 ++++++++++++++----- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index a6a95170..90f8a494 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -10,7 +10,12 @@ TILE_SIZE_ML = 3 JUMP_FORCE = 5 +"""Max player speed.""" MOV_SPEED = 10 +"""How fast the player accelerates.""" +ACCEL_SPEED = 15 +"""Essentially friction simulation""" +VELOCITY_DECAY_RATE = 0.8 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 36f29cf8..e9154888 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -26,13 +26,13 @@ def __init__(self, position: tuple[int, int]) -> None: self.draw_scene(*position) - def draw_scene(self, player_x: int, player_y: int) -> None: + def draw_scene(self, player_x: float, player_y: float) -> None: """Draw the scene, player, etc.""" self.e.clear() with self.e: self._dynctx_draw_scene((player_x, player_y)) - def _dynctx_draw_scene(self, player_pos: tuple[int, int]) -> None: + def _dynctx_draw_scene(self, player_pos: tuple[float, float]) -> None: """Draws a scene in any context.""" xv_min = player_pos[0] - (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 xv_max = player_pos[0] + (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 @@ -61,7 +61,7 @@ def _sc_draw_player(self) -> None: self._sc_create_sq_tile(player_x, player_y, c.COLOR_PLAYER) - def _sc_create_sq_tile(self, x: int, y: int, col: str) -> None: + def _sc_create_sq_tile(self, x: float, y: float, col: str) -> None: """Draw a full-color tile onto the scene.""" for offset_x in range(c.TILE_SIZE_ML): for offset_y in range(c.TILE_SIZE_ML): diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 42063e24..f3a7f0c3 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -4,13 +4,19 @@ class PlatformerPhysicsSimulation: """The physics simulation.""" - player_x: int - player_y: int + player_x: float + player_y: float + _xvel: float + _yvel: float + _keys: set[str] _world: list[list[str]] def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial + self._xvel = 0 + self._yvel = 0 + self._keys = set() self._world = constants.world_grid() self._world.reverse() @@ -22,13 +28,19 @@ def set_held_keys(self, keys: set[str]) -> None: def tick(self) -> None: """Run a tick of the simulation.""" if "ArrowRight" in self._keys: - self.player_x += 1 + self._xvel = min(constants.MOV_SPEED, self._xvel + constants.ACCEL_SPEED) if "ArrowLeft" in self._keys: - self.player_x -= 1 + self._xvel = max(-constants.MOV_SPEED, self._xvel - constants.ACCEL_SPEED) if "ArrowUp" in self._keys: - self.player_y -= 1 + self._yvel = max(-constants.MOV_SPEED, self._yvel - constants.ACCEL_SPEED) if "ArrowDown" in self._keys: - self.player_y += 1 + self._yvel = min(constants.MOV_SPEED, self._yvel + constants.ACCEL_SPEED) + + self.player_x += self._xvel / 10 + self.player_y += self._yvel / 10 + + self._xvel *= constants.VELOCITY_DECAY_RATE + self._yvel *= constants.VELOCITY_DECAY_RATE def _collide(self, world: tuple[int, int]) -> bool: """Check if a target cell contains a wall.""" From 0a6bd689861f3ecbb8737bb3f2358e975990fa99 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 00:44:55 -0700 Subject: [PATCH 118/196] Reference config color style --- src/homepage.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 99c52428..9ac3b3a1 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -1,6 +1,6 @@ from nicegui import ui -from config import INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME +from config import COLOR_STYLE, INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME def home() -> None: @@ -26,14 +26,12 @@ def home() -> None: font-size: 25px; font-weight: bold; text-align: center; - color: #393D3F; padding: 30px; } .input-box { height: 300px; width: 300px; padding: 20px; - text-color: 393D3F; } .input-grid { justify-content: center; @@ -54,22 +52,29 @@ def home() -> None: } """) - ui.query("body").style("background-color: #E9ECF5") + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']}") with ( - ui.header(fixed=False).style("background-color: #20A39E").classes("items-center thick-header"), + ui.header(fixed=False) + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes("items-center thick-header"), ui.column(align_items="center").style("gap: 0px;"), ): - ui.label(PROJECT_NAME).classes("site-title") - ui.label(PROJECT_DESCRIPTION).classes("site-subtitle") + ui.label(PROJECT_NAME).style(f"color: {COLOR_STYLE['primary']}").classes("site-title") + ui.label(PROJECT_DESCRIPTION).style(f"color: {COLOR_STYLE['contrast']}").classes("site-subtitle") with ui.element("div").classes("page-div"): - ui.label("CHOOSE YOUR INPUT METHOD").classes("heading") - ui.separator() + ui.label("CHOOSE YOUR INPUT METHOD").style(f"color: {COLOR_STYLE['secondary']}").classes("heading") + ui.separator().style("background-color: #313131;") with ui.element("div").classes("button-parent"): for input in INPUT_METHODS: - ui.button( - text=input["name"], - color="#F9F9F9", - on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path), - ).classes("input-box") + ( + ui.button( + text=input["name"], + color=COLOR_STYLE["secondary_bg"], + on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path), + ) + .style(f"color: {COLOR_STYLE['contrast']}") + .props("rounded") + .classes(f"input-box hover:!bg-[{COLOR_STYLE['primary']}] transition-colors duration-300") + ) From f7e8aafe68db370e6ebb3f4f3162cb62ea588594 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 04:14:55 -0500 Subject: [PATCH 119/196] Use deltatime + new decay algo right now there's still around 200ms input lat so a renderer rework is incoming --- src/platformer_input/platformer_constants.py | 6 ++-- src/platformer_input/platformer_simulation.py | 33 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 90f8a494..a2330913 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -11,11 +11,11 @@ JUMP_FORCE = 5 """Max player speed.""" -MOV_SPEED = 10 +MOV_SPEED = 4 """How fast the player accelerates.""" -ACCEL_SPEED = 15 +ACCEL_SPEED = 5 """Essentially friction simulation""" -VELOCITY_DECAY_RATE = 0.8 +VELOCITY_DECAY_RATE = 5 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index f3a7f0c3..3b0e894a 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -1,3 +1,5 @@ +import time + import platformer_input.platformer_constants as constants @@ -6,9 +8,13 @@ class PlatformerPhysicsSimulation: player_x: float player_y: float + _xvel: float _yvel: float + _deltatime: float + _last_tick_at: float + _keys: set[str] _world: list[list[str]] @@ -17,6 +23,9 @@ def __init__(self, initial: tuple[int, int]) -> None: self._xvel = 0 self._yvel = 0 + self._deltatime = 0 + self._last_tick_at = time.perf_counter() + self._keys = set() self._world = constants.world_grid() self._world.reverse() @@ -27,20 +36,28 @@ def set_held_keys(self, keys: set[str]) -> None: def tick(self) -> None: """Run a tick of the simulation.""" + current_time = time.perf_counter() + self._deltatime = current_time - self._last_tick_at + self._last_tick_at = current_time + + delta_accel = self._deltatime * constants.ACCEL_SPEED + if "ArrowRight" in self._keys: - self._xvel = min(constants.MOV_SPEED, self._xvel + constants.ACCEL_SPEED) + self._xvel = min(constants.MOV_SPEED, self._xvel + delta_accel) if "ArrowLeft" in self._keys: - self._xvel = max(-constants.MOV_SPEED, self._xvel - constants.ACCEL_SPEED) + self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) if "ArrowUp" in self._keys: - self._yvel = max(-constants.MOV_SPEED, self._yvel - constants.ACCEL_SPEED) + self._yvel = max(-constants.MOV_SPEED, self._yvel - delta_accel) if "ArrowDown" in self._keys: - self._yvel = min(constants.MOV_SPEED, self._yvel + constants.ACCEL_SPEED) + self._yvel = min(constants.MOV_SPEED, self._yvel + delta_accel) - self.player_x += self._xvel / 10 - self.player_y += self._yvel / 10 + self.player_x += self._xvel + self.player_y += self._yvel - self._xvel *= constants.VELOCITY_DECAY_RATE - self._yvel *= constants.VELOCITY_DECAY_RATE + decay_factor = 1 - constants.VELOCITY_DECAY_RATE * self._deltatime + decay_factor = max(decay_factor, 0) + self._xvel *= decay_factor + self._yvel *= decay_factor def _collide(self, world: tuple[int, int]) -> bool: """Check if a target cell contains a wall.""" From 682ca0783a0205d479bbc97d5227ba43f82d6711 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 04:27:03 -0500 Subject: [PATCH 120/196] Ultra-fast renderer! It's kind of fun to just play around with, to be honest --- src/platformer_input/__init__.py | 4 +- src/platformer_input/platformer_constants.py | 8 +- src/platformer_input/platformer_scene_cmp.py | 82 +++++++------------- 3 files changed, 35 insertions(+), 59 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 19ae951e..3a2db335 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -9,7 +9,7 @@ ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") INITIAL_POS = (0, 10) -FPS = 15 +FPS = 144 class PlatformerInputMethod(input_method_proto.IInputMethod): @@ -46,7 +46,7 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: def _hinterv(self) -> None: """Run every game tick.""" self.physics.tick() - self.scene.draw_scene(self.physics.player_x, self.physics.player_y) + self.scene.move_player(self.physics.player_x, self.physics.player_y) def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index a2330913..15e0500a 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -1,13 +1,11 @@ """Constants for the platformer input method rendering & simulator.""" """Size in pixels of each "tile".""" -TILE_SIZE = 10 +TILE_SIZE = 30 """Width in tiles of the whole scene.""" -SCENE_WIDTH = 45 +SCENE_WIDTH = 16 """Height in tiles of the whole scene.""" -SCENE_HEIGHT = 30 -"""Tile size multiplier.""" -TILE_SIZE_ML = 3 +SCENE_HEIGHT = 9 JUMP_FORCE = 5 """Max player speed.""" diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index e9154888..ed8beb45 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -1,5 +1,3 @@ -import math - from nicegui import ui import platformer_input.platformer_constants as c @@ -8,66 +6,46 @@ class PlatformerSceneComponent(ui.element): """Displays the characters and scene within the game.""" - e: ui.element + mask_element: ui.element world: list[list[str]] def __init__(self, position: tuple[int, int]) -> None: super().__init__("div") - self.e = ui.element("div") - self.e.style( + with self: + self.mask_element = ui.element("div") + self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" - f"background-color: {c.COLOR_BG}; position: relative" + f"background-color: {c.COLOR_BG}; position: relative; overflow: hidden; border: 2px solid black" ) self.world = c.world_grid() - self.player_center_x_offset = math.floor((c.SCENE_WIDTH - c.TILE_SIZE_ML) / 2) - self.player_center_y_offset = c.SCENE_HEIGHT - (2 * c.TILE_SIZE_ML) - - self.draw_scene(*position) - - def draw_scene(self, player_x: float, player_y: float) -> None: - """Draw the scene, player, etc.""" - self.e.clear() - with self.e: - self._dynctx_draw_scene((player_x, player_y)) - - def _dynctx_draw_scene(self, player_pos: tuple[float, float]) -> None: - """Draws a scene in any context.""" - xv_min = player_pos[0] - (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 - xv_max = player_pos[0] + (c.SCENE_WIDTH / c.TILE_SIZE_ML) / 2 - yv_min = player_pos[1] - self.player_center_y_offset / c.TILE_SIZE_ML - yv_max = player_pos[1] + 2 - - for ypos, row in enumerate(self.world): - if not (yv_min <= ypos < yv_max): - continue - for xpos, cell in enumerate(row): - if not (xv_min < xpos < xv_max): - continue - if cell == " ": - continue + self.world_height = len(self.world) + + with self.mask_element: + self.map_container = ui.element("div") + self.map_container.style( + f"position:absolute;width:{len(self.world[0]) * c.TILE_SIZE}px;" + f"height:{len(self.world) * c.TILE_SIZE}px;" + f"display:grid;grid-template-columns:repeat({len(self.world[0])}, {c.TILE_SIZE}px);" + f"grid-template-rows:repeat({len(self.world)}, {c.TILE_SIZE}px);" + ) - color = "black" if cell == "#" else "red" - tile_x = (xpos - player_pos[0]) * c.TILE_SIZE_ML + self.player_center_x_offset - tile_y = (ypos - player_pos[1]) * c.TILE_SIZE_ML + self.player_center_y_offset - self._sc_create_sq_tile(tile_x, tile_y, color) - self._sc_draw_player() + with self.map_container: + for row in self.world: + for cell in row: + ui.element("div").style(f"background-color:{self._get_bg_color(cell)}") - def _sc_draw_player(self) -> None: - """Draws the player.""" - player_x = self.player_center_x_offset - player_y = self.player_center_y_offset + self.move_player(*position) - self._sc_create_sq_tile(player_x, player_y, c.COLOR_PLAYER) + def move_player(self, player_x: float, player_y: float) -> None: + """Move the player in the renderer.""" + self.map_container.style(f"right:{player_x * c.TILE_SIZE}px;bottom:{player_y * c.TILE_SIZE}px") - def _sc_create_sq_tile(self, x: float, y: float, col: str) -> None: - """Draw a full-color tile onto the scene.""" - for offset_x in range(c.TILE_SIZE_ML): - for offset_y in range(c.TILE_SIZE_ML): - nx = x + offset_x - ny = y + offset_y - ui.element("div").style( - f"""background-color: {col}; width: {c.TILE_SIZE}px; height: {c.TILE_SIZE}px; - position: absolute; left: {nx * c.TILE_SIZE}px; top: {ny * c.TILE_SIZE}px;""" - ) + def _get_bg_color(self, scp: str) -> str: + """Get the background color in a tile by the tile type.""" + if scp == "#": + return c.COLOR_GROUND + if scp == " ": + return c.COLOR_BG + return "red" From 0826fbeaa5c47535134509dd4e09150d35f9d438 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 04:31:10 -0500 Subject: [PATCH 121/196] Display player --- src/platformer_input/platformer_scene_cmp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index ed8beb45..a7ba73e4 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -31,6 +31,12 @@ def __init__(self, position: tuple[int, int]) -> None: f"grid-template-rows:repeat({len(self.world)}, {c.TILE_SIZE}px);" ) + with self.mask_element: + ui.element("div").style( + f"position:absolute;bottom:{c.TILE_SIZE}px;left:{((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2}px;" + f"background-color:{c.COLOR_PLAYER};width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px" + ) + with self.map_container: for row in self.world: for cell in row: From 215e321b06d81ff030792bcd0ab8b0a6b63cc03d Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 04:38:49 -0500 Subject: [PATCH 122/196] Fix platformer renderer start position --- src/platformer_input/platformer_scene_cmp.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index a7ba73e4..694de797 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -31,9 +31,11 @@ def __init__(self, position: tuple[int, int]) -> None: f"grid-template-rows:repeat({len(self.world)}, {c.TILE_SIZE}px);" ) + self.px_player_offset_lx = ((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2 + self.px_player_offset_ty = (c.SCENE_HEIGHT * c.TILE_SIZE) - 2 * c.TILE_SIZE with self.mask_element: ui.element("div").style( - f"position:absolute;bottom:{c.TILE_SIZE}px;left:{((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2}px;" + f"position:absolute;top:{self.px_player_offset_ty}px;left:{self.px_player_offset_lx}px;" f"background-color:{c.COLOR_PLAYER};width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px" ) @@ -46,7 +48,11 @@ def __init__(self, position: tuple[int, int]) -> None: def move_player(self, player_x: float, player_y: float) -> None: """Move the player in the renderer.""" - self.map_container.style(f"right:{player_x * c.TILE_SIZE}px;bottom:{player_y * c.TILE_SIZE}px") + px_left = self.px_player_offset_lx - player_x * c.TILE_SIZE + self.map_container.style(f"left:{px_left}px") + + px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE + self.map_container.style(f"top:{px_top}px") def _get_bg_color(self, scp: str) -> str: """Get the background color in a tile by the tile type.""" From a1c45c112c0d0e260c1adee7ca530cddbfe03fd0 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Sun, 17 Aug 2025 08:21:24 -0700 Subject: [PATCH 123/196] Add typing overrides --- src/audio_style_input/__init__.py | 2 ++ src/sample_input_method/__init__.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d757a3bd..0e418199 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -1,6 +1,7 @@ import asyncio import string from pathlib import Path +from typing import override from nicegui import app, ui @@ -218,6 +219,7 @@ def start_audio_editor(self) -> None: self.intro_card.style("display:none") self.main_content.style("display:flex") + @override def on_text_update(self, callback: TextUpdateCallback) -> None: """Register a callback to be called whenever the text updates. diff --git a/src/sample_input_method/__init__.py b/src/sample_input_method/__init__.py index 58aad36e..40645e66 100644 --- a/src/sample_input_method/__init__.py +++ b/src/sample_input_method/__init__.py @@ -1,3 +1,5 @@ +from typing import override + from nicegui import ui from input_method_proto import IInputMethod, TextUpdateCallback @@ -16,6 +18,7 @@ def __init__(self) -> None: self.inp = ui.input("input here") self.inp.on_value_change(lambda event: [x(event.value) for x in self.callbacks]) + @override def on_text_update(self, callback: TextUpdateCallback) -> None: """Call `callback` every time the user input changes.""" self.callbacks.append(callback) From 25bfb12019e435e1f31e24b68ddd2f1adc2f205f Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 11:18:33 -0700 Subject: [PATCH 124/196] refactored with css and ruff fixes --- src/wpm_tester.py | 241 +++++++++++++++++++++++----------------------- 1 file changed, 123 insertions(+), 118 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index a706617a..b1321ce9 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -9,16 +9,22 @@ def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: - """Get an input method class by it's name. - - :returns: `type[IInputMethod]` on success, `None` on failure. - """ + """Get an input method class by its name.""" for input_method in INPUT_METHODS: if inmth == input_method["path"]: return input_method["component"] return None +@dataclass +class TimerState: + """Timer state class.""" + + active: bool = False + container: ui.timer | None = None + start: float | None = None + + @dataclass class WpmTesterPageState: """The page state.""" @@ -27,143 +33,142 @@ class WpmTesterPageState: text: str -async def wpm_tester_page(method: str) -> None: - """Create the actual page which tests the wpm. - - Usage: - In main.py, use @ui.page("/test/{method}")(this) then this takes - the method from the url - """ - state = WpmTesterPageState("") - timer_on = False - timer_container = None - start_time = None - - input_method_def = get_input_method_by_name(method) - if input_method_def is None: - ui.navigate.to("/") - return - - ui.add_css(""" - .header { - height: 8vh; - align-items: center; - } - .h1 { - font-family: Arial; - font-size: 35px; - font-weight: bold; - } - .input-method-container { - display: flex; - flex-direction: column; - justify-content: space-evenly; - position: absolute; - width: 90vw; - height: 85vh; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - border-radius: 20px - } - body { - margin: 0; - padding: 0; - height: 100%; - width: 100%; - } - """) - - # Header - with ( - ui.header(wrap=False) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") - .classes("items-center justify-between header") - ): +ui.add_css(""" +.header { + display: flex; + align-items: center; + justify-content: space-between; + height: 8vh; + padding: 0 1rem; +} + +.header-title { + font-family: Arial, sans-serif; + font-size: 35px; + font-weight: bold; +} + +.input-method-container { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + position: absolute; + width: 90vw; + height: 85vh; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 20px; +} + +body { + margin: 0; + padding: 0; + height: 100%; + width: 100%; +} + +.item-hover:hover { + background-color: #313131; +} +""") + + +def create_header() -> ui.label: + """Create header label.""" + with ui.header(wrap=False).style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("header"): with ui.card().props("flat"): # small logo placeholder pass - ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("h1") + ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("header-title") ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") - # Sidebar with ( - ui.right_drawer( - value=False, - fixed=False, - ) + ui.right_drawer(value=False, fixed=False) .style(f"background-color: {COLOR_STYLE['secondary_bg']}") .props("overlay") .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), ): - # Home Button - with ( - ui.list().classes("fit"), - ui.item(on_click=lambda: ui.navigate.to("/")) - .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), - ui.item_section(), - ): - ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") - - with ui.list().classes("fit"), ui.column().classes("w-full items-center"): + with ui.list().classes("fit"): + with ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable").classes("item-hover"): + ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") ui.separator().style("background-color: #313131; width: 95%;") - # Input method buttons with ui.list().classes("fit"): - for input in INPUT_METHODS: - with ( - ui.item(on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path)) - .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), - ui.item_section(), - ): - ui.label(input["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") - - # Main body - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") + for input_method in INPUT_METHODS: + path = f"/test/{input_method['path']}" + with ui.item(on_click=lambda _, p=path: ui.navigate.to(p)).props("clickable").classes("item-hover"): + ui.label(input_method["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") + + +def create_time_chips() -> tuple[ui.chip, ui.chip, ui.chip]: + """Create chips for timer, wpm, and wph.""" + with ui.row().classes("w-full justify-center items-center gap-4"): + timer_label = ui.chip("TIMER: 0:00", color="#6AC251", icon="timer") + wpm_label = ui.chip("WPM: --", color="#e5e5e5", icon="watch") + wph_label = ui.chip("WPH: --", color="#e5e5e5", icon="hourglass_top") + + return timer_label, wpm_label, wph_label + + +def setup( + method: str, + text_to_use: str, + state: WpmTesterPageState, + chip_package: tuple[ui.chip, ui.chip, ui.chip], + iv: input_view.input_view, +) -> None: + """Set up input method updates and timer handling.""" + input_method_def = get_input_method_by_name(method) + if input_method_def is None: + return - with ( - ui.element("div") - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") - .classes("input-method-container items-center") - ): - # Sentence & timer div - with ui.element("div"): - # TODO: get og text from babbler module - iv = input_view.input_view("the quick brown fox jumps over the lazy dog").classes("w-full") - with ui.row().classes("w-full justify-center items-center gap-4"): - timer_label = ui.chip("TIMER: 0:00", color="#6AC251", icon="timer") - wpm_label = ui.chip("WPM: --", color="#e5e5e5", icon="watch") - wph_label = ui.chip("WPH: --", color="#e5e5e5", icon="hourglass_top") - - # Input method div - with ui.element("div"): - input_method = input_method_def() + input_method = input_method_def() + timer = TimerState() + timer_label, wpm_label, wph_label = chip_package + + def stop_timer() -> None: + if timer.container: + timer.container.deactivate() def on_text_update(txt: str) -> None: - nonlocal timer_on, timer_container, text_to_use, start_time - if not timer_on: - timer_container = ui.timer(1, lambda: timer_label.set_text(iv.update_timer())) - timer_on = True - start_time = time.time() + if not timer.active: + timer.container = ui.timer(1, lambda: timer_label.set_text(iv.update_timer())) + timer.active = True + timer.start = time.time() + iv.set_text(txt) state.text = txt if len(txt) == len(text_to_use): - elapsed_seconds = time.time() - start_time - if elapsed_seconds > 0: - chars_typed = len(txt) - wpm = (chars_typed / 5) / (elapsed_seconds / 60) + elapsed = time.time() - timer.start if timer.start else 0 + if elapsed > 0: + wpm = (len(txt) / 5) / (elapsed / 60) wpm_label.set_text(f"Finished! WPM: {int(wpm)}") wph_label.set_text(f"Finished! WPH: {int(wpm * 60)}") stop_timer() - def stop_timer() -> None: - nonlocal timer_container - if timer_container: - timer_container.deactivate() - input_method.on_text_update(on_text_update) - ui.on("disconnect", stop_timer) + + +async def wpm_tester_page(method: str) -> None: + """Create the WPM tester page for a given input method.""" + input_method_def = get_input_method_by_name(method) + if input_method_def is None: + ui.navigate.to("/") + return + + state = WpmTesterPageState("") + text_to_use = "the quick brown fox jumps over the lazy dog" + + create_header() + + ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") + + with ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"): + iv = input_view.input_view(text_to_use).classes("w-full") + + chip_package = create_time_chips() + setup(method, text_to_use, state, chip_package, iv) From 2060a0ec88f9f961083e6e51fb85e98768dd2327 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 13:22:26 -0500 Subject: [PATCH 125/196] movement adjustments --- src/platformer_input/platformer_constants.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 15e0500a..3e163711 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -9,11 +9,11 @@ JUMP_FORCE = 5 """Max player speed.""" -MOV_SPEED = 4 +MOV_SPEED = 2 """How fast the player accelerates.""" -ACCEL_SPEED = 5 +ACCEL_SPEED = 3 """Essentially friction simulation""" -VELOCITY_DECAY_RATE = 5 +VELOCITY_DECAY_RATE = 10 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" From 582ae21b522f3c9cd4664a616e895678154feda9 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 11:57:43 -0700 Subject: [PATCH 126/196] Fix header spacing --- src/wpm_tester.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index b1321ce9..731712cf 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -34,14 +34,6 @@ class WpmTesterPageState: ui.add_css(""" -.header { - display: flex; - align-items: center; - justify-content: space-between; - height: 8vh; - padding: 0 1rem; -} - .header-title { font-family: Arial, sans-serif; font-size: 35px; @@ -75,9 +67,13 @@ class WpmTesterPageState: """) -def create_header() -> ui.label: +def create_header() -> None: """Create header label.""" - with ui.header(wrap=False).style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("header"): + with ( + ui.header(wrap=False) + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes("flex items-center justify-between h-[8vh] py-0 px-4") + ): with ui.card().props("flat"): # small logo placeholder pass ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("header-title") From 93e4e0907d6bc4f7aa32ac8f1f9aa6bdb2a1efe2 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 12:01:10 -0700 Subject: [PATCH 127/196] Increase title size --- src/wpm_tester.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 731712cf..b6d3f0a0 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -34,12 +34,6 @@ class WpmTesterPageState: ui.add_css(""" -.header-title { - font-family: Arial, sans-serif; - font-size: 35px; - font-weight: bold; -} - .input-method-container { display: flex; flex-direction: column; @@ -76,7 +70,11 @@ def create_header() -> None: ): with ui.card().props("flat"): # small logo placeholder pass - ui.label(PROJECT_NAME.upper()).style(f"color: {COLOR_STYLE['primary']}").classes("header-title") + ( + ui.label(PROJECT_NAME.upper()) + .style(f"color: {COLOR_STYLE['primary']}; font-family: Arial, sans-serif;") + .classes("text-4xl font-bold") + ) ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") with ( From 0dade0b5e6b00b8e004a91cc10f9c1d4f7c35c17 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 13:10:54 -0700 Subject: [PATCH 128/196] Add main page div --- src/wpm_tester.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index b6d3f0a0..0efea02f 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -34,20 +34,6 @@ class WpmTesterPageState: ui.add_css(""" -.input-method-container { - display: flex; - flex-direction: column; - justify-content: space-evenly; - align-items: center; - position: absolute; - width: 90vw; - height: 85vh; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - border-radius: 20px; -} - body { margin: 0; padding: 0; @@ -159,10 +145,22 @@ async def wpm_tester_page(method: str) -> None: create_header() + # Main body ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") - with ui.element("div").style(f"background-color: {COLOR_STYLE['secondary_bg']}").classes("input-method-container"): - iv = input_view.input_view(text_to_use).classes("w-full") - - chip_package = create_time_chips() - setup(method, text_to_use, state, chip_package, iv) + with ( + ui.element("div") + .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .classes( + """flex flex-col justify-evenly items-center absolute w-[90vw] h-[85vh] left-1/2 top-1/2 + transform -translate-x-1/2 -translate-y-1/2 rounded-xl""" + ) + ): + # Sentence and timer div + with ui.element("div"): + iv = input_view.input_view(text_to_use).classes("w-full") + chip_package = create_time_chips() + + # Input method div + with ui.element("div"): + setup(method, text_to_use, state, chip_package, iv) From 103e2df5aa6a5e5cedaff3cd62887460eca5c3f9 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 15:50:40 -0500 Subject: [PATCH 129/196] gravity and vertical collision --- src/platformer_input/__init__.py | 2 +- src/platformer_input/platformer_constants.py | 4 +- src/platformer_input/platformer_simulation.py | 53 ++++++++++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 3a2db335..15c12598 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -8,7 +8,7 @@ from platformer_input.platformer_simulation import PlatformerPhysicsSimulation ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") -INITIAL_POS = (0, 10) +INITIAL_POS = (0, 5) FPS = 144 diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 3e163711..2c155cef 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -7,13 +7,15 @@ """Height in tiles of the whole scene.""" SCENE_HEIGHT = 9 -JUMP_FORCE = 5 +JUMP_FORCE = 2 """Max player speed.""" MOV_SPEED = 2 """How fast the player accelerates.""" ACCEL_SPEED = 3 """Essentially friction simulation""" VELOCITY_DECAY_RATE = 10 +"""Gravity force""" +GRAVITY_FORCE = 15 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 3b0e894a..8bbcfccc 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -1,7 +1,10 @@ +import math import time import platformer_input.platformer_constants as constants +EPSILON = 1e-6 + class PlatformerPhysicsSimulation: """The physics simulation.""" @@ -16,7 +19,7 @@ class PlatformerPhysicsSimulation: _last_tick_at: float _keys: set[str] - _world: list[list[str]] + _world: list[list[bool]] def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial @@ -27,8 +30,10 @@ def __init__(self, initial: tuple[int, int]) -> None: self._last_tick_at = time.perf_counter() self._keys = set() - self._world = constants.world_grid() - self._world.reverse() + # turn into mask for efficient access + self._world = [] + world = constants.world_grid() + self._world = [[cell == "#" for cell in row] for row in world] def set_held_keys(self, keys: set[str]) -> None: """Set the current player-held keys.""" @@ -52,13 +57,47 @@ def tick(self) -> None: self._yvel = min(constants.MOV_SPEED, self._yvel + delta_accel) self.player_x += self._xvel - self.player_y += self._yvel decay_factor = 1 - constants.VELOCITY_DECAY_RATE * self._deltatime decay_factor = max(decay_factor, 0) self._xvel *= decay_factor - self._yvel *= decay_factor - def _collide(self, world: tuple[int, int]) -> bool: + self._apply_y_velocity() + + def _apply_y_velocity(self) -> None: + """Apply gravity and vertical player clamping.""" + self._yvel += constants.GRAVITY_FORCE * self._deltatime + dy = self._yvel * self._deltatime + if dy != 0: + new_y = self.player_y + dy + if self._collide((self.player_x, new_y)): + self._yvel = 0 + if dy > 0: + player_edge_b = self.player_y + 1 + tile_edge = int(player_edge_b) + new_y = tile_edge - EPSILON + else: + tile_edge = int(self.player_y) + new_y = tile_edge + EPSILON + self.player_y = new_y + + def _collide(self, player: tuple[float, float]) -> bool: """Check if a target cell contains a wall.""" - return self._world[world[1]][world[0]] == "#" + player_left = player[0] + player_right = player[0] + 1 + player_top = player[1] + player_bottom = player[1] + 1 + + left_tile = math.floor(player_left) + right_tile = math.floor(player_right - EPSILON) + top_tile = math.floor(player_top) + bottom_tile = math.floor(player_bottom - EPSILON) + + for tile_y in range(top_tile, bottom_tile + 1): + for tile_x in range(left_tile, right_tile + 1): + in_map = 0 <= tile_y < len(self._world) and 0 <= tile_x < len(self._world[0]) + if not in_map: + continue + if self._world[tile_y][tile_x]: + return True + return False From 5a7a1fce44c3df054211d1915ff95375b89d8194 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 13:54:30 -0700 Subject: [PATCH 130/196] Fix main page div alignment --- src/wpm_tester.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 0efea02f..086e1fa7 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -157,10 +157,10 @@ async def wpm_tester_page(method: str) -> None: ) ): # Sentence and timer div - with ui.element("div"): + with ui.element("div").classes("flex items-center w-full h-1/4 p-5"): iv = input_view.input_view(text_to_use).classes("w-full") chip_package = create_time_chips() # Input method div - with ui.element("div"): + with ui.element("div").classes("align-items w-full h-3/4"): setup(method, text_to_use, state, chip_package, iv) From 381fb72a522265abe3fd6ce9ae7a44b8685daa04 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 13:58:53 -0700 Subject: [PATCH 131/196] Remove add.css() --- src/wpm_tester.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 086e1fa7..1cc7c16d 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -33,20 +33,6 @@ class WpmTesterPageState: text: str -ui.add_css(""" -body { - margin: 0; - padding: 0; - height: 100%; - width: 100%; -} - -.item-hover:hover { - background-color: #313131; -} -""") - - def create_header() -> None: """Create header label.""" with ( From 6a6e17bed085271baee1f5b718a88c67c84d78f7 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 14:10:04 -0700 Subject: [PATCH 132/196] Fix sidebar --- src/wpm_tester.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 1cc7c16d..0fad52fa 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -56,15 +56,27 @@ def create_header() -> None: .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), ): - with ui.list().classes("fit"): - with ui.item(on_click=lambda: ui.navigate.to("/")).props("clickable").classes("item-hover"): - ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") + with ( + ui.list().classes("fit"), + ui.item(on_click=lambda: ui.navigate.to("/")) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + ui.item_section(), + ): + ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") + + with ui.list().classes("fit"), ui.column().classes("w-full items-center"): ui.separator().style("background-color: #313131; width: 95%;") with ui.list().classes("fit"): for input_method in INPUT_METHODS: path = f"/test/{input_method['path']}" - with ui.item(on_click=lambda _, p=path: ui.navigate.to(p)).props("clickable").classes("item-hover"): + with ( + ui.item(on_click=lambda _, p=path: ui.navigate.to(p)) + .props("clickable") + .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + ui.item_section(), + ): ui.label(input_method["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") From 52858571a6614e9cfcaf4c4e61d8599a767d9f41 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 14:11:27 -0700 Subject: [PATCH 133/196] Update create_header() docstring --- src/wpm_tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 0fad52fa..b565e935 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -34,7 +34,7 @@ class WpmTesterPageState: def create_header() -> None: - """Create header label.""" + """Create header and sidebar.""" with ( ui.header(wrap=False) .style(f"background-color: {COLOR_STYLE['secondary_bg']}") From 91b6d53821084eb3d2ee546bd72f8d22618b9b67 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 14:12:24 -0700 Subject: [PATCH 134/196] Add create_header() comments --- src/wpm_tester.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index b565e935..1a6eab56 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -35,6 +35,7 @@ class WpmTesterPageState: def create_header() -> None: """Create header and sidebar.""" + # Header with ( ui.header(wrap=False) .style(f"background-color: {COLOR_STYLE['secondary_bg']}") @@ -49,6 +50,7 @@ def create_header() -> None: ) ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") + # Sidebar with ( ui.right_drawer(value=False, fixed=False) .style(f"background-color: {COLOR_STYLE['secondary_bg']}") @@ -56,6 +58,7 @@ def create_header() -> None: .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), ): + # Home nav button with ( ui.list().classes("fit"), ui.item(on_click=lambda: ui.navigate.to("/")) @@ -68,6 +71,7 @@ def create_header() -> None: with ui.list().classes("fit"), ui.column().classes("w-full items-center"): ui.separator().style("background-color: #313131; width: 95%;") + # Input method nav buttons with ui.list().classes("fit"): for input_method in INPUT_METHODS: path = f"/test/{input_method['path']}" From a5a84c680f109aa90dd2cf99c55c7bd449943593 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 16:29:56 -0500 Subject: [PATCH 135/196] Jumping --- src/platformer_input/platformer_constants.py | 2 +- src/platformer_input/platformer_simulation.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 2c155cef..d384dbc6 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -7,7 +7,7 @@ """Height in tiles of the whole scene.""" SCENE_HEIGHT = 9 -JUMP_FORCE = 2 +JUMP_FORCE = 12 """Max player speed.""" MOV_SPEED = 2 """How fast the player accelerates.""" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 8bbcfccc..e5ff751c 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -51,10 +51,8 @@ def tick(self) -> None: self._xvel = min(constants.MOV_SPEED, self._xvel + delta_accel) if "ArrowLeft" in self._keys: self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) - if "ArrowUp" in self._keys: - self._yvel = max(-constants.MOV_SPEED, self._yvel - delta_accel) - if "ArrowDown" in self._keys: - self._yvel = min(constants.MOV_SPEED, self._yvel + delta_accel) + if "ArrowUp" in self._keys and self._collide((self.player_x, self.player_y + 2 * EPSILON)): + self._yvel = -constants.JUMP_FORCE self.player_x += self._xvel From 9a7bd126c55eb0b728bbccafffeecaea95f4d7c8 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 16:32:11 -0500 Subject: [PATCH 136/196] Horizontal movement As of now, movement is done! --- src/platformer_input/platformer_constants.py | 4 ++-- src/platformer_input/platformer_simulation.py | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index d384dbc6..1999b76e 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -9,9 +9,9 @@ JUMP_FORCE = 12 """Max player speed.""" -MOV_SPEED = 2 +MOV_SPEED = 200 """How fast the player accelerates.""" -ACCEL_SPEED = 3 +ACCEL_SPEED = 150 """Essentially friction simulation""" VELOCITY_DECAY_RATE = 10 """Gravity force""" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index e5ff751c..cf680aeb 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -54,13 +54,27 @@ def tick(self) -> None: if "ArrowUp" in self._keys and self._collide((self.player_x, self.player_y + 2 * EPSILON)): self._yvel = -constants.JUMP_FORCE - self.player_x += self._xvel + self._apply_x_velocity() + self._apply_y_velocity() + def _apply_x_velocity(self) -> None: + """Apply horizontal velocity and decay.""" decay_factor = 1 - constants.VELOCITY_DECAY_RATE * self._deltatime decay_factor = max(decay_factor, 0) self._xvel *= decay_factor - - self._apply_y_velocity() + dx = self._xvel * self._deltatime + if dx != 0: + new_x = self.player_x + dx + if self._collide((new_x, self.player_y)): + self._xvel = 0 + if dx > 0: + player_edge_r = self.player_x + 1 + tile_edge = int(player_edge_r) + new_x = tile_edge - EPSILON + else: + tile_edge = int(self.player_x) + new_x = tile_edge + EPSILON + self.player_x = new_x def _apply_y_velocity(self) -> None: """Apply gravity and vertical player clamping.""" From 49cc843b666aea1c10ff8c9fbae06596ffae73d9 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 16:35:36 -0500 Subject: [PATCH 137/196] fix falling through the ground on spawn --- src/platformer_input/__init__.py | 2 +- src/platformer_input/platformer_simulation.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 15c12598..5f17e66b 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -8,7 +8,7 @@ from platformer_input.platformer_simulation import PlatformerPhysicsSimulation ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") -INITIAL_POS = (0, 5) +INITIAL_POS = (0, 9) FPS = 144 diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index cf680aeb..1aa9c76a 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -27,7 +27,7 @@ def __init__(self, initial: tuple[int, int]) -> None: self._yvel = 0 self._deltatime = 0 - self._last_tick_at = time.perf_counter() + self._last_tick_at = 0 self._keys = set() # turn into mask for efficient access @@ -42,6 +42,8 @@ def set_held_keys(self, keys: set[str]) -> None: def tick(self) -> None: """Run a tick of the simulation.""" current_time = time.perf_counter() + if self._last_tick_at == 0: + self._last_tick_at = current_time self._deltatime = current_time - self._last_tick_at self._last_tick_at = current_time From 7c90de1b0ef60f3699574ba156140e9703fb7fac Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 16:46:07 -0500 Subject: [PATCH 138/196] qol updated map to have a box around it changed spawn pos --- src/platformer_input/__init__.py | 2 +- src/platformer_input/platformer_constants.py | 33 +++++++++++--------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 5f17e66b..a619d97d 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -8,7 +8,7 @@ from platformer_input.platformer_simulation import PlatformerPhysicsSimulation ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") -INITIAL_POS = (0, 9) +INITIAL_POS = (1, 10) FPS = 144 diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 1999b76e..96aa21c5 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -7,7 +7,7 @@ """Height in tiles of the whole scene.""" SCENE_HEIGHT = 9 -JUMP_FORCE = 12 +JUMP_FORCE = 14 """Max player speed.""" MOV_SPEED = 200 """How fast the player accelerates.""" @@ -15,25 +15,30 @@ """Essentially friction simulation""" VELOCITY_DECAY_RATE = 10 """Gravity force""" -GRAVITY_FORCE = 15 +GRAVITY_FORCE = 16 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" COLOR_GROUND = "#181818" -SCENE = """###################################### - u v w x y z . ! , - - ############ ############## - - k l m n o p q r s t - - ############## ################ - - a b c d e f g h i j - -###################################### +SCENE = """ +######################################## +# # +# u v w x y z . ! , # +# # +# # +# ########### ############# # +# # +# k l m n o p q r s t # +# # +# # +# ############## ################ # +# # +# a b c d e f g h i j # +# # +# # +######################################## """ From 368a8f06279ff055e7476858a10941809f85137f Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 16:53:18 -0500 Subject: [PATCH 139/196] include letters in collision detection --- src/platformer_input/platformer_simulation.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 1aa9c76a..17d5e3d1 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -1,5 +1,6 @@ import math import time +import typing import platformer_input.platformer_constants as constants @@ -19,7 +20,8 @@ class PlatformerPhysicsSimulation: _last_tick_at: float _keys: set[str] - _world: list[list[bool]] + # 0: air, 1: block, 2: letter + _world: list[list[typing.Literal[0, 1, 2]]] def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial @@ -33,7 +35,7 @@ def __init__(self, initial: tuple[int, int]) -> None: # turn into mask for efficient access self._world = [] world = constants.world_grid() - self._world = [[cell == "#" for cell in row] for row in world] + self._world = [[0 if cell == " " else 1 if cell == "#" else 2 for cell in row] for row in world] def set_held_keys(self, keys: set[str]) -> None: """Set the current player-held keys.""" @@ -112,6 +114,6 @@ def _collide(self, player: tuple[float, float]) -> bool: in_map = 0 <= tile_y < len(self._world) and 0 <= tile_x < len(self._world[0]) if not in_map: continue - if self._world[tile_y][tile_x]: + if self._world[tile_y][tile_x] > 0: return True return False From 078e46b6913e58cc3ad07e6fefc2e7523daafbd6 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 17:29:13 -0500 Subject: [PATCH 140/196] collision refactor: can detect letter hits --- src/platformer_input/platformer_simulation.py | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 17d5e3d1..a47cead3 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -1,12 +1,25 @@ import math import time import typing +from enum import Enum import platformer_input.platformer_constants as constants EPSILON = 1e-6 +class TileType(Enum): + """A type of tile.""" + + AIR = 0 + BLOCK = 1 + LETTER = 2 + + def collide(self) -> bool: + """Whether collision is enabled on this tile.""" + return self.value > 0 + + class PlatformerPhysicsSimulation: """The physics simulation.""" @@ -20,8 +33,7 @@ class PlatformerPhysicsSimulation: _last_tick_at: float _keys: set[str] - # 0: air, 1: block, 2: letter - _world: list[list[typing.Literal[0, 1, 2]]] + _world: list[list[str]] def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial @@ -32,10 +44,7 @@ def __init__(self, initial: tuple[int, int]) -> None: self._last_tick_at = 0 self._keys = set() - # turn into mask for efficient access - self._world = [] - world = constants.world_grid() - self._world = [[0 if cell == " " else 1 if cell == "#" else 2 for cell in row] for row in world] + self._world = constants.world_grid() def set_held_keys(self, keys: set[str]) -> None: """Set the current player-held keys.""" @@ -55,12 +64,16 @@ def tick(self) -> None: self._xvel = min(constants.MOV_SPEED, self._xvel + delta_accel) if "ArrowLeft" in self._keys: self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) - if "ArrowUp" in self._keys and self._collide((self.player_x, self.player_y + 2 * EPSILON)): + if "ArrowUp" in self._keys and self._collides((self.player_x, self.player_y + 2 * EPSILON)): self._yvel = -constants.JUMP_FORCE self._apply_x_velocity() self._apply_y_velocity() + def _fon_letter(self, letter: str) -> None: + """Call when a letter should be pushed.""" + print("hit", letter) + def _apply_x_velocity(self) -> None: """Apply horizontal velocity and decay.""" decay_factor = 1 - constants.VELOCITY_DECAY_RATE * self._deltatime @@ -69,7 +82,7 @@ def _apply_x_velocity(self) -> None: dx = self._xvel * self._deltatime if dx != 0: new_x = self.player_x + dx - if self._collide((new_x, self.player_y)): + if self._collides((new_x, self.player_y)): self._xvel = 0 if dx > 0: player_edge_r = self.player_x + 1 @@ -86,7 +99,7 @@ def _apply_y_velocity(self) -> None: dy = self._yvel * self._deltatime if dy != 0: new_y = self.player_y + dy - if self._collide((self.player_x, new_y)): + if collision_result := self._collision_tile((self.player_x, new_y)): self._yvel = 0 if dy > 0: player_edge_b = self.player_y + 1 @@ -95,10 +108,16 @@ def _apply_y_velocity(self) -> None: else: tile_edge = int(self.player_y) new_y = tile_edge + EPSILON + if collision_result != "#": + self._fon_letter(collision_result) self.player_y = new_y - def _collide(self, player: tuple[float, float]) -> bool: - """Check if a target cell contains a wall.""" + def _collides(self, player: tuple[float, float]) -> bool: + """Check if a position collides with a tile.""" + return bool(self._collision_tile(player)) + + def _collision_tile(self, player: tuple[float, float]) -> str | typing.Literal[False]: + """Check if the position collides with a tile, if so get the tile data.""" player_left = player[0] player_right = player[0] + 1 player_top = player[1] @@ -114,6 +133,6 @@ def _collide(self, player: tuple[float, float]) -> bool: in_map = 0 <= tile_y < len(self._world) and 0 <= tile_x < len(self._world[0]) if not in_map: continue - if self._world[tile_y][tile_x] > 0: - return True + if (v := self._world[tile_y][tile_x]) != " ": + return v return False From c881fa528d3809fb8aa1567b74833354c26dd414 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 17:47:30 -0500 Subject: [PATCH 141/196] Display letter in letter tiles --- src/platformer_input/platformer_scene_cmp.py | 29 +++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 694de797..7d241ab6 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -11,7 +11,21 @@ class PlatformerSceneComponent(ui.element): def __init__(self, position: tuple[int, int]) -> None: super().__init__("div") - + ui.add_css(f""".platformer-input-method-element .tile-ground {{ + background-color: {c.COLOR_GROUND}; +}} +.platformer-input-method-element .tile-sky {{ + background-color: {c.COLOR_BG}; +}} +.platformer-input-method-element .tile-letter {{ + background-color: red; + text-align: center; + color: white; + font-weight: bold; + font-size: 1.25em; +}} +""") + self.classes("platformer-input-method-element") with self: self.mask_element = ui.element("div") self.mask_element.style( @@ -42,7 +56,10 @@ def __init__(self, position: tuple[int, int]) -> None: with self.map_container: for row in self.world: for cell in row: - ui.element("div").style(f"background-color:{self._get_bg_color(cell)}") + if cell in "# ": + ui.element("div").classes("tile-ground" if cell == "#" else "tile-sky") + else: + ui.label(cell).classes("tile-letter") self.move_player(*position) @@ -53,11 +70,3 @@ def move_player(self, player_x: float, player_y: float) -> None: px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE self.map_container.style(f"top:{px_top}px") - - def _get_bg_color(self, scp: str) -> str: - """Get the background color in a tile by the tile type.""" - if scp == "#": - return c.COLOR_GROUND - if scp == " ": - return c.COLOR_BG - return "red" From 74ffd0c760d47ab5e237e8a88022c335a75ab563 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 18:02:50 -0500 Subject: [PATCH 142/196] Full letter handling into input method --- src/platformer_input/__init__.py | 5 +++++ src/platformer_input/platformer_constants.py | 2 +- src/platformer_input/platformer_scene_cmp.py | 5 +++-- src/platformer_input/platformer_simulation.py | 13 ++++++++----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index a619d97d..a7655957 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -27,6 +27,7 @@ def __init__(self) -> None: self.callbacks = [] self.scene = PlatformerSceneComponent(INITIAL_POS) self.physics = PlatformerPhysicsSimulation(INITIAL_POS) + self.physics.on_letter(self._hphysics_letter_press) self.held_keys = set() ui.keyboard(lambda e: self.keyboard_handler(e)) ui.timer(1 / FPS, lambda: self._hinterv()) @@ -43,6 +44,10 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: self.held_keys.remove(evk) self.physics.set_held_keys(self.held_keys) + def _hphysics_letter_press(self, letter: str) -> None: + """Call when the physics engine registers a letter press.""" + print("Got letter", letter) + def _hinterv(self) -> None: """Run every game tick.""" self.physics.tick() diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 96aa21c5..b5819341 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -25,7 +25,7 @@ SCENE = """ ######################################## # # -# u v w x y z . ! , # +# u v w x y z _ < , # # # # # # ########### ############# # diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 7d241ab6..6f448301 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -22,7 +22,8 @@ def __init__(self, position: tuple[int, int]) -> None: text-align: center; color: white; font-weight: bold; - font-size: 1.25em; + font-size: 1.15em; + padding-top: 2px; }} """) self.classes("platformer-input-method-element") @@ -59,7 +60,7 @@ def __init__(self, position: tuple[int, int]) -> None: if cell in "# ": ui.element("div").classes("tile-ground" if cell == "#" else "tile-sky") else: - ui.label(cell).classes("tile-letter") + ui.label(cell.replace("_", "Spc").replace("<", "\u232b")).classes("tile-letter") self.move_player(*position) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index a47cead3..0f074e99 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -6,6 +6,7 @@ import platformer_input.platformer_constants as constants EPSILON = 1e-6 +type LetterHandler = typing.Callable[[str], None] class TileType(Enum): @@ -34,6 +35,7 @@ class PlatformerPhysicsSimulation: _keys: set[str] _world: list[list[str]] + _letter_handler: LetterHandler | None def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial @@ -45,6 +47,7 @@ def __init__(self, initial: tuple[int, int]) -> None: self._keys = set() self._world = constants.world_grid() + self._letter_handler = None def set_held_keys(self, keys: set[str]) -> None: """Set the current player-held keys.""" @@ -70,9 +73,9 @@ def tick(self) -> None: self._apply_x_velocity() self._apply_y_velocity() - def _fon_letter(self, letter: str) -> None: - """Call when a letter should be pushed.""" - print("hit", letter) + def on_letter(self, handler: LetterHandler) -> None: + """Register callback function for a letter being bumped.""" + self._letter_handler = handler def _apply_x_velocity(self) -> None: """Apply horizontal velocity and decay.""" @@ -108,8 +111,8 @@ def _apply_y_velocity(self) -> None: else: tile_edge = int(self.player_y) new_y = tile_edge + EPSILON - if collision_result != "#": - self._fon_letter(collision_result) + if collision_result != "#" and self._letter_handler: + self._letter_handler(collision_result) self.player_y = new_y def _collides(self, player: tuple[float, float]) -> bool: From 21dc2838291753c1d446e337a03a315f29234d37 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 18:20:07 -0500 Subject: [PATCH 143/196] Text callback in component --- src/platformer_input/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index a7655957..10983283 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -22,9 +22,11 @@ class PlatformerInputMethod(input_method_proto.IInputMethod): callbacks: list[typing.Callable[[str], None]] scene: PlatformerSceneComponent held_keys: set[str] + input_value: str def __init__(self) -> None: self.callbacks = [] + self.input_value = "" self.scene = PlatformerSceneComponent(INITIAL_POS) self.physics = PlatformerPhysicsSimulation(INITIAL_POS) self.physics.on_letter(self._hphysics_letter_press) @@ -46,7 +48,17 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: def _hphysics_letter_press(self, letter: str) -> None: """Call when the physics engine registers a letter press.""" - print("Got letter", letter) + if letter == "<": + if len(self.input_value) > 0: + self.input_value = self.input_value[:-1] + else: + self.input_value += letter.replace("_", " ") + self._run_callbacks() + + def _run_callbacks(self) -> None: + """Run all component text update callbacks.""" + for c in self.callbacks: + c(self.input_value) def _hinterv(self) -> None: """Run every game tick.""" From 03116a8614de2e9fea5e3a61b7a5f38b8a86d9a8 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 18:33:57 -0500 Subject: [PATCH 144/196] small qol - revert to 60fps - improve map sizing --- src/platformer_input/__init__.py | 2 +- src/platformer_input/platformer_constants.py | 34 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 10983283..922d1cad 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -9,7 +9,7 @@ ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") INITIAL_POS = (1, 10) -FPS = 144 +FPS = 60 class PlatformerInputMethod(input_method_proto.IInputMethod): diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index b5819341..01f3cc39 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -15,7 +15,7 @@ """Essentially friction simulation""" VELOCITY_DECAY_RATE = 10 """Gravity force""" -GRAVITY_FORCE = 16 +GRAVITY_FORCE = 17 COLOR_BG = "skyblue" COLOR_PLAYER = "purple" @@ -23,22 +23,22 @@ SCENE = """ -######################################## -# # -# u v w x y z _ < , # -# # -# # -# ########### ############# # -# # -# k l m n o p q r s t # -# # -# # -# ############## ################ # -# # -# a b c d e f g h i j # -# # -# # -######################################## +############################################# +# # +# u v w x y z _ < , # +# # +# # +# ############# ############## # +# # +# k l m n o p q r s t # +# # +# # +# ################ ################ # +# # +# a b c d e f g h i j # +# # +# # +############################################# """ From 81479e6bf77f6554eeeca5ca03893824458815bb Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 18:46:14 -0500 Subject: [PATCH 145/196] play tile effect on activate --- src/platformer_input/__init__.py | 1 + src/platformer_input/platformer_scene_cmp.py | 35 ++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 922d1cad..e7141141 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -48,6 +48,7 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: def _hphysics_letter_press(self, letter: str) -> None: """Call when the physics engine registers a letter press.""" + self.scene.play_bounce_effect(letter) if letter == "<": if len(self.input_value) > 0: self.input_value = self.input_value[:-1] diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 6f448301..772d60f0 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -8,24 +8,36 @@ class PlatformerSceneComponent(ui.element): mask_element: ui.element world: list[list[str]] + letter_el_map: dict[str, ui.label] def __init__(self, position: tuple[int, int]) -> None: super().__init__("div") - ui.add_css(f""".platformer-input-method-element .tile-ground {{ + ui.add_css( + f""".platformer-input-method-element .tile-ground {{ background-color: {c.COLOR_GROUND}; }} .platformer-input-method-element .tile-sky {{ background-color: {c.COLOR_BG}; -}} -.platformer-input-method-element .tile-letter {{ +}}""" + """ +.platformer-input-method-element .tile-letter { background-color: red; text-align: center; color: white; font-weight: bold; font-size: 1.15em; padding-top: 2px; -}} -""") +} +.platformer-input-method-element .tile-bounce { + animation: bounce 200ms ease; +} +@keyframes bounce { + 0% { margin-top: 0; margin-bottom: 0; } + 50% { margin-top: -5px; margin-bottom: 5px; background-color: darkred; scale: 1.05 } + 100% { margin-top: 0; margin-bottom: 0; } +} +""" + ) self.classes("platformer-input-method-element") with self: self.mask_element = ui.element("div") @@ -54,13 +66,16 @@ def __init__(self, position: tuple[int, int]) -> None: f"background-color:{c.COLOR_PLAYER};width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px" ) + self.letter_el_map = {} + with self.map_container: for row in self.world: for cell in row: if cell in "# ": ui.element("div").classes("tile-ground" if cell == "#" else "tile-sky") else: - ui.label(cell.replace("_", "Spc").replace("<", "\u232b")).classes("tile-letter") + lb = ui.label(cell.replace("_", "Spc").replace("<", "\u232b")).classes("tile-letter") + self.letter_el_map[cell] = lb self.move_player(*position) @@ -71,3 +86,11 @@ def move_player(self, player_x: float, player_y: float) -> None: px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE self.map_container.style(f"top:{px_top}px") + + def play_bounce_effect(self, letter: str) -> None: + """Play a short bounce effect on a letter tile.""" + tile = self.letter_el_map.get(letter) + if tile is None: + return + tile.classes("tile-bounce") + ui.timer(0.2, lambda: tile.classes(remove="tile-bounce"), once=True) From 809d03df2a8d7dc1e891cb3f48be598cdd75cd1e Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 17:17:31 -0700 Subject: [PATCH 146/196] installed faker and updated pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ae062bca..c177578b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "nicegui~=2.22.2", + "Faker~=37.5.3", ] [dependency-groups] From c4d47c623e47c61d03007115c5d81dab3f756196 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 17:19:33 -0700 Subject: [PATCH 147/196] imports for faker and random --- src/wpm_tester.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 1a6eab56..1616b8cb 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -135,6 +135,10 @@ def on_text_update(txt: str) -> None: ui.on("disconnect", stop_timer) +def create_sentence() -> str: + """Create sentence to use in challenge.""" + + async def wpm_tester_page(method: str) -> None: """Create the WPM tester page for a given input method.""" input_method_def = get_input_method_by_name(method) From 77f777a1a1d89773be8fba31c64e495b4751992c Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 19:29:13 -0500 Subject: [PATCH 148/196] map update --- src/platformer_input/platformer_constants.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 01f3cc39..24a567f7 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -25,15 +25,16 @@ SCENE = """ ############################################# # # -# u v w x y z _ < , # # # +# u v w x y z . ! _ < # # # -# ############# ############## # # # +# ################### +################## # # k l m n o p q r s t # # # # # -# ################ ################ # +# ################ ############### # # # # a b c d e f g h i j # # # From add0938087637ce4f7197bcece77d28092b17a44 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 17:31:34 -0700 Subject: [PATCH 149/196] function to create sentence --- src/wpm_tester.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 1616b8cb..04393e8d 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -1,12 +1,16 @@ +import secrets import time from dataclasses import dataclass +from faker import Faker from nicegui import ui import input_method_proto import input_view from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME +fake = Faker() + def get_input_method_by_name(inmth: str) -> type[input_method_proto.IInputMethod] | None: """Get an input method class by its name.""" @@ -137,6 +141,10 @@ def on_text_update(txt: str) -> None: def create_sentence() -> str: """Create sentence to use in challenge.""" + punctuation = [".", "!"] + sentence = fake.sentence(nb_words=7, variable_nb_words=False) + sentence[:-1] + secrets.choice(punctuation) + return sentence[:-1] + secrets.choice(punctuation) async def wpm_tester_page(method: str) -> None: @@ -147,7 +155,7 @@ async def wpm_tester_page(method: str) -> None: return state = WpmTesterPageState("") - text_to_use = "the quick brown fox jumps over the lazy dog" + text_to_use = create_sentence() create_header() From 6d634d68f90f0cfad7e5dc95369f0601e522e9c5 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 17:34:50 -0700 Subject: [PATCH 150/196] 6 words for sentence --- src/wpm_tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 04393e8d..e203eeb2 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -142,7 +142,7 @@ def on_text_update(txt: str) -> None: def create_sentence() -> str: """Create sentence to use in challenge.""" punctuation = [".", "!"] - sentence = fake.sentence(nb_words=7, variable_nb_words=False) + sentence = fake.sentence(nb_words=6, variable_nb_words=False) sentence[:-1] + secrets.choice(punctuation) return sentence[:-1] + secrets.choice(punctuation) From 53011eab626141ac537be15940126780ab640a81 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 18:04:53 -0700 Subject: [PATCH 151/196] Adjust render to input page --- src/audio_style_input/__init__.py | 41 ++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index d757a3bd..db036b76 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -4,6 +4,7 @@ from nicegui import app, ui +import config from input_method_proto import IInputMethod, TextUpdateCallback media = Path("./static") @@ -52,34 +53,46 @@ def create_intro_card(self) -> tuple[ui.card, ui.button]: tuple: (intro_card, start_button) """ - intro_card = ui.card().classes("w-[100vw] h-[50vh] flex justify-center items-center bg-[#2b87d1]") + intro_card = ui.card().classes( + f"w-full h-full flex justify-center items-center bg-[{config.COLOR_STYLE['secondary']}]" + ) with intro_card, ui.card().classes("no-shadow justify-center items-center"): - ui.label("WPM Battle: DJ Edition").classes("text-[86px]") - ui.label("Use an audio editor to test your typing skills").classes("text-[28px]") - start_button = ui.button("Get started!", color="#ff9900") + ui.label("WPM Battle: DJ Edition").classes("text-5xl font-bold") + ui.label("Use an audio editor to test your typing skills").classes("text-xl") + start_button = ui.button("Get started!", color=config.COLOR_STYLE["secondary"]) return intro_card, start_button - def create_main_content(self) -> tuple[ui.column, ui.image, ui.label, ui.row]: + def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui.row]: """Create main content with record image, letter label, and button row. Returns: tuple: (main_content container, record image, label, buttons row) """ - main_content = ui.column().classes("items-center gap-4 #2b87d1").style("display:none") + main_content = ui.column().classes("w-full h-full items-center gap-4 #2b87d1").style("display:none") with ( main_content, ui.card().classes( - "gap-8 w-[100vw] h-[75vh] flex flex-col justify-center items-center bg-[#2b87d1]", + f"gap-4 w-full h-full flex flex-col justify-center items-center " + f"bg-[{config.COLOR_STYLE['secondary']}] px-16" ), ): - record = ui.image( - "/media/images/record.png", - ).style("width: 300px; transition: transform 0.05s linear;") - label = ui.label("Current letter: A") - buttons_row = ui.row().style("gap: 10px") - buttons_row_2 = ui.row().style("gap: 10x") - return main_content, record, label, buttons_row, buttons_row_2 + with ui.element("div").classes("w-full flex justify-center items-center"): + chip = ui.chip(text="Current letter: A", color=f"{config.COLOR_STYLE['contrast']}").classes("text-2xl") + with ui.element("div").classes("flex flex-row w-full justify-between"): + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + record = ( + ui.image( + "/media/images/record.png", + ) + .style("transition: transform 0.05s linear;") + .classes("w-1/2") + ) + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + buttons_row = ui.row().style("gap: 10px") + buttons_row_2 = ui.row().style("gap: 10x") + + return main_content, record, chip, buttons_row, buttons_row_2 def cycle_char_select(self) -> None: """Select character set from Capital, Lower, and Special characters.""" From 8d12c5ff04d6e5272e94e42cad9636021587d045 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 18:50:48 -0700 Subject: [PATCH 152/196] Update layout --- src/audio_style_input/__init__.py | 34 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index db036b76..1437b8ec 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -54,12 +54,12 @@ def create_intro_card(self) -> tuple[ui.card, ui.button]: """ intro_card = ui.card().classes( - f"w-full h-full flex justify-center items-center bg-[{config.COLOR_STYLE['secondary']}]" + f"w-full h-full flex justify-center items-center bg-[{config.COLOR_STYLE['secondary_bg']}]" ) with intro_card, ui.card().classes("no-shadow justify-center items-center"): ui.label("WPM Battle: DJ Edition").classes("text-5xl font-bold") ui.label("Use an audio editor to test your typing skills").classes("text-xl") - start_button = ui.button("Get started!", color=config.COLOR_STYLE["secondary"]) + start_button = ui.button("Get started!", color=config.COLOR_STYLE["primary"]) return intro_card, start_button def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui.row]: @@ -74,23 +74,25 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui. main_content, ui.card().classes( f"gap-4 w-full h-full flex flex-col justify-center items-center " - f"bg-[{config.COLOR_STYLE['secondary']}] px-16" + f"bg-[{config.COLOR_STYLE['secondary_bg']}] px-16" ), + ui.element("div").classes("flex flex-row w-full justify-between"), ): - with ui.element("div").classes("w-full flex justify-center items-center"): - chip = ui.chip(text="Current letter: A", color=f"{config.COLOR_STYLE['contrast']}").classes("text-2xl") - with ui.element("div").classes("flex flex-row w-full justify-between"): - with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): - record = ( - ui.image( - "/media/images/record.png", - ) - .style("transition: transform 0.05s linear;") - .classes("w-1/2") + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + chip = ui.chip(text="Current letter: A", color=f"{config.COLOR_STYLE['contrast']}").classes( + "relative text-2xl top-[-100px]" + ) + buttons_row = ui.row().style("gap: 10px") + buttons_row_2 = ui.row().style("gap: 10x") + + with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): + record = ( + ui.image( + "/media/images/record.png", ) - with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): - buttons_row = ui.row().style("gap: 10px") - buttons_row_2 = ui.row().style("gap: 10x") + .style("transition: transform 0.05s linear;") + .classes("w-1/2") + ) return main_content, record, chip, buttons_row, buttons_row_2 From 10145cff6f717c93e5e40e03835c9feafbb2186d Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 19:06:57 -0700 Subject: [PATCH 153/196] Change contrast color --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index d2312fb8..92ebf7ed 100644 --- a/src/config.py +++ b/src/config.py @@ -50,5 +50,5 @@ class InputMethodSpec(TypedDict): "secondary": "#7D53DE", "primary_bg": "#111111", "secondary_bg": "#1B1B1B", - "contrast": "#F9F9F9", + "contrast": "#E9E9E9", } From c6c0daf0ded3b1fcb49653e898449d409f74770b Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 21:27:48 -0500 Subject: [PATCH 154/196] scene fix: remove empty line --- src/platformer_input/platformer_constants.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 24a567f7..e80cb8ce 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -22,8 +22,7 @@ COLOR_GROUND = "#181818" -SCENE = """ -############################################# +SCENE = """############################################# # # # # # u v w x y z . ! _ < # From 8b9a8d77eaab0e7d81abd21f892311f8c827be77 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 21:34:33 -0500 Subject: [PATCH 155/196] renderer perf: use transform: translate Using the transform property is faster than repeatedly updating top and left --- src/platformer_input/platformer_scene_cmp.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 772d60f0..a4c35b7a 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -43,7 +43,7 @@ def __init__(self, position: tuple[int, int]) -> None: self.mask_element = ui.element("div") self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" - f"background-color: {c.COLOR_BG}; position: relative; overflow: hidden; border: 2px solid black" + f"background-color: {c.COLOR_BG}; position: relative; overflow: hidden" ) self.world = c.world_grid() @@ -56,6 +56,7 @@ def __init__(self, position: tuple[int, int]) -> None: f"height:{len(self.world) * c.TILE_SIZE}px;" f"display:grid;grid-template-columns:repeat({len(self.world[0])}, {c.TILE_SIZE}px);" f"grid-template-rows:repeat({len(self.world)}, {c.TILE_SIZE}px);" + "left: 0; top: 0;" ) self.px_player_offset_lx = ((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2 @@ -82,10 +83,9 @@ def __init__(self, position: tuple[int, int]) -> None: def move_player(self, player_x: float, player_y: float) -> None: """Move the player in the renderer.""" px_left = self.px_player_offset_lx - player_x * c.TILE_SIZE - self.map_container.style(f"left:{px_left}px") - px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE - self.map_container.style(f"top:{px_top}px") + + self.map_container.style(f"transform: translate({px_left}px, {px_top}px)") def play_bounce_effect(self, letter: str) -> None: """Play a short bounce effect on a letter tile.""" From afb770b8e1f35d12d5bfeb709c1d948d5645454b Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 21:46:13 -0500 Subject: [PATCH 156/196] Render using EMOJIS --- src/platformer_input/platformer_constants.py | 2 - src/platformer_input/platformer_scene_cmp.py | 46 +++++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index e80cb8ce..8536a4d0 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -17,9 +17,7 @@ """Gravity force""" GRAVITY_FORCE = 17 -COLOR_BG = "skyblue" COLOR_PLAYER = "purple" -COLOR_GROUND = "#181818" SCENE = """############################################# diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index a4c35b7a..39a3548a 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -2,6 +2,8 @@ import platformer_input.platformer_constants as c +EMOJIS = {"sky": "\U0001f600", "ground": "\U0001f61e", "letter": "\U0001f636"} + class PlatformerSceneComponent(ui.element): """Displays the characters and scene within the game.""" @@ -12,43 +14,45 @@ class PlatformerSceneComponent(ui.element): def __init__(self, position: tuple[int, int]) -> None: super().__init__("div") - ui.add_css( - f""".platformer-input-method-element .tile-ground {{ - background-color: {c.COLOR_GROUND}; -}} -.platformer-input-method-element .tile-sky {{ - background-color: {c.COLOR_BG}; -}}""" - """ -.platformer-input-method-element .tile-letter { - background-color: red; + ui.add_css(""".platformer-input-method-element .tile { + font-size: 30px; + margin-top: -7px; + margin-left: -7px; + border-radius: 5px; +} +.platformer-input-method-element .tile div { + transform: translateY(-42px); text-align: center; color: white; font-weight: bold; - font-size: 1.15em; - padding-top: 2px; + font-size: 1.5rem; + background-color: #0008; + border-radius: 100%; } .platformer-input-method-element .tile-bounce { animation: bounce 200ms ease; } @keyframes bounce { 0% { margin-top: 0; margin-bottom: 0; } - 50% { margin-top: -5px; margin-bottom: 5px; background-color: darkred; scale: 1.05 } + 50% { margin-top: -5px; margin-bottom: 5px; color: cyan; scale: 1.05 } 100% { margin-top: 0; margin-bottom: 0; } } -""" - ) +""") self.classes("platformer-input-method-element") with self: self.mask_element = ui.element("div") self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" - f"background-color: {c.COLOR_BG}; position: relative; overflow: hidden" + f"background-color: black; position: relative; overflow: hidden" ) self.world = c.world_grid() self.world_height = len(self.world) + self.initial_draw() + self.move_player(*position) + def initial_draw(self) -> None: + """Draw the map for the first time.""" with self.mask_element: self.map_container = ui.element("div") self.map_container.style( @@ -73,12 +77,12 @@ def __init__(self, position: tuple[int, int]) -> None: for row in self.world: for cell in row: if cell in "# ": - ui.element("div").classes("tile-ground" if cell == "#" else "tile-sky") + emoji = EMOJIS["ground"] if cell == "#" else EMOJIS["sky"] + ui.label(emoji).classes("tile") else: - lb = ui.label(cell.replace("_", "Spc").replace("<", "\u232b")).classes("tile-letter") - self.letter_el_map[cell] = lb - - self.move_player(*position) + with ui.label(EMOJIS["letter"]).classes("tile"): + lb = ui.label(cell.replace("<", "\u232b")) + self.letter_el_map[cell] = lb def move_player(self, player_x: float, player_y: float) -> None: """Move the player in the renderer.""" From 9e6950f20717435ebbe832eab1e3ff5a3bc4d1c4 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 21:47:25 -0500 Subject: [PATCH 157/196] rename PlatformerSceneComponent to PlatformerRendererComponent --- src/platformer_input/__init__.py | 10 +++++----- src/platformer_input/platformer_scene_cmp.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index e7141141..ee644a4b 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -4,7 +4,7 @@ from nicegui import ui import input_method_proto -from platformer_input.platformer_scene_cmp import PlatformerSceneComponent +from platformer_input.platformer_scene_cmp import PlatformerRendererComponent from platformer_input.platformer_simulation import PlatformerPhysicsSimulation ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") @@ -20,14 +20,14 @@ class PlatformerInputMethod(input_method_proto.IInputMethod): """ callbacks: list[typing.Callable[[str], None]] - scene: PlatformerSceneComponent + renderer: PlatformerRendererComponent held_keys: set[str] input_value: str def __init__(self) -> None: self.callbacks = [] self.input_value = "" - self.scene = PlatformerSceneComponent(INITIAL_POS) + self.renderer = PlatformerRendererComponent(INITIAL_POS) self.physics = PlatformerPhysicsSimulation(INITIAL_POS) self.physics.on_letter(self._hphysics_letter_press) self.held_keys = set() @@ -48,7 +48,7 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: def _hphysics_letter_press(self, letter: str) -> None: """Call when the physics engine registers a letter press.""" - self.scene.play_bounce_effect(letter) + self.renderer.play_bounce_effect(letter) if letter == "<": if len(self.input_value) > 0: self.input_value = self.input_value[:-1] @@ -64,7 +64,7 @@ def _run_callbacks(self) -> None: def _hinterv(self) -> None: """Run every game tick.""" self.physics.tick() - self.scene.move_player(self.physics.player_x, self.physics.player_y) + self.renderer.move_player(self.physics.player_x, self.physics.player_y) def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 39a3548a..06ad82ea 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -5,7 +5,7 @@ EMOJIS = {"sky": "\U0001f600", "ground": "\U0001f61e", "letter": "\U0001f636"} -class PlatformerSceneComponent(ui.element): +class PlatformerRendererComponent(ui.element): """Displays the characters and scene within the game.""" mask_element: ui.element From 5b8bef75cfe1d4b768a9681de2727cf65149e649 Mon Sep 17 00:00:00 2001 From: jks85 Date: Sun, 17 Aug 2025 19:41:09 -0700 Subject: [PATCH 158/196] Refactor color input method to interface with WPM tester page --- src/color_mixer_input/__init__.py | 99 ++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index 8b5f08eb..c3ed1581 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -3,9 +3,10 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments +from src.input_method_proto import IInputMethod, TextUpdateCallback -class ColorInputManager: +class ColorInputComponent(IInputMethod): """Implements the color-based typing input page. Allows user to type using the color palette for letters, spaces, and backspaces, and UI buttons/switches for @@ -15,19 +16,25 @@ class ColorInputManager: def __init__(self) -> None: # typed_char is input displayed to user based on selected color # confirmed_char is character typed after user confirmation + self.text_callback: TextUpdateCallback | None = None self.typed_text = "" self.typed_char = None self.confirmed_char = None self.selected_color = None self.shift_key_on = False - self.color_label = ui.label("None") - self.input_label = ui.label("None") - self.confirm_label = ui.label("None") - self.text_label = ui.label("None") + ( + self.color_picker_row, + self.color_label, + self.input_label, + self.command_buttons_row, + self.special_char_buttons_row, + ) = self.create_ui_content() + self.setup_ui_buttons() + self.color_dict = { "aqua": "#00FFFF", "blue": "#0000FF", - "cyan": "#00FFFF", + "camouflage": "#3C3910", "darkblue": "#00008B", "emerald": "00674F", "fuchsia": "#FF00FF", @@ -55,11 +62,15 @@ def __init__(self) -> None: "white": "#FFFFFF", } + def on_text_update(self, callback: TextUpdateCallback) -> None: + """Handle callbacks for text updates.""" + self.text_callback = callback + def special_character_handler(self, char: str) -> None: """Handle special character events. This function handles special characters (e.g. '.', '!', ',', '?'). These characters are input using ui.button - elements. + elements. Special characters are automatically output to the WPM page. """ self.selected_color = None self.typed_text += char @@ -71,7 +82,7 @@ def special_character_handler(self, char: str) -> None: def confirm_letter_handler(self) -> None: """Handle event when user clicks 'Confirm Letter'. - After user clicks confirm, letter is typed and text displays update. + After user clicks confirm, letter is typed and the WPM page updates the text. """ alphabet = string.ascii_letters if self.typed_char in alphabet: @@ -82,9 +93,13 @@ def confirm_letter_handler(self) -> None: def color_handler(self, element: ColorPickEventArguments) -> None: """Handle events when user selects a color. - Identifies closest color in dictionary and maps that color to a text output that is displayed to the user. + Identifies closest color in dictionary. Maps that color to a letter or action. + Black maps to backspace and white maps to space. Otherwise, colors map to the first letter of their name in the color dictionary. + + Letters must be confirmed by the user before being output to the WPM page. Special characters are automatically + output to the WPM page. """ print(type(element)) selected_color_hex = element.color @@ -115,27 +130,69 @@ def shift_handler(self) -> None: def update_helper_text(self) -> None: """Update helper text on page. - Displays the color and current character selected by the user based on an event." + Displays the color and (if applicable) current character selected based on the user click." """ - self.color_label.text = f"Color Selected: {self.selected_color}" - self.input_label.text = f"Character Selected: {self.typed_char}" + self.color_label.text = f"Current Color: {self.selected_color}" + self.input_label.text = f"Current Input: {self.typed_char}" self.color_label.update() self.input_label.update() def update_confirmation_text(self) -> None: """Update confirmed text on page. - Display the confirmed character selected and all confirmed text typed thus far." + Display the confirmed character selected and all confirmed text typed thus far. """ - self.confirm_label.text = f"Character Typed: {self.confirmed_char}" - self.text_label.text = f"Text Typed: {self.typed_text}" - self.confirm_label.update() - self.text_label.update() + if self.text_callback: + self.text_callback(self.typed_text) + + def create_ui_content(self) -> tuple[ui.row, ui.label, ui.label, ui.row, ui.row]: + """Create the frame to hold the color picker, text labels, and buttons. + + Returns: + tuple: (row for color picker, label for color selected, row for commands, row for special characters) + + """ + # Couldn't figure out how to position the palette so just stuck it in the page center. + # It is on top of the wpm timer currently + + with ui.column().classes("absolute-center"): + color_picker_row = ui.row() + + color_label = ui.label("Current Color: None") + input_label = ui.label("Current Input:") + command_buttons_row = ui.row().style("gap: 10px") + special_char_buttons_row = ui.row().style("gap: 10x") + return color_picker_row, color_label, input_label, command_buttons_row, special_char_buttons_row + + def setup_ui_buttons(self) -> None: + """Create the buttons and other dynamic elements (e.g. labels, switches) on page.""" + with self.color_picker_row, ui.button(icon="colorize").style("opacity:0;pointer-events:none"): + ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") + + with self.command_buttons_row, ui.button_group().classes("gap-10"): + ui.switch("CAPS LOCK", on_change=self.shift_handler).classes("bg-blue-500 text-white") + ui.button("Confirm Letter", on_click=self.confirm_letter_handler).classes("bg-blue-500 text-white") + + with self.special_char_buttons_row, ui.button_group().classes("gap-10"): + # creating wrappers to pass callback functions with parameters to buttons below + callback_with_period = partial(self.special_character_handler, ".") + callback_with_exclamation = partial(self.special_character_handler, "!") + callback_with_comma = partial(self.special_character_handler, ",") + callback_with_question_mark = partial(self.special_character_handler, "?") + + ui.button(".", on_click=callback_with_period).classes("bg-blue-500 text-white") + ui.button("!", on_click=callback_with_exclamation).classes("bg-blue-500 text-white") + ui.button(",", on_click=callback_with_comma).classes("bg-blue-500 text-white") + ui.button("?", on_click=callback_with_question_mark).classes("bg-blue-500 text-white") @ui.page("/color_input") def color_input_page(self) -> None: - """Create page displaying color_picker, character buttons, and text.""" + """Create page displaying color_picker, character buttons, and text. + + This method allows the class to create a page separately from the WPM tester + and was used for testing the class. + """ with ui.header(): ui.label("Title text here?") @@ -185,7 +242,7 @@ def find_closest_member(self, color_hex: str) -> str: :return: name (string) of the color with the closest hexcode """ color_dists = [ - (key, ColorInputManager.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict + (key, ColorInputComponent.color_dist(color_hex, self.color_dict[key]), 2) for key in self.color_dict ] color_dists = sorted(color_dists, key=lambda e: e[1]) @@ -226,8 +283,8 @@ def color_dist(color_code1: str, color_code2: str) -> float: :param color_code2: string representing a color hexcode :return: float representing Euclidean distance between colors """ - color_tuple_1 = ColorInputManager.hex_to_rgb(color_code1) - color_tuple_2 = ColorInputManager.hex_to_rgb(color_code2) + color_tuple_1 = ColorInputComponent.hex_to_rgb(color_code1) + color_tuple_2 = ColorInputComponent.hex_to_rgb(color_code2) red_delta = color_tuple_1["red"] - color_tuple_2["red"] green_delta = color_tuple_1["green"] - color_tuple_2["green"] From d41bd269744e5a43af6048d1e4a73484e5ad52ec Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 19:53:18 -0700 Subject: [PATCH 159/196] Add color input to config --- src/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index 4e5fd694..85b22966 100644 --- a/src/config.py +++ b/src/config.py @@ -1,6 +1,7 @@ from typing import TypedDict from audio_style_input import AudioEditorComponent +from color_mixer_input import ColorInputComponent from input_method_proto import IInputMethod from rpg_text_input import Keyboard @@ -35,7 +36,7 @@ class InputMethodSpec(TypedDict): "name": "Color Picker", "path": "color-picker", "icon": "", - "component": None, + "component": ColorInputComponent, }, { "name": "Circle Selector", From 42d88a643b3b535f2aa08c70f45863608b3bc5ca Mon Sep 17 00:00:00 2001 From: afx8732 Date: Sun, 17 Aug 2025 22:01:48 -0500 Subject: [PATCH 160/196] Use emoji for player sprite --- src/platformer_input/platformer_scene_cmp.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 06ad82ea..449c45c0 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -66,9 +66,10 @@ def initial_draw(self) -> None: self.px_player_offset_lx = ((c.SCENE_WIDTH - 1) * c.TILE_SIZE) / 2 self.px_player_offset_ty = (c.SCENE_HEIGHT * c.TILE_SIZE) - 2 * c.TILE_SIZE with self.mask_element: - ui.element("div").style( + ui.label("\U0001f7e6").style( f"position:absolute;top:{self.px_player_offset_ty}px;left:{self.px_player_offset_lx}px;" - f"background-color:{c.COLOR_PLAYER};width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px" + f"width:{c.TILE_SIZE}px;height:{c.TILE_SIZE}px;font-size:{c.TILE_SIZE}px;" + "transform: translate(-8px, -6px);" ) self.letter_el_map = {} From 6f16e637549be5df6c49cfd8ad5ccb9441659a9b Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 20:54:46 -0700 Subject: [PATCH 161/196] Create base layout --- src/color_mixer_input/__init__.py | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index c3ed1581..81e642d0 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -3,7 +3,8 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments -from src.input_method_proto import IInputMethod, TextUpdateCallback + +from input_method_proto import IInputMethod, TextUpdateCallback class ColorInputComponent(IInputMethod): @@ -153,28 +154,29 @@ def create_ui_content(self) -> tuple[ui.row, ui.label, ui.label, ui.row, ui.row] tuple: (row for color picker, label for color selected, row for commands, row for special characters) """ - # Couldn't figure out how to position the palette so just stuck it in the page center. - # It is on top of the wpm timer currently - - with ui.column().classes("absolute-center"): - color_picker_row = ui.row() + with ui.element("div").classes("flex flex-col items-center justify-center w-full h-full"): + with ui.element("div").classes("flex flex-row justify-center w-1/2 h-full"): + color_picker_row = ui.row() + with ui.element("div").classes("w-1/2 h-full flex flex-col items-center justify-center gap-4"): + with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): + color_label = ui.label("Current Color: None") + input_label = ui.label("Current Input:") + with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): + command_buttons_row = ui.row().style("gap: 10px") + special_char_buttons_row = ui.row().style("gap: 10px") - color_label = ui.label("Current Color: None") - input_label = ui.label("Current Input:") - command_buttons_row = ui.row().style("gap: 10px") - special_char_buttons_row = ui.row().style("gap: 10x") return color_picker_row, color_label, input_label, command_buttons_row, special_char_buttons_row def setup_ui_buttons(self) -> None: """Create the buttons and other dynamic elements (e.g. labels, switches) on page.""" - with self.color_picker_row, ui.button(icon="colorize").style("opacity:0;pointer-events:none"): - ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") + with self.color_picker_row, ui.button(icon="colorize").style("opacity:0; pointer-events:none"): + ui.color_picker(on_pick=self.color_handler, value=True).props("persistent").classes("w-[300px] h-auto") - with self.command_buttons_row, ui.button_group().classes("gap-10"): - ui.switch("CAPS LOCK", on_change=self.shift_handler).classes("bg-blue-500 text-white") + with self.command_buttons_row, ui.button_group().classes("gap-1"): + ui.switch("CAPS LOCK", on_change=self.shift_handler).classes("bg-blue-500 text-white pr-[10px]") ui.button("Confirm Letter", on_click=self.confirm_letter_handler).classes("bg-blue-500 text-white") - with self.special_char_buttons_row, ui.button_group().classes("gap-10"): + with self.special_char_buttons_row, ui.button_group().classes("gap-1"): # creating wrappers to pass callback functions with parameters to buttons below callback_with_period = partial(self.special_character_handler, ".") callback_with_exclamation = partial(self.special_character_handler, "!") From cc382f183f6798befcf1f246c2ddc9d508a63f85 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Sun, 17 Aug 2025 21:07:40 -0700 Subject: [PATCH 162/196] Add config color style support --- src/color_mixer_input/__init__.py | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index 81e642d0..c6a3b6ef 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -4,6 +4,7 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments +import config from input_method_proto import IInputMethod, TextUpdateCallback @@ -147,7 +148,7 @@ def update_confirmation_text(self) -> None: if self.text_callback: self.text_callback(self.typed_text) - def create_ui_content(self) -> tuple[ui.row, ui.label, ui.label, ui.row, ui.row]: + def create_ui_content(self) -> tuple[ui.row, ui.chip, ui.chip, ui.row, ui.row]: """Create the frame to hold the color picker, text labels, and buttons. Returns: @@ -157,15 +158,23 @@ def create_ui_content(self) -> tuple[ui.row, ui.label, ui.label, ui.row, ui.row] with ui.element("div").classes("flex flex-col items-center justify-center w-full h-full"): with ui.element("div").classes("flex flex-row justify-center w-1/2 h-full"): color_picker_row = ui.row() - with ui.element("div").classes("w-1/2 h-full flex flex-col items-center justify-center gap-4"): - with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): - color_label = ui.label("Current Color: None") - input_label = ui.label("Current Input:") + with ui.element("div").classes("w-1/2 h-full flex flex-col items-center justify-center gap-16"): + with ui.element("div").classes("flex flex-col items-center justify-center gap-2"): + color_chip = ui.chip( + "Current Color: None", + color=config.COLOR_STYLE["contrast"], + text_color=config.COLOR_STYLE["primary_bg"], + ) + input_chip = ui.chip( + "Current Input: ", + color=config.COLOR_STYLE["contrast"], + text_color=config.COLOR_STYLE["primary_bg"], + ) with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): command_buttons_row = ui.row().style("gap: 10px") special_char_buttons_row = ui.row().style("gap: 10px") - return color_picker_row, color_label, input_label, command_buttons_row, special_char_buttons_row + return color_picker_row, color_chip, input_chip, command_buttons_row, special_char_buttons_row def setup_ui_buttons(self) -> None: """Create the buttons and other dynamic elements (e.g. labels, switches) on page.""" @@ -173,8 +182,12 @@ def setup_ui_buttons(self) -> None: ui.color_picker(on_pick=self.color_handler, value=True).props("persistent").classes("w-[300px] h-auto") with self.command_buttons_row, ui.button_group().classes("gap-1"): - ui.switch("CAPS LOCK", on_change=self.shift_handler).classes("bg-blue-500 text-white pr-[10px]") - ui.button("Confirm Letter", on_click=self.confirm_letter_handler).classes("bg-blue-500 text-white") + ui.switch("CAPS LOCK", on_change=self.shift_handler).classes( + f"bg-[{config.COLOR_STYLE['secondary']}] text-white pr-[10px]" + ) + ui.button( + "Confirm Letter", on_click=self.confirm_letter_handler, color=config.COLOR_STYLE["secondary"] + ).classes("bg-blue-500 text-white") with self.special_char_buttons_row, ui.button_group().classes("gap-1"): # creating wrappers to pass callback functions with parameters to buttons below @@ -183,10 +196,14 @@ def setup_ui_buttons(self) -> None: callback_with_comma = partial(self.special_character_handler, ",") callback_with_question_mark = partial(self.special_character_handler, "?") - ui.button(".", on_click=callback_with_period).classes("bg-blue-500 text-white") - ui.button("!", on_click=callback_with_exclamation).classes("bg-blue-500 text-white") - ui.button(",", on_click=callback_with_comma).classes("bg-blue-500 text-white") - ui.button("?", on_click=callback_with_question_mark).classes("bg-blue-500 text-white") + ui.button(".", on_click=callback_with_period, color=config.COLOR_STYLE["secondary"]).classes("text-white") + ui.button("!", on_click=callback_with_exclamation, color=config.COLOR_STYLE["secondary"]).classes( + "text-white" + ) + ui.button(",", on_click=callback_with_comma, color=config.COLOR_STYLE["secondary"]).classes("text-white") + ui.button("?", on_click=callback_with_question_mark, color=config.COLOR_STYLE["secondary"]).classes( + "text-white" + ) @ui.page("/color_input") def color_input_page(self) -> None: From c6fc920f6355763305af29bf53c567150ffea61b Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Sun, 17 Aug 2025 22:30:26 -0700 Subject: [PATCH 163/196] centered scene and resized --- src/platformer_input/platformer_constants.py | 6 +++--- src/platformer_input/platformer_scene_cmp.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 8536a4d0..8ee0420f 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -1,11 +1,11 @@ """Constants for the platformer input method rendering & simulator.""" """Size in pixels of each "tile".""" -TILE_SIZE = 30 +TILE_SIZE = 35 """Width in tiles of the whole scene.""" -SCENE_WIDTH = 16 +SCENE_WIDTH = 32 """Height in tiles of the whole scene.""" -SCENE_HEIGHT = 9 +SCENE_HEIGHT = 12 JUMP_FORCE = 14 """Max player speed.""" diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 449c45c0..db6ffb17 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -38,7 +38,7 @@ def __init__(self, position: tuple[int, int]) -> None: 100% { margin-top: 0; margin-bottom: 0; } } """) - self.classes("platformer-input-method-element") + self.classes("platformer-input-method-element flex items-center justify-center") with self: self.mask_element = ui.element("div") self.mask_element.style( From 90e2ff58c54c8cb039c05effe5e30d5835aaf7ad Mon Sep 17 00:00:00 2001 From: jks85 Date: Sun, 17 Aug 2025 22:39:33 -0700 Subject: [PATCH 164/196] Refactor color input method to interface with WPM tester page --- src/color_mixer_input/__init__.py | 44 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index c6a3b6ef..dd8b98d5 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -3,9 +3,16 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments +from src.input_method_proto import IInputMethod, TextUpdateCallback -import config -from input_method_proto import IInputMethod, TextUpdateCallback +# COLORS +COLOR_STYLE: dict[str, str] = { + "primary": "#12E7B2", + "secondary": "#7D53DE", + "primary_bg": "#111111", + "secondary_bg": "#1B1B1B", + "contrast": "#F9F9F9", +} class ColorInputComponent(IInputMethod): @@ -16,8 +23,6 @@ class ColorInputComponent(IInputMethod): """ def __init__(self) -> None: - # typed_char is input displayed to user based on selected color - # confirmed_char is character typed after user confirmation self.text_callback: TextUpdateCallback | None = None self.typed_text = "" self.typed_char = None @@ -103,7 +108,6 @@ def color_handler(self, element: ColorPickEventArguments) -> None: Letters must be confirmed by the user before being output to the WPM page. Special characters are automatically output to the WPM page. """ - print(type(element)) selected_color_hex = element.color self.selected_color = self.find_closest_member(selected_color_hex) @@ -162,13 +166,13 @@ def create_ui_content(self) -> tuple[ui.row, ui.chip, ui.chip, ui.row, ui.row]: with ui.element("div").classes("flex flex-col items-center justify-center gap-2"): color_chip = ui.chip( "Current Color: None", - color=config.COLOR_STYLE["contrast"], - text_color=config.COLOR_STYLE["primary_bg"], + color=COLOR_STYLE["contrast"], + text_color=COLOR_STYLE["primary_bg"], ) input_chip = ui.chip( "Current Input: ", - color=config.COLOR_STYLE["contrast"], - text_color=config.COLOR_STYLE["primary_bg"], + color=COLOR_STYLE["contrast"], + text_color=COLOR_STYLE["primary_bg"], ) with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): command_buttons_row = ui.row().style("gap: 10px") @@ -183,11 +187,11 @@ def setup_ui_buttons(self) -> None: with self.command_buttons_row, ui.button_group().classes("gap-1"): ui.switch("CAPS LOCK", on_change=self.shift_handler).classes( - f"bg-[{config.COLOR_STYLE['secondary']}] text-white pr-[10px]" + f"bg-[{COLOR_STYLE['secondary']}] text-white pr-[10px]" + ) + ui.button("Confirm Letter", on_click=self.confirm_letter_handler, color=COLOR_STYLE["secondary"]).classes( + "bg-blue-500 text-white" ) - ui.button( - "Confirm Letter", on_click=self.confirm_letter_handler, color=config.COLOR_STYLE["secondary"] - ).classes("bg-blue-500 text-white") with self.special_char_buttons_row, ui.button_group().classes("gap-1"): # creating wrappers to pass callback functions with parameters to buttons below @@ -196,14 +200,10 @@ def setup_ui_buttons(self) -> None: callback_with_comma = partial(self.special_character_handler, ",") callback_with_question_mark = partial(self.special_character_handler, "?") - ui.button(".", on_click=callback_with_period, color=config.COLOR_STYLE["secondary"]).classes("text-white") - ui.button("!", on_click=callback_with_exclamation, color=config.COLOR_STYLE["secondary"]).classes( - "text-white" - ) - ui.button(",", on_click=callback_with_comma, color=config.COLOR_STYLE["secondary"]).classes("text-white") - ui.button("?", on_click=callback_with_question_mark, color=config.COLOR_STYLE["secondary"]).classes( - "text-white" - ) + ui.button(".", on_click=callback_with_period, color=COLOR_STYLE["secondary"]).classes("text-white") + ui.button("!", on_click=callback_with_exclamation, color=COLOR_STYLE["secondary"]).classes("text-white") + ui.button(",", on_click=callback_with_comma, color=COLOR_STYLE["secondary"]).classes("text-white") + ui.button("?", on_click=callback_with_question_mark, color=COLOR_STYLE["secondary"]).classes("text-white") @ui.page("/color_input") def color_input_page(self) -> None: @@ -242,8 +242,6 @@ def color_input_page(self) -> None: # ui labels displaying selected color, last input character, and text typed by user self.color_label.text = f"Color Selected: {self.selected_color}" self.input_label.text = f"Character Selected: {self.typed_char}" - self.confirm_label.text = f"Character Typed: {self.confirmed_char}" - self.text_label.text = f"Text Typed: {self.typed_text}" with ui.row(), ui.button(icon="colorize").style("opacity:0;pointer-events:none"): ui.color_picker(on_pick=self.color_handler, value=True).props("persistent") From 01a883e3d3feb87f87d3f777c0c40ea928f3369b Mon Sep 17 00:00:00 2001 From: jks85 Date: Sun, 17 Aug 2025 23:59:53 -0700 Subject: [PATCH 165/196] Reverting imports from config and input_method_proto for main repo. I'll modify my own local copy as needed for testing --- src/color_mixer_input/__init__.py | 39 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index dd8b98d5..92efc508 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -3,16 +3,9 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments -from src.input_method_proto import IInputMethod, TextUpdateCallback -# COLORS -COLOR_STYLE: dict[str, str] = { - "primary": "#12E7B2", - "secondary": "#7D53DE", - "primary_bg": "#111111", - "secondary_bg": "#1B1B1B", - "contrast": "#F9F9F9", -} +import config +from input_method_proto import IInputMethod, TextUpdateCallback class ColorInputComponent(IInputMethod): @@ -166,13 +159,13 @@ def create_ui_content(self) -> tuple[ui.row, ui.chip, ui.chip, ui.row, ui.row]: with ui.element("div").classes("flex flex-col items-center justify-center gap-2"): color_chip = ui.chip( "Current Color: None", - color=COLOR_STYLE["contrast"], - text_color=COLOR_STYLE["primary_bg"], + color=config.COLOR_STYLE["contrast"], + text_color=config.COLOR_STYLE["primary_bg"], ) input_chip = ui.chip( "Current Input: ", - color=COLOR_STYLE["contrast"], - text_color=COLOR_STYLE["primary_bg"], + color=config.COLOR_STYLE["contrast"], + text_color=config.COLOR_STYLE["primary_bg"], ) with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): command_buttons_row = ui.row().style("gap: 10px") @@ -187,11 +180,11 @@ def setup_ui_buttons(self) -> None: with self.command_buttons_row, ui.button_group().classes("gap-1"): ui.switch("CAPS LOCK", on_change=self.shift_handler).classes( - f"bg-[{COLOR_STYLE['secondary']}] text-white pr-[10px]" - ) - ui.button("Confirm Letter", on_click=self.confirm_letter_handler, color=COLOR_STYLE["secondary"]).classes( - "bg-blue-500 text-white" + f"bg-[{config.COLOR_STYLE['secondary']}] text-white pr-[10px]" ) + ui.button( + "Confirm Letter", on_click=self.confirm_letter_handler, color=config.COLOR_STYLE["secondary"] + ).classes("bg-blue-500 text-white") with self.special_char_buttons_row, ui.button_group().classes("gap-1"): # creating wrappers to pass callback functions with parameters to buttons below @@ -200,10 +193,14 @@ def setup_ui_buttons(self) -> None: callback_with_comma = partial(self.special_character_handler, ",") callback_with_question_mark = partial(self.special_character_handler, "?") - ui.button(".", on_click=callback_with_period, color=COLOR_STYLE["secondary"]).classes("text-white") - ui.button("!", on_click=callback_with_exclamation, color=COLOR_STYLE["secondary"]).classes("text-white") - ui.button(",", on_click=callback_with_comma, color=COLOR_STYLE["secondary"]).classes("text-white") - ui.button("?", on_click=callback_with_question_mark, color=COLOR_STYLE["secondary"]).classes("text-white") + ui.button(".", on_click=callback_with_period, color=config.COLOR_STYLE["secondary"]).classes("text-white") + ui.button("!", on_click=callback_with_exclamation, color=config.COLOR_STYLE["secondary"]).classes( + "text-white" + ) + ui.button(",", on_click=callback_with_comma, color=config.COLOR_STYLE["secondary"]).classes("text-white") + ui.button("?", on_click=callback_with_question_mark, color=config.COLOR_STYLE["secondary"]).classes( + "text-white" + ) @ui.page("/color_input") def color_input_page(self) -> None: From 6591ec3fa1f855d60a20d9f920fe1f4e991eaf19 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:00:55 -0700 Subject: [PATCH 166/196] Remove duplicate line in wpm_tester.py --- src/wpm_tester.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index e203eeb2..9b6f378b 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -143,7 +143,6 @@ def create_sentence() -> str: """Create sentence to use in challenge.""" punctuation = [".", "!"] sentence = fake.sentence(nb_words=6, variable_nb_words=False) - sentence[:-1] + secrets.choice(punctuation) return sentence[:-1] + secrets.choice(punctuation) From 73d439c6793f11d886fa30624e623ee502b7e09d Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:12:15 -0700 Subject: [PATCH 167/196] Make wpm_tester.py check that inputed text is equal to target text for timer stop --- src/wpm_tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 9b6f378b..b276fc22 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -127,7 +127,7 @@ def on_text_update(txt: str) -> None: iv.set_text(txt) state.text = txt - if len(txt) == len(text_to_use): + if txt == text_to_use: elapsed = time.time() - timer.start if timer.start else 0 if elapsed > 0: wpm = (len(txt) / 5) / (elapsed / 60) From ebaa981ae8f40767e23118a34ff27b298793539d Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 18 Aug 2025 00:15:29 -0700 Subject: [PATCH 168/196] rounded corners and margin --- src/platformer_input/platformer_scene_cmp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index db6ffb17..32b2d8d7 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -40,7 +40,7 @@ def __init__(self, position: tuple[int, int]) -> None: """) self.classes("platformer-input-method-element flex items-center justify-center") with self: - self.mask_element = ui.element("div") + self.mask_element = ui.element("div").classes("rounded-3xl m-4") self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" f"background-color: black; position: relative; overflow: hidden" From 198ef8f4335b6ef1da5716b61d6b3d19556695d7 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:16:25 -0700 Subject: [PATCH 169/196] Clean up comments in main.py --- src/main.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main.py b/src/main.py index 018c450f..212f6752 100644 --- a/src/main.py +++ b/src/main.py @@ -3,13 +3,7 @@ from homepage import home from wpm_tester import wpm_tester_page -###from rpg_text_input import rpg_text_input_page so it doesnt think it's code - - ui.page("/")(home) ui.page("/test/{method}")(wpm_tester_page) -# http://localhost:8080/test/audio_input - - ui.run() From 9c1801ca8f61bbacd7cb8aa93f5816533ec8baad Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 18 Aug 2025 00:25:24 -0700 Subject: [PATCH 170/196] added border --- src/platformer_input/platformer_scene_cmp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 32b2d8d7..d75ea719 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -1,5 +1,6 @@ from nicegui import ui +import config import platformer_input.platformer_constants as c EMOJIS = {"sky": "\U0001f600", "ground": "\U0001f61e", "letter": "\U0001f636"} @@ -40,7 +41,9 @@ def __init__(self, position: tuple[int, int]) -> None: """) self.classes("platformer-input-method-element flex items-center justify-center") with self: - self.mask_element = ui.element("div").classes("rounded-3xl m-4") + self.mask_element = ui.element("div").classes( + f"rounded-3xl m-4 border border-[{config.COLOR_STYLE['primary']}]" + ) self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" f"background-color: black; position: relative; overflow: hidden" From c66384af0bc98525743f11ecd546a8700eb1bb7a Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:27:03 -0700 Subject: [PATCH 171/196] Make wpm_tester.py header project name into a link --- src/wpm_tester.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 9b6f378b..b6be0fcf 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -48,8 +48,8 @@ def create_header() -> None: with ui.card().props("flat"): # small logo placeholder pass ( - ui.label(PROJECT_NAME.upper()) - .style(f"color: {COLOR_STYLE['primary']}; font-family: Arial, sans-serif;") + ui.link(PROJECT_NAME.upper(), "/") + .style(f"color: {COLOR_STYLE['primary']}; font-family: Arial, sans-serif; text-decoration: none") .classes("text-4xl font-bold") ) ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") From ff933b431b96688cf5d2287f148535c4b91dd6a6 Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 18 Aug 2025 00:31:34 -0700 Subject: [PATCH 172/196] color border and size --- src/platformer_input/platformer_scene_cmp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index d75ea719..ea17f6dd 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -42,7 +42,7 @@ def __init__(self, position: tuple[int, int]) -> None: self.classes("platformer-input-method-element flex items-center justify-center") with self: self.mask_element = ui.element("div").classes( - f"rounded-3xl m-4 border border-[{config.COLOR_STYLE['primary']}]" + f"rounded-3xl m-4 border-4 border-[{config.COLOR_STYLE['primary']}] " ) self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" @@ -57,7 +57,7 @@ def __init__(self, position: tuple[int, int]) -> None: def initial_draw(self) -> None: """Draw the map for the first time.""" with self.mask_element: - self.map_container = ui.element("div") + self.map_container = ui.element("div").classes("shadow-cyan-500/50 ") self.map_container.style( f"position:absolute;width:{len(self.world[0]) * c.TILE_SIZE}px;" f"height:{len(self.world) * c.TILE_SIZE}px;" From caf87a9753d3256ded162237dc88015ad55a84cc Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 18 Aug 2025 00:33:28 -0700 Subject: [PATCH 173/196] double border and remove child border --- src/platformer_input/platformer_scene_cmp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index ea17f6dd..493c87a7 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -42,7 +42,7 @@ def __init__(self, position: tuple[int, int]) -> None: self.classes("platformer-input-method-element flex items-center justify-center") with self: self.mask_element = ui.element("div").classes( - f"rounded-3xl m-4 border-4 border-[{config.COLOR_STYLE['primary']}] " + f"rounded-3xl m-4 border-4 border-[{config.COLOR_STYLE['primary']}] border-double " ) self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" @@ -57,7 +57,7 @@ def __init__(self, position: tuple[int, int]) -> None: def initial_draw(self) -> None: """Draw the map for the first time.""" with self.mask_element: - self.map_container = ui.element("div").classes("shadow-cyan-500/50 ") + self.map_container = ui.element("div") self.map_container.style( f"position:absolute;width:{len(self.world[0]) * c.TILE_SIZE}px;" f"height:{len(self.world) * c.TILE_SIZE}px;" From d8ba8244111cc55c581b52fa0d85d9cb7f6c6719 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 00:35:02 -0700 Subject: [PATCH 174/196] Add keyboard styling --- src/rpg_text_input/__init__.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 4cab7aa2..646c88d7 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -4,6 +4,7 @@ from nicegui import ui from nicegui.events import KeyEventArguments +import config from input_method_proto import IInputMethod, TextUpdateCallback @@ -72,12 +73,29 @@ def __init__(self) -> None: @ui.refreshable_method def render(self) -> None: """Render the keyboard to the page.""" - with ui.grid(columns=len(KEYBOARD_KEYS[0])): + with ( + ui.element("div").classes("w-full h-full flex justify-center items-center"), # centering div + ui.element("div").classes( + f"w-[85%] h-[60%] bg-[{config.COLOR_STYLE['primary_bg']}] p-5 rounded-xl" + ), # keyboard outer + ui.grid(columns=len(KEYBOARD_KEYS[0])).classes("h-full w-full"), # key grid + ): for row_index, row in enumerate(KEYBOARD_KEYS): for col_index, char in enumerate(row): - label = ui.label(char).style("text-align: center") - if (col_index, row_index) == (self.position.x, self.position.y): - label.style("background-color: lightblue") + with ui.element("div").classes( # keys + f"w-full h-full flex justify-center items-center border-2 " + f"{ + 'bg-[' + config.COLOR_STYLE['primary'] + ']' + if (col_index, row_index) == (self.position.x, self.position.y) + else 'bg-[' + config.COLOR_STYLE['secondary_bg'] + ']' + } " + f"border-[{config.COLOR_STYLE['secondary_bg']}] rounded-md" + ): + ( + ui.label(char) + .style("font-size: clamp(1rem, 2vh, 2rem)") + .classes(f"text-center font-bold text-[{config.COLOR_STYLE['contrast']}] p-2") + ) def move(self, x: int, y: int) -> None: """Move the keyboard selected character in the given directions.""" From 9654914ab2c5c8354009d1f4b7c4159e6417ef0a Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:48:01 -0700 Subject: [PATCH 175/196] Unstack line in audio_style_input __init__.py --- src/audio_style_input/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index b5b33be0..f37baffe 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -166,7 +166,8 @@ def play_pause_handler(self) -> None: self.main_track.play() self.on_play() else: - self.main_track.pause(), self.on_pause() + self.main_track.pause() + self.on_pause() def on_play(self) -> None: """Start letter spinner and spinning.""" From c0f52f94058c1217350f095256480f51d17517b6 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 02:48:05 -0500 Subject: [PATCH 176/196] Fade background emojis in platformer --- src/platformer_input/platformer_scene_cmp.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 493c87a7..ec9b1377 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -21,13 +21,16 @@ def __init__(self, position: tuple[int, int]) -> None: margin-left: -7px; border-radius: 5px; } +.platformer-input-method-element .tile.fade { + opacity: 60%; +} .platformer-input-method-element .tile div { transform: translateY(-42px); text-align: center; color: white; font-weight: bold; font-size: 1.5rem; - background-color: #0008; + background-color: #0002; border-radius: 100%; } .platformer-input-method-element .tile-bounce { @@ -82,7 +85,10 @@ def initial_draw(self) -> None: for cell in row: if cell in "# ": emoji = EMOJIS["ground"] if cell == "#" else EMOJIS["sky"] - ui.label(emoji).classes("tile") + classes = "tile" + if cell == " ": + classes += " fade" + ui.label(emoji).classes(classes) else: with ui.label(EMOJIS["letter"]).classes("tile"): lb = ui.label(cell.replace("<", "\u232b")) From a7402f9c1fb6d680f8c600423f8f26e9b368ab0a Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 02:51:13 -0500 Subject: [PATCH 177/196] fix stupid type error --- src/platformer_input/platformer_constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platformer_input/platformer_constants.py b/src/platformer_input/platformer_constants.py index 8ee0420f..827aac14 100644 --- a/src/platformer_input/platformer_constants.py +++ b/src/platformer_input/platformer_constants.py @@ -45,7 +45,7 @@ def world_grid() -> list[list[str]]: lines = SCENE.splitlines() max_length = max(len(ln) for ln in lines) - grid = [] + grid: list[list[str]] = [] for line in lines: lst = list(line) if len(line) < max_length: From 377f0a21b206c3ca8ee3c1bfac46717d80e23d9b Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 02:52:58 -0500 Subject: [PATCH 178/196] replace some physics comments with simulation --- src/platformer_input/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index ee644a4b..9b8a849b 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -28,8 +28,8 @@ def __init__(self) -> None: self.callbacks = [] self.input_value = "" self.renderer = PlatformerRendererComponent(INITIAL_POS) - self.physics = PlatformerPhysicsSimulation(INITIAL_POS) - self.physics.on_letter(self._hphysics_letter_press) + self.simulation = PlatformerPhysicsSimulation(INITIAL_POS) + self.simulation.on_letter(self._on_simulation_letter) self.held_keys = set() ui.keyboard(lambda e: self.keyboard_handler(e)) ui.timer(1 / FPS, lambda: self._hinterv()) @@ -44,10 +44,10 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: self.held_keys.add(evk) elif event.action.keyup and evk in self.held_keys: self.held_keys.remove(evk) - self.physics.set_held_keys(self.held_keys) + self.simulation.set_held_keys(self.held_keys) - def _hphysics_letter_press(self, letter: str) -> None: - """Call when the physics engine registers a letter press.""" + def _on_simulation_letter(self, letter: str) -> None: + """Call when the simulation registers a letter press.""" self.renderer.play_bounce_effect(letter) if letter == "<": if len(self.input_value) > 0: @@ -63,8 +63,8 @@ def _run_callbacks(self) -> None: def _hinterv(self) -> None: """Run every game tick.""" - self.physics.tick() - self.renderer.move_player(self.physics.player_x, self.physics.player_y) + self.simulation.tick() + self.renderer.move_player(self.simulation.player_x, self.simulation.player_y) def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" From e19961fa8c46709626a913eca70b5cfadf276188 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 02:54:52 -0500 Subject: [PATCH 179/196] replace letter handler property with callback list --- src/platformer_input/platformer_simulation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 0f074e99..301ad54d 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -35,7 +35,7 @@ class PlatformerPhysicsSimulation: _keys: set[str] _world: list[list[str]] - _letter_handler: LetterHandler | None + _letter_handlers: list[LetterHandler] def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial @@ -47,7 +47,7 @@ def __init__(self, initial: tuple[int, int]) -> None: self._keys = set() self._world = constants.world_grid() - self._letter_handler = None + self._letter_handlers = [] def set_held_keys(self, keys: set[str]) -> None: """Set the current player-held keys.""" @@ -75,7 +75,7 @@ def tick(self) -> None: def on_letter(self, handler: LetterHandler) -> None: """Register callback function for a letter being bumped.""" - self._letter_handler = handler + self._letter_handlers.append(handler) def _apply_x_velocity(self) -> None: """Apply horizontal velocity and decay.""" @@ -111,8 +111,8 @@ def _apply_y_velocity(self) -> None: else: tile_edge = int(self.player_y) new_y = tile_edge + EPSILON - if collision_result != "#" and self._letter_handler: - self._letter_handler(collision_result) + if collision_result != "#": + [x(collision_result) for x in self._letter_handlers] self.player_y = new_y def _collides(self, player: tuple[float, float]) -> bool: From e3341add1bcdc8e8cf70cb6634e026e1b3e09124 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:58:38 -0700 Subject: [PATCH 180/196] Switch comma for exclamation mark in rpg_text_input --- src/rpg_text_input/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 646c88d7..4cd9f563 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -48,7 +48,7 @@ def wrapping_add(self, x: int, y: int) -> "WrappingPosition": "ABCDEFGabcdefg", "HIJKLMNhijklmn", "OPQRSTUopqrstu", - "VWXYZ. vwxyz,\N{SYMBOL FOR BACKSPACE}", + "VWXYZ. vwxyz!\N{SYMBOL FOR BACKSPACE}", ) From 4a52ea90348126d043c60094a7cab0f494d61492 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:03:39 -0700 Subject: [PATCH 181/196] Make rpg_style_input keys more readable --- src/rpg_text_input/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 4cd9f563..a7c8ce8e 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -93,8 +93,8 @@ def render(self) -> None: ): ( ui.label(char) - .style("font-size: clamp(1rem, 2vh, 2rem)") - .classes(f"text-center font-bold text-[{config.COLOR_STYLE['contrast']}] p-2") + .style("font-size: clamp(1rem, 3vh, 3rem)") + .classes(f"text-center text-[{config.COLOR_STYLE['contrast']}] p-2") ) def move(self, x: int, y: int) -> None: From cc61f023c854dd8fb46fc7e203257d3bc36431c9 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 03:03:48 -0500 Subject: [PATCH 182/196] add exclusions to ruff ignore --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c177578b..7636100b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,4 +69,8 @@ ignore = [ "FIX", # Conflicts with formatter. "COM812", + # Boolean positional arguments + "FBT001", + "FBT002", + "FBT003", ] From 937d560be95c2363eb942589e0c34bafc0c20f0e Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 03:13:32 -0500 Subject: [PATCH 183/196] capitalization on jump --- src/platformer_input/__init__.py | 2 +- src/platformer_input/platformer_scene_cmp.py | 12 ++++++++---- src/platformer_input/platformer_simulation.py | 12 +++++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 9b8a849b..f568200e 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -64,7 +64,7 @@ def _run_callbacks(self) -> None: def _hinterv(self) -> None: """Run every game tick.""" self.simulation.tick() - self.renderer.move_player(self.simulation.player_x, self.simulation.player_y) + self.renderer.rerender(self.simulation.player_x, self.simulation.player_y, self.simulation.capitalized) def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index 493c87a7..37a7cf86 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -21,6 +21,9 @@ def __init__(self, position: tuple[int, int]) -> None: margin-left: -7px; border-radius: 5px; } +.platformer-input-method-element.capitalization .tile div { + text-transform: uppercase; +} .platformer-input-method-element .tile div { transform: translateY(-42px); text-align: center; @@ -51,10 +54,10 @@ def __init__(self, position: tuple[int, int]) -> None: self.world = c.world_grid() self.world_height = len(self.world) - self.initial_draw() - self.move_player(*position) + self._initial_draw() + self.rerender(*position, True) - def initial_draw(self) -> None: + def _initial_draw(self) -> None: """Draw the map for the first time.""" with self.mask_element: self.map_container = ui.element("div") @@ -88,12 +91,13 @@ def initial_draw(self) -> None: lb = ui.label(cell.replace("<", "\u232b")) self.letter_el_map[cell] = lb - def move_player(self, player_x: float, player_y: float) -> None: + def rerender(self, player_x: float, player_y: float, capitalization_state: bool) -> None: """Move the player in the renderer.""" px_left = self.px_player_offset_lx - player_x * c.TILE_SIZE px_top = self.px_player_offset_ty - player_y * c.TILE_SIZE self.map_container.style(f"transform: translate({px_left}px, {px_top}px)") + self.classes("capitalization") if capitalization_state else self.classes(remove="capitalization") def play_bounce_effect(self, letter: str) -> None: """Play a short bounce effect on a letter tile.""" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 301ad54d..0cf41d32 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -26,6 +26,7 @@ class PlatformerPhysicsSimulation: player_x: float player_y: float + capitalized: bool _xvel: float _yvel: float @@ -39,6 +40,7 @@ class PlatformerPhysicsSimulation: def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial + self.capitalized = False self._xvel = 0 self._yvel = 0 @@ -69,6 +71,7 @@ def tick(self) -> None: self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) if "ArrowUp" in self._keys and self._collides((self.player_x, self.player_y + 2 * EPSILON)): self._yvel = -constants.JUMP_FORCE + self.capitalized = not self.capitalized self._apply_x_velocity() self._apply_y_velocity() @@ -112,7 +115,14 @@ def _apply_y_velocity(self) -> None: tile_edge = int(self.player_y) new_y = tile_edge + EPSILON if collision_result != "#": - [x(collision_result) for x in self._letter_handlers] + [ + x( + collision_result.capitalize() + if self.capitalized and collision_result.isalpha() + else collision_result + ) + for x in self._letter_handlers + ] self.player_y = new_y def _collides(self, player: tuple[float, float]) -> bool: From 37de23e78f6dba0828670da044b49ad1e75f80f4 Mon Sep 17 00:00:00 2001 From: afx8732 Date: Mon, 18 Aug 2025 03:23:10 -0500 Subject: [PATCH 184/196] Invert platformer capitalization on up arrow rather than executed jump --- src/platformer_input/__init__.py | 9 ++++++++- src/platformer_input/platformer_simulation.py | 12 +----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index f568200e..545e8e25 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -23,6 +23,7 @@ class PlatformerInputMethod(input_method_proto.IInputMethod): renderer: PlatformerRendererComponent held_keys: set[str] input_value: str + capitalized: bool def __init__(self) -> None: self.callbacks = [] @@ -31,6 +32,7 @@ def __init__(self) -> None: self.simulation = PlatformerPhysicsSimulation(INITIAL_POS) self.simulation.on_letter(self._on_simulation_letter) self.held_keys = set() + self.capitalized = False ui.keyboard(lambda e: self.keyboard_handler(e)) ui.timer(1 / FPS, lambda: self._hinterv()) @@ -46,6 +48,9 @@ def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: self.held_keys.remove(evk) self.simulation.set_held_keys(self.held_keys) + if event.key.arrow_up and event.action.keydown: + self.capitalized = not self.capitalized + def _on_simulation_letter(self, letter: str) -> None: """Call when the simulation registers a letter press.""" self.renderer.play_bounce_effect(letter) @@ -53,6 +58,8 @@ def _on_simulation_letter(self, letter: str) -> None: if len(self.input_value) > 0: self.input_value = self.input_value[:-1] else: + if self.capitalized: + letter = letter.capitalize() self.input_value += letter.replace("_", " ") self._run_callbacks() @@ -64,7 +71,7 @@ def _run_callbacks(self) -> None: def _hinterv(self) -> None: """Run every game tick.""" self.simulation.tick() - self.renderer.rerender(self.simulation.player_x, self.simulation.player_y, self.simulation.capitalized) + self.renderer.rerender(self.simulation.player_x, self.simulation.player_y, self.capitalized) def on_text_update(self, callback: typing.Callable[[str], None]) -> None: """Call `callback` every time the user input changes.""" diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 0cf41d32..301ad54d 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -26,7 +26,6 @@ class PlatformerPhysicsSimulation: player_x: float player_y: float - capitalized: bool _xvel: float _yvel: float @@ -40,7 +39,6 @@ class PlatformerPhysicsSimulation: def __init__(self, initial: tuple[int, int]) -> None: self.player_x, self.player_y = initial - self.capitalized = False self._xvel = 0 self._yvel = 0 @@ -71,7 +69,6 @@ def tick(self) -> None: self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) if "ArrowUp" in self._keys and self._collides((self.player_x, self.player_y + 2 * EPSILON)): self._yvel = -constants.JUMP_FORCE - self.capitalized = not self.capitalized self._apply_x_velocity() self._apply_y_velocity() @@ -115,14 +112,7 @@ def _apply_y_velocity(self) -> None: tile_edge = int(self.player_y) new_y = tile_edge + EPSILON if collision_result != "#": - [ - x( - collision_result.capitalize() - if self.capitalized and collision_result.isalpha() - else collision_result - ) - for x in self._letter_handlers - ] + [x(collision_result) for x in self._letter_handlers] self.player_y = new_y def _collides(self, player: tuple[float, float]) -> bool: From 65dacda9818340e1e190bfd957e97259939368f8 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:24:01 -0700 Subject: [PATCH 185/196] Update keyboard docstring to remove raises --- src/rpg_text_input/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 4cd9f563..8de97b46 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -56,10 +56,6 @@ class Keyboard(IInputMethod): r"""A RPG-style keyboard where characters are selected by navigating with wasd/the arror keys. Positions are stored internally as (col, row). - - Raises: - ValueError: If input keys is non-rectangular (jagged) or starting position is outside keys. - """ def __init__(self) -> None: From 76146e75ae243cf99a943bb7d8ef50f9eeb3043f Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:27:31 -0700 Subject: [PATCH 186/196] Fix config.py platformer entry formatting --- src/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index a7ae425b..2590626f 100644 --- a/src/config.py +++ b/src/config.py @@ -45,7 +45,12 @@ class InputMethodSpec(TypedDict): "icon": "", "component": None, }, - {"name": "Platformer", "path": "platformer", "icon": "", "component": PlatformerInputMethod}, + { + "name": "Platformer", + "path": "platformer", + "icon": "", + "component": PlatformerInputMethod, + }, ] # COLORS From 9afea8ebf6bffa3b144b8a1c1e827b0b6352da7c Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 01:31:22 -0700 Subject: [PATCH 187/196] Remove color style from config --- src/config.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/config.py b/src/config.py index a7ae425b..18bb541c 100644 --- a/src/config.py +++ b/src/config.py @@ -47,12 +47,3 @@ class InputMethodSpec(TypedDict): }, {"name": "Platformer", "path": "platformer", "icon": "", "component": PlatformerInputMethod}, ] - -# COLORS -COLOR_STYLE: dict[str, str] = { - "primary": "#12E7B2", - "secondary": "#7D53DE", - "primary_bg": "#111111", - "secondary_bg": "#1B1B1B", - "contrast": "#E9E9E9", -} From e802321e1f5b577996d97e1c710bfb225b109b71 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 01:36:36 -0700 Subject: [PATCH 188/196] Create color style dataclass --- src/color_style.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/color_style.py diff --git a/src/color_style.py b/src/color_style.py new file mode 100644 index 00000000..a8815bb3 --- /dev/null +++ b/src/color_style.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class ColorStyle: + """The color theme for the website. + + Attributes: + primary (str): Primary color. + secondary (str): Secondary color. + primary_bg (str): Primary background color. + secondary_bg (str): Secondary background color. + contrast (str): Color that contrasts with the background color. + + """ + + def __init__(self) -> None: + self.primary: str = "#12E7B2" + self.secondary: str = "#7D53DE" + self.primary_bg: str = "#111111" + self.secondary_bg: str = "#1B1B1B" + self.contrast: str = "#E9E9E9" From e733c280463a1671cbc1396f1d593e6bf9e7ba74 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 01:47:57 -0700 Subject: [PATCH 189/196] Add color style class support --- src/audio_style_input/__init__.py | 13 ++++---- src/color_mixer_input/__init__.py | 32 +++++++++----------- src/platformer_input/platformer_scene_cmp.py | 6 ++-- src/rpg_text_input/__init__.py | 14 +++++---- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/audio_style_input/__init__.py b/src/audio_style_input/__init__.py index f37baffe..16bfaeb6 100644 --- a/src/audio_style_input/__init__.py +++ b/src/audio_style_input/__init__.py @@ -5,9 +5,11 @@ from nicegui import app, ui -import config +from color_style import ColorStyle from input_method_proto import IInputMethod, TextUpdateCallback +COLOR_STYLE = ColorStyle() + media = Path("./static") app.add_media_files("/media", media) @@ -55,12 +57,12 @@ def create_intro_card(self) -> tuple[ui.card, ui.button]: """ intro_card = ui.card().classes( - f"w-full h-full flex justify-center items-center bg-[{config.COLOR_STYLE['secondary_bg']}]" + f"w-full h-full flex justify-center items-center bg-[{COLOR_STYLE.secondary_bg}]" ) with intro_card, ui.card().classes("no-shadow justify-center items-center"): ui.label("WPM Battle: DJ Edition").classes("text-5xl font-bold") ui.label("Use an audio editor to test your typing skills").classes("text-xl") - start_button = ui.button("Get started!", color=config.COLOR_STYLE["primary"]) + start_button = ui.button("Get started!", color=COLOR_STYLE.primary) return intro_card, start_button def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui.row]: @@ -74,13 +76,12 @@ def create_main_content(self) -> tuple[ui.column, ui.image, ui.chip, ui.row, ui. with ( main_content, ui.card().classes( - f"gap-4 w-full h-full flex flex-col justify-center items-center " - f"bg-[{config.COLOR_STYLE['secondary_bg']}] px-16" + f"gap-4 w-full h-full flex flex-col justify-center items-center bg-[{COLOR_STYLE.secondary_bg}] px-16" ), ui.element("div").classes("flex flex-row w-full justify-between"), ): with ui.element("div").classes("flex flex-col w-1/2 h-full justify-center items-center gap-4"): - chip = ui.chip(text="Current letter: A", color=f"{config.COLOR_STYLE['contrast']}").classes( + chip = ui.chip(text="Current letter: A", color=f"{COLOR_STYLE.contrast}").classes( "relative text-2xl top-[-100px]" ) buttons_row = ui.row().style("gap: 10px") diff --git a/src/color_mixer_input/__init__.py b/src/color_mixer_input/__init__.py index 92efc508..0be23638 100644 --- a/src/color_mixer_input/__init__.py +++ b/src/color_mixer_input/__init__.py @@ -4,9 +4,11 @@ from nicegui import ui from nicegui.events import ColorPickEventArguments -import config +from color_style import ColorStyle from input_method_proto import IInputMethod, TextUpdateCallback +COLOR_STYLE = ColorStyle() + class ColorInputComponent(IInputMethod): """Implements the color-based typing input page. @@ -159,13 +161,13 @@ def create_ui_content(self) -> tuple[ui.row, ui.chip, ui.chip, ui.row, ui.row]: with ui.element("div").classes("flex flex-col items-center justify-center gap-2"): color_chip = ui.chip( "Current Color: None", - color=config.COLOR_STYLE["contrast"], - text_color=config.COLOR_STYLE["primary_bg"], + color=COLOR_STYLE.contrast, + text_color=COLOR_STYLE.primary_bg, ) input_chip = ui.chip( "Current Input: ", - color=config.COLOR_STYLE["contrast"], - text_color=config.COLOR_STYLE["primary_bg"], + color=COLOR_STYLE.contrast, + text_color=COLOR_STYLE.primary_bg, ) with ui.element("div").classes("flex flex-col items-center justify-center gap-4"): command_buttons_row = ui.row().style("gap: 10px") @@ -180,11 +182,11 @@ def setup_ui_buttons(self) -> None: with self.command_buttons_row, ui.button_group().classes("gap-1"): ui.switch("CAPS LOCK", on_change=self.shift_handler).classes( - f"bg-[{config.COLOR_STYLE['secondary']}] text-white pr-[10px]" + f"bg-[{COLOR_STYLE.secondary}] text-white pr-[10px]" + ) + ui.button("Confirm Letter", on_click=self.confirm_letter_handler, color=COLOR_STYLE.secondary).classes( + "bg-blue-500 text-white" ) - ui.button( - "Confirm Letter", on_click=self.confirm_letter_handler, color=config.COLOR_STYLE["secondary"] - ).classes("bg-blue-500 text-white") with self.special_char_buttons_row, ui.button_group().classes("gap-1"): # creating wrappers to pass callback functions with parameters to buttons below @@ -193,14 +195,10 @@ def setup_ui_buttons(self) -> None: callback_with_comma = partial(self.special_character_handler, ",") callback_with_question_mark = partial(self.special_character_handler, "?") - ui.button(".", on_click=callback_with_period, color=config.COLOR_STYLE["secondary"]).classes("text-white") - ui.button("!", on_click=callback_with_exclamation, color=config.COLOR_STYLE["secondary"]).classes( - "text-white" - ) - ui.button(",", on_click=callback_with_comma, color=config.COLOR_STYLE["secondary"]).classes("text-white") - ui.button("?", on_click=callback_with_question_mark, color=config.COLOR_STYLE["secondary"]).classes( - "text-white" - ) + ui.button(".", on_click=callback_with_period, color=COLOR_STYLE.secondary).classes("text-white") + ui.button("!", on_click=callback_with_exclamation, color=COLOR_STYLE.secondary).classes("text-white") + ui.button(",", on_click=callback_with_comma, color=COLOR_STYLE.secondary).classes("text-white") + ui.button("?", on_click=callback_with_question_mark, color=COLOR_STYLE.secondary).classes("text-white") @ui.page("/color_input") def color_input_page(self) -> None: diff --git a/src/platformer_input/platformer_scene_cmp.py b/src/platformer_input/platformer_scene_cmp.py index ec9b1377..3e56a10c 100644 --- a/src/platformer_input/platformer_scene_cmp.py +++ b/src/platformer_input/platformer_scene_cmp.py @@ -1,7 +1,9 @@ from nicegui import ui -import config import platformer_input.platformer_constants as c +from color_style import ColorStyle + +COLOR_STYLE = ColorStyle() EMOJIS = {"sky": "\U0001f600", "ground": "\U0001f61e", "letter": "\U0001f636"} @@ -45,7 +47,7 @@ def __init__(self, position: tuple[int, int]) -> None: self.classes("platformer-input-method-element flex items-center justify-center") with self: self.mask_element = ui.element("div").classes( - f"rounded-3xl m-4 border-4 border-[{config.COLOR_STYLE['primary']}] border-double " + f"rounded-3xl m-4 border-4 border-[{COLOR_STYLE.primary}] border-double " ) self.mask_element.style( f"width: {c.TILE_SIZE * c.SCENE_WIDTH}px; height: {c.TILE_SIZE * c.SCENE_HEIGHT}px;" diff --git a/src/rpg_text_input/__init__.py b/src/rpg_text_input/__init__.py index 945b04dd..69767d45 100644 --- a/src/rpg_text_input/__init__.py +++ b/src/rpg_text_input/__init__.py @@ -4,9 +4,11 @@ from nicegui import ui from nicegui.events import KeyEventArguments -import config +from color_style import ColorStyle from input_method_proto import IInputMethod, TextUpdateCallback +COLOR_STYLE = ColorStyle() + def wrap_to_range(num: int, num_min: int, num_max: int) -> int: """Ensure num is in the half-open interval [min, max), wrapping as needed. @@ -72,7 +74,7 @@ def render(self) -> None: with ( ui.element("div").classes("w-full h-full flex justify-center items-center"), # centering div ui.element("div").classes( - f"w-[85%] h-[60%] bg-[{config.COLOR_STYLE['primary_bg']}] p-5 rounded-xl" + f"w-[85%] h-[60%] bg-[{COLOR_STYLE.primary_bg}] p-5 rounded-xl" ), # keyboard outer ui.grid(columns=len(KEYBOARD_KEYS[0])).classes("h-full w-full"), # key grid ): @@ -81,16 +83,16 @@ def render(self) -> None: with ui.element("div").classes( # keys f"w-full h-full flex justify-center items-center border-2 " f"{ - 'bg-[' + config.COLOR_STYLE['primary'] + ']' + 'bg-[' + COLOR_STYLE.primary + ']' if (col_index, row_index) == (self.position.x, self.position.y) - else 'bg-[' + config.COLOR_STYLE['secondary_bg'] + ']' + else 'bg-[' + COLOR_STYLE.secondary_bg + ']' } " - f"border-[{config.COLOR_STYLE['secondary_bg']}] rounded-md" + f"border-[{COLOR_STYLE.secondary_bg}] rounded-md" ): ( ui.label(char) .style("font-size: clamp(1rem, 3vh, 3rem)") - .classes(f"text-center text-[{config.COLOR_STYLE['contrast']}] p-2") + .classes(f"text-center text-[{COLOR_STYLE.contrast}] p-2") ) def move(self, x: int, y: int) -> None: From f3d03fafa1c0b0aec90c82bca39c0a5bd15d1202 Mon Sep 17 00:00:00 2001 From: MeGaGiGaGon <107241144+MeGaGiGaGon@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:48:35 -0700 Subject: [PATCH 190/196] Remove circle imput method --- src/config.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/config.py b/src/config.py index a7ae425b..63899f5b 100644 --- a/src/config.py +++ b/src/config.py @@ -39,12 +39,6 @@ class InputMethodSpec(TypedDict): "icon": "", "component": ColorInputComponent, }, - { - "name": "Circle Selector", - "path": "circle-selector", - "icon": "", - "component": None, - }, {"name": "Platformer", "path": "platformer", "icon": "", "component": PlatformerInputMethod}, ] From 5644dce795f115152ab8371a3ca218aa71b018e2 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 01:51:08 -0700 Subject: [PATCH 191/196] Add color style class support --- src/homepage.py | 21 ++++++++++++--------- src/wpm_tester.py | 23 +++++++++++++---------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 9ac3b3a1..be8d5ad4 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -1,6 +1,9 @@ from nicegui import ui -from config import COLOR_STYLE, INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME +from color_style import ColorStyle +from config import INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME + +COLOR_STYLE = ColorStyle() def home() -> None: @@ -52,29 +55,29 @@ def home() -> None: } """) - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']}") + ui.query("body").style(f"background-color: {COLOR_STYLE.primary_bg}") with ( ui.header(fixed=False) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .style(f"background-color: {COLOR_STYLE.secondary_bg}") .classes("items-center thick-header"), ui.column(align_items="center").style("gap: 0px;"), ): - ui.label(PROJECT_NAME).style(f"color: {COLOR_STYLE['primary']}").classes("site-title") - ui.label(PROJECT_DESCRIPTION).style(f"color: {COLOR_STYLE['contrast']}").classes("site-subtitle") + ui.label(PROJECT_NAME).style(f"color: {COLOR_STYLE.primary}").classes("site-title") + ui.label(PROJECT_DESCRIPTION).style(f"color: {COLOR_STYLE.contrast}").classes("site-subtitle") with ui.element("div").classes("page-div"): - ui.label("CHOOSE YOUR INPUT METHOD").style(f"color: {COLOR_STYLE['secondary']}").classes("heading") + ui.label("CHOOSE YOUR INPUT METHOD").style(f"color: {COLOR_STYLE.secondary}").classes("heading") ui.separator().style("background-color: #313131;") with ui.element("div").classes("button-parent"): for input in INPUT_METHODS: ( ui.button( text=input["name"], - color=COLOR_STYLE["secondary_bg"], + color=COLOR_STYLE.secondary_bg, on_click=lambda _, path=f"/test/{input['path']}": ui.navigate.to(path), ) - .style(f"color: {COLOR_STYLE['contrast']}") + .style(f"color: {COLOR_STYLE.contrast}") .props("rounded") - .classes(f"input-box hover:!bg-[{COLOR_STYLE['primary']}] transition-colors duration-300") + .classes(f"input-box hover:!bg-[{COLOR_STYLE.primary}] transition-colors duration-300") ) diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 78875505..649e206c 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -7,7 +7,10 @@ import input_method_proto import input_view -from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME +from color_style import ColorStyle +from config import INPUT_METHODS, PROJECT_NAME + +COLOR_STYLE = ColorStyle() fake = Faker() @@ -42,14 +45,14 @@ def create_header() -> None: # Header with ( ui.header(wrap=False) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .style(f"background-color: {COLOR_STYLE.secondary_bg}") .classes("flex items-center justify-between h-[8vh] py-0 px-4") ): with ui.card().props("flat"): # small logo placeholder pass ( ui.link(PROJECT_NAME.upper(), "/") - .style(f"color: {COLOR_STYLE['primary']}; font-family: Arial, sans-serif; text-decoration: none") + .style(f"color: {COLOR_STYLE.primary}; font-family: Arial, sans-serif; text-decoration: none") .classes("text-4xl font-bold") ) ui.button(on_click=lambda: right_drawer.toggle(), icon="menu").props("flat color=white") @@ -57,7 +60,7 @@ def create_header() -> None: # Sidebar with ( ui.right_drawer(value=False, fixed=False) - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .style(f"background-color: {COLOR_STYLE.secondary_bg}") .props("overlay") .classes("p-0") as right_drawer, ui.element("q-scroll-area").classes("fit"), @@ -67,10 +70,10 @@ def create_header() -> None: ui.list().classes("fit"), ui.item(on_click=lambda: ui.navigate.to("/")) .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + .classes(f"hover:bg-[{COLOR_STYLE.primary}]"), ui.item_section(), ): - ui.label("HOME").style(f"color: {COLOR_STYLE['contrast']}") + ui.label("HOME").style(f"color: {COLOR_STYLE.contrast}") with ui.list().classes("fit"), ui.column().classes("w-full items-center"): ui.separator().style("background-color: #313131; width: 95%;") @@ -82,10 +85,10 @@ def create_header() -> None: with ( ui.item(on_click=lambda _, p=path: ui.navigate.to(p)) .props("clickable") - .classes(f"hover:bg-[{COLOR_STYLE['primary']}]"), + .classes(f"hover:bg-[{COLOR_STYLE.primary}]"), ui.item_section(), ): - ui.label(input_method["name"].upper()).style(f"color: {COLOR_STYLE['contrast']}") + ui.label(input_method["name"].upper()).style(f"color: {COLOR_STYLE.contrast}") def create_time_chips() -> tuple[ui.chip, ui.chip, ui.chip]: @@ -159,11 +162,11 @@ async def wpm_tester_page(method: str) -> None: create_header() # Main body - ui.query("body").style(f"background-color: {COLOR_STYLE['primary_bg']};") + ui.query("body").style(f"background-color: {COLOR_STYLE.primary_bg};") with ( ui.element("div") - .style(f"background-color: {COLOR_STYLE['secondary_bg']}") + .style(f"background-color: {COLOR_STYLE.secondary_bg}") .classes( """flex flex-col justify-evenly items-center absolute w-[90vw] h-[85vh] left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-xl""" From 043dde8699fe85d5a452e1f32257a6d60ab503ca Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 01:56:52 -0700 Subject: [PATCH 192/196] Add logo icon --- static/images/logo-icon.png | Bin 0 -> 1789 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/images/logo-icon.png diff --git a/static/images/logo-icon.png b/static/images/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..461e5241f77f8e68afb8362b24ff27b912368ae1 GIT binary patch literal 1789 zcmVVhit{9FMKU1h5Z?T$^hj?Q@z71}LVn7Z|BMX(BW!-E|V zX&n{nj>z8Lv|6vKP`6wIS({Q-sC!zKszPPZnp71khgPJjP+2sVszT*)jTu0%g$k8P z6y@op&KFgvT)x!lCRCxaIk`h$h05j%GuyB-DpWonPwC88p?)xl@^nz;gDTVyuBdn` zqeA`Rrb??;o#)FZ##k{r=ujAaz*rb1mm7e86;{T`Cg}WU54=KY;tT76L_i zI>bmGViq$G@q2MrKg!c_5`=Nto6>~-NP_UT&3~}4{4XoMZW|c4_R(+injJ78vIGeWM^ zzP&AbLxmopAXRGdJ1+i3g;u~yZT0@RMSh7(g;vDYsh?fPQ9{4i+Et+^kObi)GV9Z~ zELEYW(^+rU35+&RROpE$L6{-0n{c#wqC)F2aUH-GSDSiNh1S4kskFLu1S+%%wb!4V zi2YIo}>PlqbB0k*X(i4Q8YIg)N) zG(%g!`P7*LnxQR_xelO+3cW0}$Zdgz>i`O9k=p_b*8vn!p%;bA7| zsL%^Tg(Hp|!DFvt7B4qd*2KvzeRf+QS5%}gh zjsxs$+9B=S77^G6&=gsN*JZ@pFKC8tVT;%W@?}03TellDLoHV11b z*mS}3zLsqfFN9Lzboh5brCAcrht%X)>%@S#yOKJZ?W z1mX7kF%U<28Y6ee>lEhpJkv^u&rtUFsXmv;e8axyd#0{9{Da;>w9q4~yQ)kFnY0v& zEAFcnwHrhWOiwY@djp)iNmr%{#_CNmqI1>?hg=piE8EjZ!lp*TbQ znt1>%$prs}f)VF)KW}R?!S5pX64*3=T03&<5;D|R<}_%{?n8yTp{x(O6|~6hmKE^n zTrxp3v`e0WaT0`!b6!M+o}U$F>PRPROI7H3`GFZRS#)(lNkVs8dr1&xQJ%Vpr73fa z$gJxNOA?yxd3}z|mD6P`F~w`_Dga4B_Z?6kBX_~=hB;;ebnH41DMFJ2^-PTEo0T{p zHvwWvXK_oo4_$F!n9KI0NZROW4txgwNP=)INhX*Kb$y2P(R;Hb2xG^41kEue#k9*7 zYaM!bFHg6aB?euy!pPiLckU47=>+(4u7C>6F~e~gMxyH Date: Mon, 18 Aug 2025 01:57:49 -0700 Subject: [PATCH 193/196] Make platformer work with wasd and spacebar --- src/platformer_input/__init__.py | 5 ++--- src/platformer_input/platformer_simulation.py | 8 +++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/platformer_input/__init__.py b/src/platformer_input/__init__.py index 545e8e25..11dbf72b 100644 --- a/src/platformer_input/__init__.py +++ b/src/platformer_input/__init__.py @@ -7,7 +7,6 @@ from platformer_input.platformer_scene_cmp import PlatformerRendererComponent from platformer_input.platformer_simulation import PlatformerPhysicsSimulation -ALLOWED_KEYS = ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Shift", " ", "Enter") INITIAL_POS = (1, 10) FPS = 60 @@ -38,8 +37,8 @@ def __init__(self) -> None: def keyboard_handler(self, event: nicegui.events.KeyEventArguments) -> None: """Call with the nicegui keyboard callback.""" - evk = str(event.key) - if event.action.repeat or evk not in ALLOWED_KEYS: + evk = event.key.code + if event.action.repeat: return if event.action.keydown: diff --git a/src/platformer_input/platformer_simulation.py b/src/platformer_input/platformer_simulation.py index 301ad54d..35e3a4c5 100644 --- a/src/platformer_input/platformer_simulation.py +++ b/src/platformer_input/platformer_simulation.py @@ -63,11 +63,13 @@ def tick(self) -> None: delta_accel = self._deltatime * constants.ACCEL_SPEED - if "ArrowRight" in self._keys: + if "ArrowRight" in self._keys or "KeyD" in self._keys: self._xvel = min(constants.MOV_SPEED, self._xvel + delta_accel) - if "ArrowLeft" in self._keys: + if "ArrowLeft" in self._keys or "KeyA" in self._keys: self._xvel = max(-constants.MOV_SPEED, self._xvel - delta_accel) - if "ArrowUp" in self._keys and self._collides((self.player_x, self.player_y + 2 * EPSILON)): + if ("ArrowUp" in self._keys or "KeyW" in self._keys or "Space" in self._keys) and self._collides( + (self.player_x, self.player_y + 2 * EPSILON) + ): self._yvel = -constants.JUMP_FORCE self._apply_x_velocity() From 35a94ce7cc4a892ee56a572fa61757876950ef2f Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 02:08:20 -0700 Subject: [PATCH 194/196] Add favicon --- src/homepage.py | 7 ++++++- src/main.py | 9 +++++++-- src/wpm_tester.py | 10 +++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/homepage.py b/src/homepage.py index 9ac3b3a1..706d350c 100644 --- a/src/homepage.py +++ b/src/homepage.py @@ -1,7 +1,12 @@ -from nicegui import ui +from pathlib import Path + +from nicegui import app, ui from config import COLOR_STYLE, INPUT_METHODS, PROJECT_DESCRIPTION, PROJECT_NAME +media = Path("./static") +app.add_media_files("/media", media) + def home() -> None: """Render the home page.""" diff --git a/src/main.py b/src/main.py index 212f6752..1c3d32e0 100644 --- a/src/main.py +++ b/src/main.py @@ -1,9 +1,14 @@ -from nicegui import ui +from pathlib import Path + +from nicegui import app, ui from homepage import home from wpm_tester import wpm_tester_page +media = Path("./static") +app.add_media_files("/media", media) + ui.page("/")(home) ui.page("/test/{method}")(wpm_tester_page) -ui.run() +ui.run(title="Dynamic Typing") diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 78875505..08bd02ce 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -1,14 +1,18 @@ import secrets import time from dataclasses import dataclass +from pathlib import Path from faker import Faker -from nicegui import ui +from nicegui import app, ui import input_method_proto import input_view from config import COLOR_STYLE, INPUT_METHODS, PROJECT_NAME +media = Path("./static") +app.add_media_files("/media", media) + fake = Faker() @@ -45,8 +49,8 @@ def create_header() -> None: .style(f"background-color: {COLOR_STYLE['secondary_bg']}") .classes("flex items-center justify-between h-[8vh] py-0 px-4") ): - with ui.card().props("flat"): # small logo placeholder - pass + with ui.element("div").classes("w-[30px] h-auto"): + ui.image("/media/images/logo-icon.png") ( ui.link(PROJECT_NAME.upper(), "/") .style(f"color: {COLOR_STYLE['primary']}; font-family: Arial, sans-serif; text-decoration: none") From 51a439baabfdfa07343bdaadfd47564acd88e4c2 Mon Sep 17 00:00:00 2001 From: enskyeing Date: Mon, 18 Aug 2025 02:09:06 -0700 Subject: [PATCH 195/196] Remove path from main --- src/main.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main.py b/src/main.py index 1c3d32e0..044c412e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,13 +1,8 @@ -from pathlib import Path - -from nicegui import app, ui +from nicegui import ui from homepage import home from wpm_tester import wpm_tester_page -media = Path("./static") -app.add_media_files("/media", media) - ui.page("/")(home) ui.page("/test/{method}")(wpm_tester_page) From f32c5ea798e40ba0ca3e7981f949e2db74bd5d8c Mon Sep 17 00:00:00 2001 From: Manny Vera Date: Mon, 18 Aug 2025 02:40:16 -0700 Subject: [PATCH 196/196] added favicon and header logo clickable to home --- src/main.py | 2 +- src/wpm_tester.py | 2 +- static/images/favicon.ico | Bin 0 -> 15406 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 static/images/favicon.ico diff --git a/src/main.py b/src/main.py index 044c412e..724195ea 100644 --- a/src/main.py +++ b/src/main.py @@ -6,4 +6,4 @@ ui.page("/")(home) ui.page("/test/{method}")(wpm_tester_page) -ui.run(title="Dynamic Typing") +ui.run(title="Dynamic Typing", favicon="./static/images/favicon.ico") diff --git a/src/wpm_tester.py b/src/wpm_tester.py index 08bd02ce..cbcceb3f 100644 --- a/src/wpm_tester.py +++ b/src/wpm_tester.py @@ -49,7 +49,7 @@ def create_header() -> None: .style(f"background-color: {COLOR_STYLE['secondary_bg']}") .classes("flex items-center justify-between h-[8vh] py-0 px-4") ): - with ui.element("div").classes("w-[30px] h-auto"): + with ui.link(target="/").classes("w-[30px] h-auto"): ui.image("/media/images/logo-icon.png") ( ui.link(PROJECT_NAME.upper(), "/") diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c0e4561d648acdbcde8d49c81f00211da81078c1 GIT binary patch literal 15406 zcmeHO3v3ic7~TScf)5@NA3-fXNsI!eGxPs5<+u{AE7!X>hkGgaOgE0Zi{rS`(&Y1?J{-3g?*(6O-RXB;~1)(2UFTEWzJ@{=5tL!z@n+GhfrqQSP0P}``R6*V2tmuO=;en7M zG@`8^t>{zlJ_dP-6+N~4c7x|4@VGLhvb#l9_y{y-Ol`GBWbb{M=&23k1==|#`ULtH zuuH$F!P6J?B)_w=#_w%*=?M#TL%KH7|Luexw7cBWFOkMy9!~1t$lo@8;C$P}JAw9U z+Td5%uR)bv>tD@Z6_Cc2hL!S%K(_>?)kNPa#`t^N(rfZTU1qLt#7(+eMl#JnpH|nz zm%hcrb>){jric6@s_>3~<>;P7OIMoz>VBNM!v{`?valC*)`90tEB{Tl;{Gpga zP-uM0bb)se7>=MEH5cIA7L@s^(Ws{@(bvSsT@Bw2qwG)8uwCfKPom1c!hIq1UKNnb zZt|q+XX0sm4<3J_t;~8+g)>1#7?VhFCD85acIzhFIR)K+i@MYOux%qRtKV7qC)->Q z{n~RG+J#@XgSG9q1Hkd3(JsswZ1I=*h0%ryG|n7%$e(<%6*}Gw8QyN@P0ud)H)Gs= zu2$u%ZPBJ=LUn89@{wi?w%S1dBWhWnWdE6jHcB+?GK)BrM^k7WyWfbxE`Ra?*!Xv- ze*!w42ANL4^I?bl$I2PG=hnGhu^GEZ2S734g-i zlSXul)R$d{Ie1V&Djy0QV}db$9*uWoYngbO-nRrL;bCgeDSx7|x>h!h?g^c|0v!LE zcv+7vvQS6uWZIM+Tu7o0M<L21b>>Vzs(zZ{+j${VN zANrE@LjMoh;9n5?izw_-%+W>hKMC_gNY&Q07Gqxe+9?!g6Y^<)DZhK!*1V_+CuknC z!#^yu56P$6yP`^Ef9U%IlolI$PhhTEMeW()kM?%LpC4VkmLH;*Fy>xO&k8>o-$k44 zXgZ;_1KQKpK@R9zQTL2qY{|IodN^qlDqHIF`MNmhe^y(vBlBb(wreTk7+seR`kx03 z>HRG!y{_d^|0OElH@}sIkyYr9L0@J#;_yEvCin7_^b0u{ zp$T=jz&@9wPcMb)*@KH#d-~EG?WQMa2YGKt_^GcuBU0I5j&euP2k^Lw*D0nwDXQN6 zpqItCFp>1=v=1)vH;Xbqmh>N#1t07LF~4IOm~~FMgAJnW9UI&r^s}oEm-zodE_ZzN zXHR#m3Fp$fBMiW9d~_jl zg7eYt7ChUD6?C~<-H{jNXH$-@(D;G%7}lVTx%f{NxP9Dq3hVYIZf<8xeG=9A=q3B^`W-jdjt8keawb6uI$|( zz#6`o_-V|7FVBKKJ052dD>aAWLRm5KlMWVPKEl5JUygNv`I2JNV&m6*P!WE1j30A2 z#^dG1#-B*|y1;%uafKi8TCwrNgi7E8@3_K`z2VM)AM1-Z-QXwe=ke1hlEanE!MyKA z{_x+T+Yk9K)}^-g)y;jRd`q$IhxWuFN6HmvSgU1z-x;S^)jI=qYEZWxG{2)ztmt^GA%2F^jQq^V9P=l}_F51FrWNqQa~e-$ zeb>Jcubc+28Xxj8y6w8LAA>AhV{y?2<>wn~ae$9#C|TwANGA1@tH3wJ$tbk`#FIBDE#&Lnt0VER@gf&epx%~wF7fx7430m z!rn>oBR9>$;dsSRA7Y#Kuy->2+WvJ|62{Q~MCkKVC&o|bAx^?S4`N(bF^^Ajb$-D4 z`3<+@xORR6SdRhoRx!pt#T+toNvz@##E^Y`J}#@RSm{E`!`nMQlkI#>`gi<41@LV~ lfoz3k+BpL{KOw4Z7