First real commit. Setting up for full CI/CD and testing pipeline
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/wailsjs
|
||||||
|
|
||||||
|
build/bin
|
||||||
|
dist
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
||||||
220
.gitignore
vendored
220
.gitignore
vendored
@@ -1,207 +1,31 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[codz]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
# Distribution / packaging
|
# Wails build artifacts
|
||||||
.Python
|
/build/
|
||||||
build/
|
/frontend/wailsjs/
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# Frontend dependencies and build output
|
||||||
# Usually these files are written by a python script from a template
|
/frontend/node_modules/
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
/frontend/dist/
|
||||||
*.manifest
|
/frontend/package.json.md5
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
# Environment files
|
||||||
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
|
|
||||||
.env
|
.env
|
||||||
.envrc
|
.env.*
|
||||||
.venv
|
!.env.example
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
# IDE
|
||||||
.spyderproject
|
.idea/
|
||||||
.spyproject
|
.vscode/
|
||||||
|
|
||||||
# Rope project settings
|
# OS
|
||||||
.ropeproject
|
.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__/
|
|
||||||
|
|||||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -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
|
||||||
|
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -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 .
|
||||||
|
|
||||||
88
README.md
88
README.md
@@ -1,2 +1,90 @@
|
|||||||
# fast_reader
|
# fast_reader
|
||||||
|
|
||||||
Machine that aims to speed up your reading.
|
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
|
||||||
|
|||||||
121
app/app.go
Normal file
121
app/app.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
154
app/app_test.go
Normal file
154
app/app_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Fast_Reader</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1173
frontend/package-lock.json
generated
Normal file
1173
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
431
frontend/src/app.ts
Normal file
431
frontend/src/app.ts
Normal file
@@ -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<HTMLDivElement>("#app")!;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<main class="layout">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Fast Reader Training</h1>
|
||||||
|
<p class="subtitle">Focus on one word at a time.</p>
|
||||||
|
</div>
|
||||||
|
<span class="version">v0.1.0</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="input-section">
|
||||||
|
<label for="text-input">Your text</label>
|
||||||
|
<textarea id="text-input"
|
||||||
|
placeholder="Paste or type the text you want to read..."
|
||||||
|
></textarea>
|
||||||
|
<div class="input-footer">
|
||||||
|
<span id="word-count">0 words</span>
|
||||||
|
<button id="load-demo" class="secondary">Load demo text</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="reader">
|
||||||
|
<div class="reader-top">
|
||||||
|
<span id="reading-status">Ready to read</span>
|
||||||
|
<span id="current-position">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="word-display" id="word-display">
|
||||||
|
<span class="word-prefix"></span><span class="word-focus">F</span><span class="word-suffix">lashRead</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-track">
|
||||||
|
<div id="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button id="reset" class="secondary" title="Reset (R)">↺</button>
|
||||||
|
<button id="play" class="primary" title="Play / Pause (Space)">▶ Start reading</button>
|
||||||
|
<button id="skip" class="secondary" title="Next word (→)">→</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings">
|
||||||
|
<div class="setting-group">
|
||||||
|
<label for="speed">Reading speed</label>
|
||||||
|
<div class="speed-control">
|
||||||
|
<input
|
||||||
|
id="speed"
|
||||||
|
type="range"
|
||||||
|
min="100"
|
||||||
|
max="1000"
|
||||||
|
step="25"
|
||||||
|
value="300"
|
||||||
|
/>
|
||||||
|
<strong id="speed-value">300 WPM</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<label for="font-size">Word font size</label>
|
||||||
|
<div class="speed-control">
|
||||||
|
<input
|
||||||
|
id="font-size"
|
||||||
|
type="range"
|
||||||
|
min="24"
|
||||||
|
max="96"
|
||||||
|
step="2"
|
||||||
|
value="48"
|
||||||
|
/>
|
||||||
|
<strong id="font-size-value">48 px</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<label for="theme">Theme</label>
|
||||||
|
<select id="theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<span id="settings-status" role="status"></span>
|
||||||
|
<button id="reset-settings" class="secondary">
|
||||||
|
Reset settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>Space: Play/Pause</span>
|
||||||
|
<span>← →: Navigate</span>
|
||||||
|
<span>R: Reset</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textInput = document.querySelector<HTMLTextAreaElement>("#text-input")!;
|
||||||
|
const wordCount = document.querySelector<HTMLSpanElement>("#word-count")!;
|
||||||
|
const display = document.querySelector<HTMLDivElement>("#word-display")!;
|
||||||
|
const prefixEl = display.querySelector<HTMLSpanElement>(".word-prefix")!;
|
||||||
|
const focusEl = display.querySelector<HTMLSpanElement>(".word-focus")!;
|
||||||
|
const suffixEl = display.querySelector<HTMLSpanElement>(".word-suffix")!;
|
||||||
|
const playButton = document.querySelector<HTMLButtonElement>("#play")!;
|
||||||
|
const resetButton = document.querySelector<HTMLButtonElement>("#reset")!;
|
||||||
|
const skipButton = document.querySelector<HTMLButtonElement>("#skip")!;
|
||||||
|
const speedInput = document.querySelector<HTMLInputElement>("#speed")!;
|
||||||
|
const speedValue = document.querySelector<HTMLElement>("#speed-value")!;
|
||||||
|
const progressBar = document.querySelector<HTMLDivElement>("#progress-bar")!;
|
||||||
|
const positionLabel =
|
||||||
|
document.querySelector<HTMLSpanElement>("#current-position")!;
|
||||||
|
const statusLabel = document.querySelector<HTMLSpanElement>("#reading-status")!;
|
||||||
|
const demoButton = document.querySelector<HTMLButtonElement>("#load-demo")!;
|
||||||
|
const fontSizeInput = document.querySelector<HTMLInputElement>("#font-size")!;
|
||||||
|
|
||||||
|
const fontSizeValue = document.querySelector<HTMLElement>("#font-size-value")!;
|
||||||
|
|
||||||
|
const themeInput = document.querySelector<HTMLSelectElement>("#theme")!;
|
||||||
|
|
||||||
|
const resetSettingsButton =
|
||||||
|
document.querySelector<HTMLButtonElement>("#reset-settings")!;
|
||||||
|
|
||||||
|
const settingsStatus =
|
||||||
|
document.querySelector<HTMLSpanElement>("#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();
|
||||||
2
frontend/src/main.ts
Normal file
2
frontend/src/main.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "./styles/style.css";
|
||||||
|
import "./app";
|
||||||
306
frontend/src/styles/style.css
Normal file
306
frontend/src/styles/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
81
go.sum
Normal file
81
go.sum
Normal file
@@ -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=
|
||||||
39
internal/reader/engine.go
Normal file
39
internal/reader/engine.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
273
internal/reader/engine_test.go
Normal file
273
internal/reader/engine_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/reader/orp.go
Normal file
20
internal/reader/orp.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
98
internal/reader/tokenizer.go
Normal file
98
internal/reader/tokenizer.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
64
internal/settings/settings.go
Normal file
64
internal/settings/settings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
214
internal/settings/settings_test.go
Normal file
214
internal/settings/settings_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
internal/storage/storage.go
Normal file
66
internal/storage/storage.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
176
internal/storage/storage_test.go
Normal file
176
internal/storage/storage_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
main.go
Normal file
46
main.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
wails.json
Normal file
9
wails.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user