diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc548ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ + +.git +.gitignore +.idea +.vscode + +node_modules +frontend/node_modules +frontend/wailsjs + +build/bin +dist + +*.log + diff --git a/.gitignore b/.gitignore index b7faf40..0471666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,31 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class -# C extensions +# Go +*.exe +*.dll *.so +*.dylib +*.test +*.out -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# Wails build artifacts +/build/ +/frontend/wailsjs/ -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +# Frontend dependencies and build output +/frontend/node_modules/ +/frontend/dist/ +/frontend/package.json.md5 -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# Environment files .env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +.env.* +!.env.example -# Spyder project settings -.spyderproject -.spyproject +# IDE +.idea/ +.vscode/ -# Rope project settings -.ropeproject +# OS +.DS_Store +Thumbs.db -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc5e863 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ + +# Stage 1: Frontend build +FROM node:22-bookworm-slim AS frontend-builder + +WORKDIR /frontend + +COPY frontend/package.json ./ + +RUN npm install + +COPY frontend/ ./ + +RUN npm run build + + +# Stage 2: Go build and test +FROM golang:1.24-bookworm AS go-builder + +ENV CGO_ENABLED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +COPY --from=frontend-builder /frontend/dist ./frontend/dist + +RUN go test -v ./... + +RUN go build -o /out/fast_reader . + + +# Stage 3: Export build artifact +FROM scratch AS artifact + +COPY --from=go-builder /out/fast_reader /fast_reader + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b122d92 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ + +.PHONY: install dev build test lint clean docker-build docker-test + +install: + go mod tidy + cd frontend && npm install + +dev: + wails dev + +build: + wails build + +test: + go test -v -race ./... + +lint: + go vet ./... + +clean: + rm -rf build/bin frontend/dist + +docker-build: + docker build --target artifact -t fast_reader:0.1.0 . + docker create --name fast_reader-export fast_reader:0.1.0 + docker cp fast_reader-export:/fast_reader ./fast_reader-linux + docker rm fast_reader-export + +docker-test: + docker build --target go-builder -t fast_reader-ci . + diff --git a/README.md b/README.md index e7fb5ef..a42ef32 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ # fast_reader + Machine that aims to speed up your reading. +A cross-platform RSVP (Rapid Serial Visual Presentation) speed-reading +desktop application built with Go and Wails. + +## Features + +- RSVP speed reading +- Adjustable reading speed (100–1000 WPM) +- Optimal Recognition Point (ORP) highlighting +- Text input and demo text +- Playback controls +- Reading progress indicator +- Keyboard shortcuts +- Cross-platform desktop support + +## Tech Stack + +- Go +- Wails v2 +- TypeScript +- Vite + +## Prerequisites + +- Go 1.24+ +- Node.js 22+ +- npm +- Wails CLI v2 + +For Linux development, install the Wails system dependencies: +https://wails.io/docs/gettingstarted/installation + +## Getting Started + +Install Wails: + + go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 + +Install project dependencies: + + make install + +Start development mode: + + make dev + +Build the desktop application: + + make build + +Run Go tests: + + make test + +## Docker + +Build and test the Linux application in Docker: + + docker build --target go-builder -t fast_reader-ci . + +Export the Linux binary: + + make docker-build + +Note: Docker builds a Linux binary. Windows and macOS builds +should be performed on their respective operating systems or +through dedicated cross-platform build runners. + +## Keyboard Shortcuts + +| Key | Action | +| ------ | ------------- | +| Space | Play / Pause | +| ← | Previous word | +| → | Next word | +| R | Reset | +| Escape | Pause | + +## Project Structure + + app/ Wails application bindings + internal/reader/ RSVP reader engine + frontend/ TypeScript UI + build/ Wails build artifacts + +## License + +MIT diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..d8335df --- /dev/null +++ b/app/app.go @@ -0,0 +1,121 @@ +package app + +import ( + "context" + "log" + "os" + "path/filepath" + "sync" + + "fast_reader/internal/reader" + "fast_reader/internal/settings" + "fast_reader/internal/storage" +) + +type App struct { + ctx context.Context + mu sync.RWMutex + settings settings.Settings + settingsPath string +} + +func NewApp() *App { + return &App{ + settings: settings.Default(), + } +} + +func (a *App) Startup(ctx context.Context) { + a.ctx = ctx + + configDir, err := os.UserConfigDir() + if err != nil { + log.Printf("Could not locate user config directory: %v", err) + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + a.settingsPath = filepath.Join( + configDir, + "fast_reader", + "settings.json", + ) + + var saved settings.Settings + + err = storage.Load(a.settingsPath, &saved) + + if err != nil { + if !os.IsNotExist(err) { + log.Printf("Could not load settings: %v", err) + } + + a.settings = settings.Default() + return + } + + a.settings = saved.Normalize() + + log.Println("Settings loaded successfully") +} + +// PrepareText converts pasted text into a sequence of words +// ready for RSVP playback. +func (a *App) PrepareText(text string) []reader.Word { + return reader.Tokenize(text) +} + +// GetVersion returns the application version. +func (a *App) GetVersion() string { + return "0.1.0" +} + +// GetSettings returns the current application settings. +func (a *App) GetSettings() settings.Settings { + a.mu.RLock() + defer a.mu.RUnlock() + + return a.settings +} + +// SaveSettings validates and persists application settings. +func (a *App) SaveSettings(input settings.Settings) error { + normalized := input.Normalize() + + a.mu.Lock() + defer a.mu.Unlock() + + if a.settingsPath == "" { + configDir, err := os.UserConfigDir() + if err != nil { + return err + } + + a.settingsPath = filepath.Join( + configDir, + "fast_reader", + "settings.json", + ) + } + + if err := storage.Save(a.settingsPath, normalized); err != nil { + return err + } + + a.settings = normalized + + return nil +} + +// ResetSettings restores and persists the default settings. +func (a *App) ResetSettings() (settings.Settings, error) { + defaults := settings.Default() + + if err := a.SaveSettings(defaults); err != nil { + return settings.Settings{}, err + } + + return defaults, nil +} diff --git a/app/app_test.go b/app/app_test.go new file mode 100644 index 0000000..5b8a683 --- /dev/null +++ b/app/app_test.go @@ -0,0 +1,154 @@ +package app + +import ( + "context" + "testing" + + "fast_reader/internal/settings" +) + +func TestNewApp(t *testing.T) { + a := NewApp() + + if a == nil { + t.Fatal("NewApp() returned nil") + } + + if got := a.GetSettings(); got != settings.Default() { + t.Errorf("initial settings = %+v, want %+v", + got, settings.Default()) + } +} + +func TestStartup(t *testing.T) { + application := NewApp() + ctx := context.Background() + + // Startup should store the Wails application context. + application.Startup(ctx) + + if application.ctx != ctx { + t.Error("Startup() did not store the application context") + } +} + +func TestPrepareText(t *testing.T) { + a := NewApp() + + words := a.PrepareText("Hello world") + + if len(words) != 2 { + t.Fatalf("PrepareText() returned %d words, want 2", len(words)) + } + + if words[0].Text != "Hello" || words[1].Text != "world" { + t.Errorf("unexpected words: %+v", words) + } +} + +func TestPrepareEmptyText(t *testing.T) { + application := NewApp() + + got := application.PrepareText("") + + if len(got) != 0 { + t.Errorf("PrepareText() returned %d words for empty input, want 0", len(got)) + } +} + +func TestGetVersion(t *testing.T) { + a := NewApp() + + if got := a.GetVersion(); got != "0.1.0" { + t.Errorf("GetVersion() = %q, want %q", got, "0.1.0") + } +} + +func TestSaveAndLoadSettings(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + first := NewApp() + first.Startup(context.Background()) + + expected := settings.Settings{ + ReadingSpeed: 500, + FontSize: 64, + Theme: settings.ThemeLight, + } + + if err := first.SaveSettings(expected); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + if got := first.GetSettings(); got != expected { + t.Errorf("settings = %+v, want %+v", got, expected) + } + + // Simulate restarting the application. + second := NewApp() + second.Startup(context.Background()) + + if got := second.GetSettings(); got != expected { + t.Errorf("reloaded settings = %+v, want %+v", got, expected) + } +} + +func TestSaveSettingsNormalizesValues(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + a := NewApp() + a.Startup(context.Background()) + + input := settings.Settings{ + ReadingSpeed: 2000, + FontSize: 10, + Theme: "invalid", + } + + if err := a.SaveSettings(input); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + expected := settings.Settings{ + ReadingSpeed: settings.MaxReadingSpeed, + FontSize: settings.MinFontSize, + Theme: settings.ThemeDark, + } + + if got := a.GetSettings(); got != expected { + t.Errorf("settings = %+v, want %+v", got, expected) + } +} + +func TestResetSettings(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + a := NewApp() + a.Startup(context.Background()) + + custom := settings.Settings{ + ReadingSpeed: 600, + FontSize: 72, + Theme: settings.ThemeLight, + } + + if err := a.SaveSettings(custom); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + got, err := a.ResetSettings() + if err != nil { + t.Fatalf("ResetSettings() error = %v", err) + } + + if got != settings.Default() { + t.Errorf("ResetSettings() = %+v, want %+v", + got, settings.Default()) + } + + if current := a.GetSettings(); current != settings.Default() { + t.Errorf("current settings = %+v, want defaults %+v", + current, settings.Default()) + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c7f0fba --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Fast_Reader + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0767996 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1173 @@ +{ + "name": "fast_reader-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fast_reader-frontend", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.5.tgz", + "integrity": "sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.5.tgz", + "integrity": "sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.5.tgz", + "integrity": "sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.5.tgz", + "integrity": "sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.5.tgz", + "integrity": "sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.5.tgz", + "integrity": "sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.5.tgz", + "integrity": "sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.5.tgz", + "integrity": "sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.5.tgz", + "integrity": "sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.5.tgz", + "integrity": "sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.5.tgz", + "integrity": "sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.5.tgz", + "integrity": "sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.5.tgz", + "integrity": "sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.5.tgz", + "integrity": "sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.5.tgz", + "integrity": "sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.5.tgz", + "integrity": "sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.5.tgz", + "integrity": "sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.5.tgz", + "integrity": "sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.5.tgz", + "integrity": "sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.5.tgz", + "integrity": "sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.5.tgz", + "integrity": "sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.5.tgz", + "integrity": "sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.5.tgz", + "integrity": "sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.5.tgz", + "integrity": "sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.5.tgz", + "integrity": "sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.5.tgz", + "integrity": "sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.67.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.5.tgz", + "integrity": "sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.67.5", + "@rollup/rollup-android-arm64": "4.67.5", + "@rollup/rollup-android-x64": "4.67.5", + "@rollup/rollup-darwin-arm64": "4.67.5", + "@rollup/rollup-darwin-x64": "4.67.5", + "@rollup/rollup-freebsd-arm64": "4.67.5", + "@rollup/rollup-freebsd-x64": "4.67.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.5", + "@rollup/rollup-linux-arm-musleabihf": "4.67.5", + "@rollup/rollup-linux-arm64-gnu": "4.67.5", + "@rollup/rollup-linux-arm64-musl": "4.67.5", + "@rollup/rollup-linux-loong64-gnu": "4.67.5", + "@rollup/rollup-linux-loong64-musl": "4.67.5", + "@rollup/rollup-linux-ppc64-gnu": "4.67.5", + "@rollup/rollup-linux-ppc64-musl": "4.67.5", + "@rollup/rollup-linux-riscv64-gnu": "4.67.5", + "@rollup/rollup-linux-riscv64-musl": "4.67.5", + "@rollup/rollup-linux-s390x-gnu": "4.67.5", + "@rollup/rollup-linux-x64-gnu": "4.67.5", + "@rollup/rollup-linux-x64-musl": "4.67.5", + "@rollup/rollup-openbsd-x64": "4.67.5", + "@rollup/rollup-openharmony-arm64": "4.67.5", + "@rollup/rollup-win32-arm64-msvc": "4.67.5", + "@rollup/rollup-win32-ia32-msvc": "4.67.5", + "@rollup/rollup-win32-x64-gnu": "4.67.5", + "@rollup/rollup-win32-x64-msvc": "4.67.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c98f95f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "fast_reader-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^6.2.0" + }, + "allowScripts": { + "esbuild@0.25.12": true + } +} diff --git a/frontend/src/app.ts b/frontend/src/app.ts new file mode 100644 index 0000000..426486d --- /dev/null +++ b/frontend/src/app.ts @@ -0,0 +1,431 @@ +import { + PrepareText, + GetSettings, + SaveSettings, + ResetSettings, +} from "../wailsjs/go/app/App"; + +interface Word { + text: string; + prefix: string; + focus: string; + suffix: string; +} + +interface AppSettings { + readingSpeed: number; + fontSize: number; + theme: string; +} + +const root = document.querySelector("#app")!; + +root.innerHTML = ` +
+
+
+

Fast Reader Training

+

Focus on one word at a time.

+
+ v0.1.0 +
+ +
+ + + +
+ +
+
+ Ready to read + 0 / 0 +
+ +
+ FlashRead +
+ +
+
+
+ +
+ + + +
+
+
+ +
+ + 300 WPM +
+
+ +
+ +
+ + 48 px +
+
+ +
+ + +
+ + +
+
+ +
+ Space: Play/Pause + ← →: Navigate + R: Reset +
+
+`; + +const textInput = document.querySelector("#text-input")!; +const wordCount = document.querySelector("#word-count")!; +const display = document.querySelector("#word-display")!; +const prefixEl = display.querySelector(".word-prefix")!; +const focusEl = display.querySelector(".word-focus")!; +const suffixEl = display.querySelector(".word-suffix")!; +const playButton = document.querySelector("#play")!; +const resetButton = document.querySelector("#reset")!; +const skipButton = document.querySelector("#skip")!; +const speedInput = document.querySelector("#speed")!; +const speedValue = document.querySelector("#speed-value")!; +const progressBar = document.querySelector("#progress-bar")!; +const positionLabel = + document.querySelector("#current-position")!; +const statusLabel = document.querySelector("#reading-status")!; +const demoButton = document.querySelector("#load-demo")!; +const fontSizeInput = document.querySelector("#font-size")!; + +const fontSizeValue = document.querySelector("#font-size-value")!; + +const themeInput = document.querySelector("#theme")!; + +const resetSettingsButton = + document.querySelector("#reset-settings")!; + +const settingsStatus = + document.querySelector("#settings-status")!; + +let words: Word[] = []; +let currentIndex = 0; +let isPlaying = false; +let timer: number | undefined; +let wpm = 300; +let fontSize = 48; +let theme = "dark"; + +let currentSettings: AppSettings = { + readingSpeed: 300, + fontSize: 48, + theme: "dark", +}; + +function applySettings(settings: AppSettings) { + currentSettings = settings; + + wpm = settings.readingSpeed; + fontSize = settings.fontSize; + theme = settings.theme; + + speedInput.value = String(wpm); + speedValue.textContent = `${wpm} WPM`; + + fontSizeInput.value = String(fontSize); + fontSizeValue.textContent = `${fontSize} px`; + + themeInput.value = theme; + + document.documentElement.dataset.theme = theme; + display.style.fontSize = `${fontSize}px`; +} + +async function persistSettings() { + currentSettings = { + readingSpeed: wpm, + fontSize, + theme, + }; + + settingsStatus.textContent = "Saving..."; + + try { + await SaveSettings(currentSettings); + settingsStatus.textContent = "Settings saved"; + } catch (error) { + console.error("Could not save settings:", error); + settingsStatus.textContent = "Could not save settings"; + } +} + +async function loadSettings() { + try { + const saved = await GetSettings(); + applySettings(saved); + } catch (error) { + console.error("Could not load settings:", error); + settingsStatus.textContent = "Using default settings"; + } +} + +function updateWordCount() { + const count = textInput.value.trim() + ? textInput.value.trim().split(/\s+/).length + : 0; + + wordCount.textContent = `${count} words`; +} + +function renderWord() { + if (words.length === 0) { + prefixEl.textContent = ""; + focusEl.textContent = "F"; + suffixEl.textContent = "lashRead"; + positionLabel.textContent = "0 / 0"; + progressBar.style.width = "0%"; + return; + } + + const word = words[currentIndex]; + + prefixEl.textContent = word.prefix; + focusEl.textContent = word.focus; + suffixEl.textContent = word.suffix; + + positionLabel.textContent = `${currentIndex + 1} / ${words.length}`; + + progressBar.style.width = `${((currentIndex + 1) / words.length) * 100}%`; +} + +function stopPlayback() { + isPlaying = false; + + if (timer !== undefined) { + window.clearTimeout(timer); + timer = undefined; + } + + playButton.textContent = "▶ Start reading"; + statusLabel.textContent = "Paused"; +} + +function advance() { + if (!isPlaying) return; + + if (currentIndex >= words.length - 1) { + stopPlayback(); + statusLabel.textContent = "Finished reading"; + return; + } + + const currentWord = words[currentIndex].text; + + currentIndex++; + renderWord(); + + const delay = getDuration(currentWord); + timer = window.setTimeout(advance, delay); +} + +function getDuration(word: string): number { + let duration = 60000 / wpm; + + if (/[.!?]$/.test(word)) { + duration += 300; + } else if (/[,;:]$/.test(word)) { + duration += 150; + } + + if ([...word].length >= 10) { + duration += 50; + } + + return duration; +} + +async function startPlayback() { + if (words.length === 0) { + const text = textInput.value.trim(); + + if (!text) { + textInput.focus(); + return; + } + + words = await PrepareText(text); + currentIndex = 0; + renderWord(); + } + + if (currentIndex >= words.length - 1) { + currentIndex = 0; + renderWord(); + } + + isPlaying = true; + playButton.textContent = "⏸ Pause"; + statusLabel.textContent = "Reading"; + + timer = window.setTimeout(advance, getDuration(words[currentIndex].text)); +} + +playButton.addEventListener("click", () => { + if (isPlaying) { + stopPlayback(); + } else { + void startPlayback(); + } +}); + +resetButton.addEventListener("click", () => { + stopPlayback(); + currentIndex = 0; + renderWord(); + statusLabel.textContent = "Ready to read"; +}); + +skipButton.addEventListener("click", () => { + if (currentIndex < words.length - 1) { + currentIndex++; + renderWord(); + } +}); + +speedInput.addEventListener("input", () => { + wpm = Number(speedInput.value); + speedValue.textContent = `${wpm} WPM`; +}); + +speedInput.addEventListener("change", () => { + void persistSettings(); +}); +fontSizeInput.addEventListener("input", () => { + fontSize = Number(fontSizeInput.value); + + fontSizeValue.textContent = `${fontSize} px`; + display.style.fontSize = `${fontSize}px`; +}); + +fontSizeInput.addEventListener("change", () => { + void persistSettings(); +}); + +themeInput.addEventListener("change", () => { + theme = themeInput.value; + document.documentElement.dataset.theme = theme; + + void persistSettings(); +}); + +textInput.addEventListener("input", () => { + stopPlayback(); + words = []; + currentIndex = 0; + updateWordCount(); + renderWord(); + statusLabel.textContent = "Ready to read"; +}); + +demoButton.addEventListener("click", () => { + textInput.value = + "The quick brown fox jumps over the lazy dog. " + + "Speed reading helps you focus on one word at a time, " + + "reducing the need for eye movement across a page. " + + "Experiment with different speeds and discover what works best for you."; + + updateWordCount(); + words = []; + currentIndex = 0; + stopPlayback(); + renderWord(); +}); +resetSettingsButton.addEventListener("click", async () => { + try { + settingsStatus.textContent = "Resetting..."; + + const defaults = await ResetSettings(); + applySettings(defaults); + + settingsStatus.textContent = "Settings reset"; + } catch (error) { + console.error("Could not reset settings:", error); + settingsStatus.textContent = "Could not reset settings"; + } +}); + +document.addEventListener("keydown", (event) => { + const target = event.target as HTMLElement; + + if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") { + return; + } + + switch (event.key) { + case " ": + event.preventDefault(); + playButton.click(); + break; + + case "ArrowRight": + skipButton.click(); + break; + + case "ArrowLeft": + if (currentIndex > 0) { + currentIndex--; + renderWord(); + } + break; + + case "r": + case "R": + resetButton.click(); + break; + + case "Escape": + stopPlayback(); + break; + } +}); + +updateWordCount(); +renderWord(); + +void loadSettings(); diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cb6b82d --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,2 @@ +import "./styles/style.css"; +import "./app"; diff --git a/frontend/src/styles/style.css b/frontend/src/styles/style.css new file mode 100644 index 0000000..275ffa7 --- /dev/null +++ b/frontend/src/styles/style.css @@ -0,0 +1,306 @@ +:root { + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + + --background: #121218; + --text: #e8e8ed; + --surface: #1c1c24; + --border: #30303a; + --muted: #92929e; + --accent: #8b7cf6; + --red: #ff4c55; + --secondary-background: #292932; + --secondary-border: #3a3a45; + --secondary-text: #d0d0da; + --progress-track: #32323c; + + color: var(--text); + background: var(--background); +} + +:root[data-theme="light"] { + --background: #f4f4f8; + --text: #24242d; + --surface: #ffffff; + --border: #dedee7; + --muted: #707080; + --accent: #6958df; + --red: #d93645; + --secondary-background: #ededf3; + --secondary-border: #d5d5df; + --secondary-text: #33333e; + --progress-track: #dedee7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 600px; + min-height: 100vh; + background: var(--background); + color: var(--text); +} + +button, +textarea, +input { + font: inherit; +} + +.layout { + max-width: 1000px; + margin: 0 auto; + padding: 36px 48px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +h1 { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.8px; + margin: 0; +} + +.subtitle { + margin: 6px 0 0; + color: var(--muted); + font-size: 14px; +} + +.version { + color: var(--muted); + font-size: 12px; + border: 1px solid var(--border); + padding: 5px 10px; + border-radius: 20px; +} + +.input-section { + margin-bottom: 24px; +} + +.input-section label, +.settings label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 10px; +} + +textarea { + display: block; + width: 100%; + height: 130px; + resize: vertical; + padding: 14px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + outline: none; + line-height: 1.6; + font-size: 14px; +} + +textarea:focus { + border-color: var(--accent); +} + +.input-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; +} + +#word-count { + font-size: 12px; + color: var(--muted); +} + +button { + border: 0; + border-radius: 8px; + cursor: pointer; + transition: opacity 0.15s; +} + +button:hover { + opacity: 0.85; +} + +.primary { + background: var(--accent); + color: white; + padding: 12px 28px; + font-weight: 600; +} + +.secondary { + color: var(--secondary-text); + background: var(--secondary-background); + border: 1px solid var(--secondary-border); + padding: 10px 16px; +} + +.reader { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 24px; +} + +.reader-top { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; +} + +.word-display { + display: flex; + align-items: baseline; + justify-content: center; + min-height: 180px; + padding: 48px 0; + font-size: clamp(36px, 6vw, 64px); + font-weight: 500; + letter-spacing: 0.5px; + font-variant-ligatures: none; +} + +.word-prefix, +.word-focus, +.word-suffix { + white-space: pre; +} + +.word-focus { + color: var(--red); + font-weight: 700; +} + +.progress-track { + height: 4px; + border-radius: 4px; + overflow: hidden; + background: var(--progress-track); +} + +#progress-bar { + height: 100%; + width: 0; + background: var(--accent); + transition: width 0.08s linear; +} + +.controls { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin: 24px 0 32px; +} + +.settings { + max-width: 500px; + margin: 0 auto; +} + +.speed-control { + display: flex; + align-items: center; + gap: 20px; +} + +input[type="range"] { + flex: 1; + accent-color: var(--accent); + cursor: pointer; +} + +#speed-value { + min-width: 80px; + font-size: 13px; + text-align: right; +} + +footer { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 24px; + margin-top: 24px; + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 700px) { + .layout { + padding: 24px 18px; + } + + .reader { + padding: 16px; + } + + .word-display { + min-height: 140px; + padding: 36px 0; + } +} + +.setting-group { + margin-bottom: 20px; +} + +.settings select { + width: 100%; + padding: 10px 12px; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + outline: none; + color-scheme: light dark; +} + +.settings select option { + color: var(--text); + background-color: var(--surface); +} + +.settings select:focus { + border-color: var(--accent); +} + +.settings-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 24px; +} + +#settings-status { + color: var(--muted); + font-size: 12px; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..9e7fe01 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": [ + "src" + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..197e80a --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module fast_reader + +go 1.24.0 + +require github.com/wailsapp/wails/v2 v2.10.2 + +require ( + github.com/bep/debounce v1.2.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.19 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b1e0229 --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= +github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk= +github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/reader/engine.go b/internal/reader/engine.go new file mode 100644 index 0000000..1801f01 --- /dev/null +++ b/internal/reader/engine.go @@ -0,0 +1,39 @@ +package reader + +import "time" + +const ( + MinWPM = 100 + MaxWPM = 1000 +) + +// WordDuration calculates how long a word should be displayed +// at the specified reading speed. +func WordDuration(word string, wpm int) time.Duration { + // Enforce supported reading speed limits. + if wpm < MinWPM { + wpm = MinWPM + } + + if wpm > MaxWPM { + wpm = MaxWPM + } + + // Base duration for one word. + duration := time.Minute / time.Duration(wpm) + + // Add pauses for punctuation. + switch { + case IsSentenceEnding(word): + duration += 300 * time.Millisecond + case IsPausePunctuation(word): + duration += 150 * time.Millisecond + } + + // Allow slightly more time for longer words. + if RuneLength(word) >= 10 { + duration += 50 * time.Millisecond + } + + return duration +} diff --git a/internal/reader/engine_test.go b/internal/reader/engine_test.go new file mode 100644 index 0000000..4eedffd --- /dev/null +++ b/internal/reader/engine_test.go @@ -0,0 +1,273 @@ +package reader + +import ( + "reflect" + "testing" + "time" +) + +func TestORPIndex(t *testing.T) { + tests := []struct { + name string + word string + expected int + }{ + {"empty", "", 0}, + {"one character", "a", 0}, + {"two characters", "go", 1}, + {"three characters", "cat", 1}, + {"five characters", "hello", 1}, + {"six characters", "reader", 2}, + {"nine characters", "wonderful", 2}, + {"ten characters", "technology", 3}, + {"long word", "extraordinary", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ORPIndex([]rune(tt.word)) + + if got != tt.expected { + t.Errorf("ORPIndex(%q) = %d, want %d", + tt.word, got, tt.expected) + } + }) + } +} + +func TestSplitWord(t *testing.T) { + tests := []struct { + name string + input string + expected Word + }{ + { + name: "normal word", + input: "hello", + expected: Word{ + Text: "hello", + Prefix: "h", + Focus: "e", + Suffix: "llo", + }, + }, + { + name: "unicode word", + input: "café", + expected: Word{ + Text: "café", + Prefix: "c", + Focus: "a", + Suffix: "fé", + }, + }, + { + name: "emoji", + input: "🙂hello", + expected: Word{ + Text: "🙂hello", + Prefix: "🙂h", + Focus: "e", + Suffix: "llo", + }, + }, + { + name: "empty word", + input: "", + expected: Word{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SplitWord(tt.input) + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("SplitWord(%q) = %+v, want %+v", + tt.input, got, tt.expected) + } + }) + } +} + +func TestTokenize(t *testing.T) { + tests := []struct { + name string + input string + expectedCount int + }{ + {"normal text", "Hello world", 2}, + {"multiple spaces", "Hello world", 2}, + {"newlines", "Hello\nworld", 2}, + {"tabs", "Hello\tworld", 2}, + {"mixed whitespace", " Hello\n world\tagain ", 3}, + {"empty input", "", 0}, + {"whitespace only", " \n\t ", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Tokenize(tt.input) + + if len(got) != tt.expectedCount { + t.Errorf("Tokenize(%q) returned %d words, want %d", + tt.input, len(got), tt.expectedCount) + } + }) + } +} + +func TestTokenizePreservesWords(t *testing.T) { + input := "Hello, world!" + words := Tokenize(input) + + expected := []string{"Hello,", "world!"} + + for i, word := range words { + if word.Text != expected[i] { + t.Errorf("word %d = %q, want %q", + i, word.Text, expected[i]) + } + } +} + +func TestIsSentenceEnding(t *testing.T) { + tests := []struct { + word string + expected bool + }{ + {"hello.", true}, + {"hello!", true}, + {"hello?", true}, + {"hello…", true}, + {`hello."`, true}, + {"hello!)", true}, + {"hello", false}, + {"hello,", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + got := IsSentenceEnding(tt.word) + + if got != tt.expected { + t.Errorf("IsSentenceEnding(%q) = %t, want %t", + tt.word, got, tt.expected) + } + }) + } +} + +func TestIsPausePunctuation(t *testing.T) { + tests := []struct { + word string + expected bool + }{ + {"hello,", true}, + {"hello;", true}, + {"hello:", true}, + {`hello,"`, true}, + {"hello", false}, + {"hello.", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + got := IsPausePunctuation(tt.word) + + if got != tt.expected { + t.Errorf("IsPausePunctuation(%q) = %t, want %t", + tt.word, got, tt.expected) + } + }) + } +} + +func TestWordDuration(t *testing.T) { + tests := []struct { + name string + word string + wpm int + expected time.Duration + }{ + { + name: "normal word", + word: "hello", + wpm: 300, + expected: 200 * time.Millisecond, + }, + { + name: "sentence ending", + word: "hello.", + wpm: 300, + expected: 500 * time.Millisecond, + }, + { + name: "comma pause", + word: "hello,", + wpm: 300, + expected: 350 * time.Millisecond, + }, + { + name: "long word", + word: "extraordinary", + wpm: 300, + expected: 250 * time.Millisecond, + }, + { + name: "sentence ending and long word", + word: "extraordinary.", + wpm: 300, + expected: 550 * time.Millisecond, + }, + { + name: "minimum WPM clamp", + word: "hello", + wpm: 50, + expected: 600 * time.Millisecond, + }, + { + name: "maximum WPM clamp", + word: "hello", + wpm: 1500, + expected: 60 * time.Millisecond, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := WordDuration(tt.word, tt.wpm) + + if got != tt.expected { + t.Errorf("WordDuration(%q, %d) = %v, want %v", + tt.word, tt.wpm, got, tt.expected) + } + }) + } +} + +func TestRuneLength(t *testing.T) { + tests := []struct { + word string + expected int + }{ + {"hello", 5}, + {"café", 4}, + {"🙂", 1}, + {"🙂hello", 6}, + {"", 0}, + } + + for _, tt := range tests { + t.Run(tt.word, func(t *testing.T) { + got := RuneLength(tt.word) + + if got != tt.expected { + t.Errorf("RuneLength(%q) = %d, want %d", + tt.word, got, tt.expected) + } + }) + } +} diff --git a/internal/reader/orp.go b/internal/reader/orp.go new file mode 100644 index 0000000..ad3f353 --- /dev/null +++ b/internal/reader/orp.go @@ -0,0 +1,20 @@ +package reader + +// ORPIndex calculates the approximate optimal recognition +// point of a word. +// +// The returned value is a zero-based rune index. +func ORPIndex(runes []rune) int { + length := len(runes) + + switch { + case length <= 1: + return 0 + case length <= 5: + return 1 + case length <= 9: + return 2 + default: + return 3 + } +} diff --git a/internal/reader/tokenizer.go b/internal/reader/tokenizer.go new file mode 100644 index 0000000..5a55961 --- /dev/null +++ b/internal/reader/tokenizer.go @@ -0,0 +1,98 @@ +package reader + +import ( + "strings" + "unicode" +) + +type Word struct { + Text string `json:"text"` + Prefix string `json:"prefix"` + Focus string `json:"focus"` + Suffix string `json:"suffix"` +} + +// Tokenize splits text into words while ignoring repeated whitespace. +func Tokenize(text string) []Word { + fields := strings.Fields(text) + words := make([]Word, 0, len(fields)) + + for _, field := range fields { + words = append(words, SplitWord(field)) + } + + return words +} + +// SplitWord separates a word into its prefix, focus character and suffix. +func SplitWord(word string) Word { + if word == "" { + return Word{} + } + + runes := []rune(word) + position := ORPIndex(runes) + + // Keep the focus index inside the word. + if position >= len(runes) { + position = len(runes) - 1 + } + + return Word{ + Text: word, + Prefix: string(runes[:position]), + Focus: string(runes[position : position+1]), + Suffix: string(runes[position+1:]), + } +} + +// lastMeaningfulRune returns the last rune after ignoring +// closing quotes and brackets. +func lastMeaningfulRune(word string) rune { + word = strings.TrimRightFunc(word, func(r rune) bool { + switch r { + case '"', '\'', '”', '’', ')', ']', '}', '»': + return true + default: + return false + } + }) + + runes := []rune(word) + if len(runes) == 0 { + return 0 + } + + return runes[len(runes)-1] +} + +// IsSentenceEnding checks whether a word ends a sentence. +func IsSentenceEnding(word string) bool { + switch lastMeaningfulRune(word) { + case '.', '!', '?', '…': + return true + default: + return false + } +} + +// IsPausePunctuation checks for punctuation that should +// introduce a short pause during playback. +func IsPausePunctuation(word string) bool { + switch lastMeaningfulRune(word) { + case ',', ';', ':': + return true + default: + return false + } +} + +// RuneLength returns the number of Unicode characters in a word. +func RuneLength(word string) int { + return len([]rune(word)) +} + +// IsPunctuation reports whether the final meaningful character is punctuation. +func IsPunctuation(word string) bool { + return unicode.IsPunct(lastMeaningfulRune(word)) +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..5a0a009 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,64 @@ +package settings + +import "strings" + +const ( + MinReadingSpeed = 100 + MaxReadingSpeed = 1000 + + MinFontSize = 24 + MaxFontSize = 96 + + ThemeDark = "dark" + ThemeLight = "light" +) + +// Settings contains user preferences. +type Settings struct { + ReadingSpeed int `json:"readingSpeed"` + FontSize int `json:"fontSize"` + Theme string `json:"theme"` +} + +// Default returns the default application settings. +func Default() Settings { + return Settings{ + ReadingSpeed: 300, + FontSize: 48, + Theme: ThemeDark, + } +} + +// Validate checks whether all settings contain supported values. +func (s Settings) Validate() bool { + return s.ReadingSpeed >= MinReadingSpeed && + s.ReadingSpeed <= MaxReadingSpeed && + s.FontSize >= MinFontSize && + s.FontSize <= MaxFontSize && + (s.Theme == ThemeDark || s.Theme == ThemeLight) +} + +// Normalize returns settings with values restricted to supported options. +func (s Settings) Normalize() Settings { + defaults := Default() + + if s.ReadingSpeed < MinReadingSpeed { + s.ReadingSpeed = MinReadingSpeed + } else if s.ReadingSpeed > MaxReadingSpeed { + s.ReadingSpeed = MaxReadingSpeed + } + + if s.FontSize < MinFontSize { + s.FontSize = MinFontSize + } else if s.FontSize > MaxFontSize { + s.FontSize = MaxFontSize + } + + s.Theme = strings.ToLower(strings.TrimSpace(s.Theme)) + + if s.Theme != ThemeDark && s.Theme != ThemeLight { + s.Theme = defaults.Theme + } + + return s +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..0cb3163 --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,214 @@ +package settings + +import "testing" + +func TestDefault(t *testing.T) { + got := Default() + + if got.ReadingSpeed != 300 { + t.Errorf("ReadingSpeed = %d, want 300", got.ReadingSpeed) + } + + if got.FontSize != 48 { + t.Errorf("FontSize = %d, want 48", got.FontSize) + } + + if got.Theme != ThemeDark { + t.Errorf("Theme = %q, want %q", got.Theme, ThemeDark) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + settings Settings + want bool + }{ + { + name: "valid defaults", + settings: Default(), + want: true, + }, + { + name: "minimum values", + settings: Settings{ + ReadingSpeed: MinReadingSpeed, + FontSize: MinFontSize, + Theme: ThemeDark, + }, + want: true, + }, + { + name: "maximum values", + settings: Settings{ + ReadingSpeed: MaxReadingSpeed, + FontSize: MaxFontSize, + Theme: ThemeLight, + }, + want: true, + }, + { + name: "invalid reading speed", + settings: Settings{ + ReadingSpeed: 50, + FontSize: 48, + Theme: ThemeDark, + }, + want: false, + }, + { + name: "reading speed too high", + settings: Settings{ + ReadingSpeed: 1500, + FontSize: 48, + Theme: ThemeDark, + }, + want: false, + }, + { + name: "invalid font size", + settings: Settings{ + ReadingSpeed: 300, + FontSize: 10, + Theme: ThemeDark, + }, + want: false, + }, + { + name: "invalid theme", + settings: Settings{ + ReadingSpeed: 300, + FontSize: 48, + Theme: "purple", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.settings.Validate(); got != tt.want { + t.Errorf("Validate() = %t, want %t", got, tt.want) + } + }) + } +} +func TestNormalize(t *testing.T) { + tests := []struct { + name string + input Settings + want Settings + }{ + { + name: "valid settings unchanged", + input: Default(), + want: Default(), + }, + { + name: "speed below minimum clamped", + input: Settings{ + ReadingSpeed: 50, + FontSize: 48, + Theme: ThemeDark, + }, + want: Settings{ + ReadingSpeed: MinReadingSpeed, + FontSize: 48, + Theme: ThemeDark, + }, + }, + { + name: "speed above maximum clamped", + input: Settings{ + ReadingSpeed: 1200, + FontSize: 48, + Theme: ThemeDark, + }, + want: Settings{ + ReadingSpeed: MaxReadingSpeed, + FontSize: 48, + Theme: ThemeDark, + }, + }, + { + name: "font below minimum clamped", + input: Settings{ + ReadingSpeed: 400, + FontSize: 10, + Theme: ThemeLight, + }, + want: Settings{ + ReadingSpeed: 400, + FontSize: MinFontSize, + Theme: ThemeLight, + }, + }, + { + name: "font above maximum clamped", + input: Settings{ + ReadingSpeed: 400, + FontSize: 120, + Theme: ThemeLight, + }, + want: Settings{ + ReadingSpeed: 400, + FontSize: MaxFontSize, + Theme: ThemeLight, + }, + }, + { + name: "invalid theme replaced", + input: Settings{ + ReadingSpeed: 400, + FontSize: 48, + Theme: "purple", + }, + want: Settings{ + ReadingSpeed: 400, + FontSize: 48, + Theme: ThemeDark, + }, + }, + { + name: "theme whitespace and case normalized", + input: Settings{ + ReadingSpeed: 400, + FontSize: 48, + Theme: " LIGHT ", + }, + want: Settings{ + ReadingSpeed: 400, + FontSize: 48, + Theme: ThemeLight, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.input.Normalize() + + if got != tt.want { + t.Errorf("Normalize() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestNormalizeAlwaysProducesValidSettings(t *testing.T) { + tests := []Settings{ + {}, + {ReadingSpeed: -1, FontSize: -1, Theme: "invalid"}, + {ReadingSpeed: 2000, FontSize: 200, Theme: " BLUE "}, + {ReadingSpeed: 300, FontSize: 48, Theme: " LIGHT "}, + } + + for _, input := range tests { + got := input.Normalize() + + if !got.Validate() { + t.Errorf("Normalize(%+v) produced invalid settings: %+v", + input, got) + } + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..31c9ac5 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,66 @@ +package storage + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Encode serializes a value into JSON. +func Encode(value any) ([]byte, error) { + return json.MarshalIndent(value, "", " ") +} + +// Decode deserializes JSON into the supplied value. +func Decode(data []byte, target any) error { + return json.Unmarshal(data, target) +} + +// Save writes a value to a JSON file. +func Save(path string, value any) error { + data, err := Encode(value) + if err != nil { + return err + } + + dir := filepath.Dir(path) + + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + file, err := os.CreateTemp(dir, ".settings-*.tmp") + if err != nil { + return err + } + + tempPath := file.Name() + + defer os.Remove(tempPath) + + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + + if err := file.Sync(); err != nil { + file.Close() + return err + } + + if err := file.Close(); err != nil { + return err + } + + return os.Rename(tempPath, path) +} + +// Load reads a JSON file into the supplied target. +func Load(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return Decode(data, target) +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..cf5c987 --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,176 @@ +package storage + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type testSettings struct { + ReadingSpeed int `json:"readingSpeed"` + Theme string `json:"theme"` +} + +func TestEncode(t *testing.T) { + input := testSettings{ + ReadingSpeed: 300, + Theme: "dark", + } + + got, err := Encode(input) + if err != nil { + t.Fatalf("Encode() returned error: %v", err) + } + + var decoded testSettings + + if err := json.Unmarshal(got, &decoded); err != nil { + t.Fatalf("encoded JSON is invalid: %v", err) + } + + if decoded != input { + t.Errorf("decoded value = %+v, want %+v", decoded, input) + } + + if !strings.Contains(string(got), "\n") { + t.Error("Encode() should produce indented JSON") + } +} + +func TestEncodeNil(t *testing.T) { + got, err := Encode(nil) + if err != nil { + t.Fatalf("Encode(nil) returned error: %v", err) + } + + if string(got) != "null" { + t.Errorf("Encode(nil) = %q, want %q", got, "null") + } +} + +func TestEncodeUnsupportedValue(t *testing.T) { + _, err := Encode(make(chan int)) + + if err == nil { + t.Error("Encode() expected an error for unsupported value") + } +} + +func TestDecode(t *testing.T) { + input := []byte(`{ + "readingSpeed": 400, + "theme": "light" + }`) + + var got testSettings + + if err := Decode(input, &got); err != nil { + t.Fatalf("Decode() returned error: %v", err) + } + + expected := testSettings{ + ReadingSpeed: 400, + Theme: "light", + } + + if got != expected { + t.Errorf("Decode() = %+v, want %+v", got, expected) + } +} + +func TestDecodeInvalidJSON(t *testing.T) { + input := []byte(`{"readingSpeed": }`) + + var got testSettings + + if err := Decode(input, &got); err == nil { + t.Error("Decode() expected an error for invalid JSON") + } +} + +func TestDecodeNilTarget(t *testing.T) { + err := Decode([]byte(`{"readingSpeed":300}`), nil) + + if err == nil { + t.Error("Decode() expected an error for nil target") + } +} + +func TestDecodeEmptyJSON(t *testing.T) { + var got testSettings + + if err := Decode([]byte(``), &got); err == nil { + t.Error("Decode() expected an error for empty input") + } +} + +// NEW +func TestEncodeDecode(t *testing.T) { + original := testSettings{ + ReadingSpeed: 400, + Theme: "dark", + } + + data, err := Encode(original) + if err != nil { + t.Fatalf("Encode() error = %v", err) + } + + var decoded testSettings + + if err := Decode(data, &decoded); err != nil { + t.Fatalf("Decode() error = %v", err) + } + + if !reflect.DeepEqual(decoded, original) { + t.Errorf("decoded = %+v, want %+v", decoded, original) + } +} + +func TestSaveAndLoad(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings", "settings.json") + + original := testSettings{ + ReadingSpeed: 500, + Theme: "light", + } + + if err := Save(path, original); err != nil { + t.Fatalf("Save() error = %v", err) + } + + var loaded testSettings + + if err := Load(path, &loaded); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if !reflect.DeepEqual(loaded, original) { + t.Errorf("loaded = %+v, want %+v", loaded, original) + } +} + +func TestLoadMissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.json") + + var target testSettings + + err := Load(path, &target) + + if !os.IsNotExist(err) { + t.Errorf("Load() error = %v, want file-not-found error", err) + } +} + +func TestSaveInvalidValue(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + + err := Save(path, make(chan int)) + + if err == nil { + t.Fatal("Save() expected an error for an unsupported JSON value") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..213d69a --- /dev/null +++ b/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + + "fast_reader/app" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + application := app.NewApp() + + err := wails.Run(&options.App{ + Title: "Fast Reader Training", + Width: 1000, + Height: 700, + MinWidth: 700, + MinHeight: 500, + + AssetServer: &assetserver.Options{ + Assets: assets, + }, + + BackgroundColour: &options.RGBA{ + R: 18, + G: 18, + B: 24, + A: 1, + }, + + OnStartup: application.Startup, + Bind: []interface{}{ + application, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..c480da1 --- /dev/null +++ b/wails.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Fast Reader", + "outputfilename": "fast_reader", + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "auto" +}