(Feat): Initial Commit, Termdoku

This commit is contained in:
2025-11-25 21:09:27 +00:00
commit f6933958e2
40 changed files with 5755 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.claude
.dist/
.github
.vscode
.qodo

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright © 2025 <bereckobrian / bereck-work>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

376
Makefile Normal file
View File

@@ -0,0 +1,376 @@
# Termdoku - Makefile for cross-platform builds
# Project configuration
BINARY_NAME=termdoku
VERSION?=1.0.1
BUILD_DIR=dist
CMD_DIR=cmd/termdoku
MAIN_FILE=$(CMD_DIR)/main.go
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOMOD=$(GOCMD) mod
GOVET=$(GOCMD) vet
GOFMT=$(GOCMD) fmt
LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION)"
CURRENT_OS:=$(shell go env GOOS)
CURRENT_ARCH:=$(shell go env GOARCH)
COLOR_RESET=\033[0m
COLOR_BOLD=\033[1m
COLOR_DIM=\033[2m
COLOR_RED=\033[31m
COLOR_GREEN=\033[32m
COLOR_YELLOW=\033[33m
COLOR_BLUE=\033[34m
COLOR_MAGENTA=\033[35m
COLOR_CYAN=\033[36m
COLOR_WHITE=\033[37m
SYMBOL_CHECK=
SYMBOL_CROSS=
SYMBOL_ARROW=
SYMBOL_STAR=
.PHONY: all build clean test vet fmt help
.PHONY: build-linux build-darwin build-windows build-freebsd
.PHONY: build-all build-linux-all build-darwin-all build-windows-all
.PHONY: install deps run coverage benchmark lint check watch dev
.PHONY: release version info size
all: clean build
## help: Display this help message
help:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Termdoku - Makefile Targets $(SYMBOL_STAR)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Main Targets:$(COLOR_RESET)"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) build$(COLOR_RESET) Build for current platform ($(COLOR_CYAN)$(CURRENT_OS)/$(CURRENT_ARCH)$(COLOR_RESET))"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) build-all$(COLOR_RESET) Build for all supported platforms"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) run$(COLOR_RESET) Run the application"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) dev$(COLOR_RESET) Run in development mode (with auto-rebuild)"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) test$(COLOR_RESET) Run all tests"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) clean$(COLOR_RESET) Remove build artifacts"
@echo " $(COLOR_GREEN)$(SYMBOL_CHECK) install$(COLOR_RESET) Install to GOPATH/bin"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Platform-Specific Builds:$(COLOR_RESET)"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-linux$(COLOR_RESET) Build for Linux amd64"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-linux-all$(COLOR_RESET) Build for all Linux architectures"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-darwin$(COLOR_RESET) Build for macOS amd64"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-darwin-all$(COLOR_RESET) Build for all macOS architectures"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-windows$(COLOR_RESET) Build for Windows amd64"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-windows-all$(COLOR_RESET) Build for all Windows architectures"
@echo " $(COLOR_YELLOW)$(SYMBOL_ARROW) build-freebsd$(COLOR_RESET) Build for FreeBSD amd64"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Development & Quality:$(COLOR_RESET)"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) deps$(COLOR_RESET) Download and tidy dependencies"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) fmt$(COLOR_RESET) Format code with gofmt"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) vet$(COLOR_RESET) Run go vet for code analysis"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) lint$(COLOR_RESET) Run golangci-lint (if installed)"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) check$(COLOR_RESET) Run fmt, vet, and lint"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) coverage$(COLOR_RESET) Run tests with coverage report"
@echo " $(COLOR_BLUE)$(SYMBOL_ARROW) benchmark$(COLOR_RESET) Run benchmarks"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_WHITE)$(SYMBOL_ARROW) Release & Info:$(COLOR_RESET)"
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) release$(COLOR_RESET) Create release builds with checksums"
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) version$(COLOR_RESET) Display version information"
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) info$(COLOR_RESET) Display project information"
@echo " $(COLOR_MAGENTA)$(SYMBOL_ARROW) size$(COLOR_RESET) Show binary sizes"
@echo ""
@echo "$(COLOR_DIM)Run 'make <target>' to execute a target$(COLOR_RESET)"
@echo ""
## build: Build binary for current platform
build:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)┌───────────────────────────────────────────────────────────┐$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_GREEN)Building $(BINARY_NAME)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)└───────────────────────────────────────────────────────────┘$(COLOR_RESET)"
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Target Platform:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_OS)/$(CURRENT_ARCH)$(COLOR_RESET)"
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Version:$(COLOR_RESET) $(COLOR_YELLOW)$(VERSION)$(COLOR_RESET)"
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Output:$(COLOR_RESET) $(COLOR_YELLOW)$(BUILD_DIR)/$(BINARY_NAME)$(COLOR_RESET)"
@echo ""
@mkdir -p $(BUILD_DIR)
@echo "$(COLOR_DIM)Compiling...$(COLOR_RESET)"
@$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_FILE)
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_GREEN)$(SYMBOL_CHECK) Build complete!$(COLOR_RESET)"
@ls -lh $(BUILD_DIR)/$(BINARY_NAME) | awk '{print "$(COLOR_DIM)Size: " $$5 "$(COLOR_RESET)"}'
@echo ""
## run: Run the application
run:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Running $(BINARY_NAME)...$(COLOR_RESET)"
@echo ""
@$(GOCMD) run $(MAIN_FILE)
## clean: Remove build artifacts
clean:
@echo ""
@echo "$(COLOR_YELLOW)$(SYMBOL_ARROW) Cleaning build artifacts...$(COLOR_RESET)"
@$(GOCLEAN)
@rm -rf $(BUILD_DIR)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Clean complete$(COLOR_RESET)"
@echo ""
## test: Run tests
test:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_BLUE)$(SYMBOL_ARROW) Running tests...$(COLOR_RESET)"
@echo ""
@$(GOTEST) -v ./...
@echo ""
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Tests complete$(COLOR_RESET)"
@echo ""
## vet: Run go vet
vet:
@echo ""
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Running go vet...$(COLOR_RESET)"
@$(GOVET) ./...
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Vet complete$(COLOR_RESET)"
@echo ""
## fmt: Format code
fmt:
@echo ""
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Formatting code...$(COLOR_RESET)"
@$(GOFMT) ./...
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Format complete$(COLOR_RESET)"
@echo ""
## deps: Download dependencies
deps:
@echo ""
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Downloading dependencies...$(COLOR_RESET)"
@$(GOMOD) download
@$(GOMOD) tidy
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Dependencies updated$(COLOR_RESET)"
@echo ""
## install: Install binary to GOPATH/bin
install:
@echo ""
@echo "$(COLOR_GREEN)$(SYMBOL_ARROW) Installing $(BINARY_NAME)...$(COLOR_RESET)"
@$(GOCMD) install $(LDFLAGS) $(CMD_DIR)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Installed to $(COLOR_CYAN)$(shell go env GOPATH)/bin/$(BINARY_NAME)$(COLOR_RESET)"
@echo ""
# ============================================================================
# Platform-specific builds
# ============================================================================
## build-linux: Build for Linux amd64
build-linux:
@echo ""
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)Linux amd64$(COLOR_RESET)..."
@mkdir -p $(BUILD_DIR)
@GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-linux-amd64$(COLOR_RESET)"
@echo ""
## build-linux-all: Build for all Linux architectures
build-linux-all:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all Linux architectures...$(COLOR_RESET)"
@mkdir -p $(BUILD_DIR)
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
@GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_FILE)
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
@GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 $(MAIN_FILE)
@echo " $(COLOR_DIM)Building 386...$(COLOR_RESET)"
@GOOS=linux GOARCH=386 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-386 $(MAIN_FILE)
@echo " $(COLOR_DIM)Building arm...$(COLOR_RESET)"
@GOOS=linux GOARCH=arm $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all Linux binaries$(COLOR_RESET)"
@echo ""
## build-darwin: Build for macOS amd64
build-darwin:
@echo ""
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)macOS amd64$(COLOR_RESET)..."
@mkdir -p $(BUILD_DIR)
@GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64$(COLOR_RESET)"
@echo ""
## build-darwin-all: Build for all macOS architectures
build-darwin-all:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all macOS architectures...$(COLOR_RESET)"
@mkdir -p $(BUILD_DIR)
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
@GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_FILE)
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
@GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all macOS binaries$(COLOR_RESET)"
@echo ""
## build-windows: Build for Windows amd64
build-windows:
@echo ""
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)Windows amd64$(COLOR_RESET)..."
@mkdir -p $(BUILD_DIR)
@GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe$(COLOR_RESET)"
@echo ""
## build-windows-all: Build for all Windows architectures
build-windows-all:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(SYMBOL_ARROW) Building for all Windows architectures...$(COLOR_RESET)"
@mkdir -p $(BUILD_DIR)
@echo " $(COLOR_DIM)Building amd64...$(COLOR_RESET)"
@GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_FILE)
@echo " $(COLOR_DIM)Building arm64...$(COLOR_RESET)"
@GOOS=windows GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-arm64.exe $(MAIN_FILE)
@echo " $(COLOR_DIM)Building 386...$(COLOR_RESET)"
@GOOS=windows GOARCH=386 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-386.exe $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built all Windows binaries$(COLOR_RESET)"
@echo ""
## build-freebsd: Build for FreeBSD amd64
build-freebsd:
@echo ""
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Building for $(COLOR_YELLOW)FreeBSD amd64$(COLOR_RESET)..."
@mkdir -p $(BUILD_DIR)
@GOOS=freebsd GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 $(MAIN_FILE)
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Built: $(COLOR_DIM)$(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64$(COLOR_RESET)"
@echo ""
## build-all: Build for all major platforms
build-all: build-linux-all build-darwin-all build-windows-all build-freebsd
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_GREEN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_GREEN)$(COLOR_RESET) $(COLOR_BOLD)$(SYMBOL_CHECK) All builds complete!$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_GREEN)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_GREEN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Build Artifacts:$(COLOR_RESET)"
@ls -lh $(BUILD_DIR) | tail -n +2 | awk '{print " $(COLOR_YELLOW)" $$9 "$(COLOR_RESET) $(COLOR_DIM)(" $$5 ")$(COLOR_RESET)"}'
@echo ""
# ============================================================================
# Release targets
# ============================================================================
## release: Create release builds with checksums
release: clean build-all
@echo ""
@echo "$(COLOR_MAGENTA)$(SYMBOL_ARROW) Generating checksums...$(COLOR_RESET)"
@cd $(BUILD_DIR) && sha256sum * > checksums.txt
@echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Release build complete with checksums$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Checksums:$(COLOR_RESET)"
@cat $(BUILD_DIR)/checksums.txt | awk '{print " $(COLOR_DIM)" $$1 "$(COLOR_RESET) $(COLOR_YELLOW)" $$2 "$(COLOR_RESET)"}'
@echo ""
# ============================================================================
# Development & Quality targets
# ============================================================================
## coverage: Run tests with coverage report
coverage:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_BLUE)$(SYMBOL_ARROW) Running tests with coverage...$(COLOR_RESET)"
@echo ""
@$(GOTEST) -coverprofile=coverage.out ./...
@echo ""
@echo "$(COLOR_CYAN)$(SYMBOL_ARROW) Coverage Summary:$(COLOR_RESET)"
@$(GOCMD) tool cover -func=coverage.out | tail -n 1
@echo ""
@echo "$(COLOR_DIM)Run 'go tool cover -html=coverage.out' to view detailed coverage$(COLOR_RESET)"
@echo ""
## benchmark: Run benchmarks
benchmark:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_YELLOW)$(SYMBOL_ARROW) Running benchmarks...$(COLOR_RESET)"
@echo ""
@$(GOTEST) -bench=. -benchmem ./...
@echo ""
## lint: Run golangci-lint
lint:
@echo ""
@echo "$(COLOR_BLUE)$(SYMBOL_ARROW) Running linter...$(COLOR_RESET)"
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run ./...; \
echo "$(COLOR_GREEN)$(SYMBOL_CHECK) Lint complete$(COLOR_RESET)"; \
else \
echo "$(COLOR_YELLOW)$(SYMBOL_CROSS) golangci-lint not installed$(COLOR_RESET)"; \
echo "$(COLOR_DIM)Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest$(COLOR_RESET)"; \
fi
@echo ""
## check: Run fmt, vet, and lint
check: fmt vet lint
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_GREEN)$(SYMBOL_CHECK) All checks complete!$(COLOR_RESET)"
@echo ""
## dev: Run in development mode (auto-rebuild on changes)
dev:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_MAGENTA)$(SYMBOL_STAR) Development Mode$(COLOR_RESET)"
@echo "$(COLOR_DIM)Watching for changes... (Ctrl+C to stop)$(COLOR_RESET)"
@echo ""
@if command -v air >/dev/null 2>&1; then \
air; \
else \
echo "$(COLOR_YELLOW)$(SYMBOL_CROSS) 'air' not installed, running without hot reload$(COLOR_RESET)"; \
echo "$(COLOR_DIM)Install with: go install github.com/cosmtrek/air@latest$(COLOR_RESET)"; \
echo ""; \
$(GOCMD) run $(MAIN_FILE); \
fi
# ============================================================================
# Info targets
# ============================================================================
## version: Display version information
version:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Version Information:$(COLOR_RESET)"
@echo " $(COLOR_YELLOW)$(BINARY_NAME)$(COLOR_RESET) version $(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
@echo " Go version: $(COLOR_DIM)$(shell go version)$(COLOR_RESET)"
@echo ""
## info: Display project information
info:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╔═══════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_MAGENTA)Project Information$(COLOR_RESET) $(COLOR_BOLD)$(COLOR_CYAN)$(COLOR_RESET)"
@echo "$(COLOR_BOLD)$(COLOR_CYAN)╚═══════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Project:$(COLOR_RESET) $(COLOR_YELLOW)$(BINARY_NAME)$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Version:$(COLOR_RESET) $(COLOR_GREEN)$(VERSION)$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Build Dir:$(COLOR_RESET) $(COLOR_DIM)$(BUILD_DIR)$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Main File:$(COLOR_RESET) $(COLOR_DIM)$(MAIN_FILE)$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Go Version:$(COLOR_RESET) $(COLOR_DIM)$(shell go version | awk '{print $$3}')$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Current OS:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_OS)$(COLOR_RESET)"
@echo " $(COLOR_CYAN)$(SYMBOL_ARROW) Current Arch:$(COLOR_RESET) $(COLOR_YELLOW)$(CURRENT_ARCH)$(COLOR_RESET)"
@echo ""
## size: Show binary sizes
size:
@echo ""
@echo "$(COLOR_BOLD)$(COLOR_CYAN)Binary Sizes:$(COLOR_RESET)"
@if [ -d "$(BUILD_DIR)" ] && [ -n "$$(ls -A $(BUILD_DIR) 2>/dev/null)" ]; then \
for file in $(BUILD_DIR)/*; do \
if [ -f "$$file" ] && [ "$$(basename $$file)" != "checksums.txt" ]; then \
size=$$(ls -lh "$$file" | awk '{print $$5}'); \
name=$$(basename "$$file"); \
echo " $(COLOR_YELLOW)$$name$(COLOR_RESET) $(COLOR_DIM)- $$size$(COLOR_RESET)"; \
fi; \
done; \
else \
echo " $(COLOR_RED)$(SYMBOL_CROSS) No binaries found. Run 'make build' first.$(COLOR_RESET)"; \
fi
@echo ""

254
README.md Normal file
View File

@@ -0,0 +1,254 @@
# 🎮 Termdoku
**A beautiful, feature-rich terminal-based Sudoku game.**
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Go Version](https://img.shields.io/badge/go-%3E%3D1.21-00ADD8.svg)
## ✨ Features
### 🎯 Core Gameplay
- **Multiple Difficulty Levels**: Easy, Normal, Hard, Expert, and Lunatic
- **Daily Puzzles**: New puzzle every day with consistent seeds
- **Smart Hint System**: Intelligent hints using solving techniques (Naked Singles, Hidden Singles)
- **Auto-Check Mode**: Optional real-time validation of moves
- **Timer**: Track your solving time
- **Undo/Redo**: Full move history support
### 🧩 Advanced Puzzle Generation
- **Unique Solution Guarantee**: All puzzles have exactly one solution
- **Symmetry Patterns**: Support for rotational, vertical, and horizontal symmetry
- **Puzzle Analysis**: Comprehensive difficulty rating and analysis
- **Custom Generation**: Create puzzles with custom parameters
- **Pattern Detection**: Identifies required solving techniques
- **Optimized Solver**: Uses most-constrained-first heuristic for fast solving
### 📊 Statistics & Progress
- **Comprehensive Stats**: Track games played, win rate, streaks, and more
- **Best Times**: Personal records for each difficulty level
- **Leaderboard**: Top 5 fastest times per difficulty
- **Daily History**: Track your daily puzzle completions
- **Recent Games**: View your last 50 games
### 🏆 Achievement System
- **8 Unique Achievements**: Unlock achievements as you play
- **Progress Tracking**: See your progress toward each achievement
- **Visual Indicators**: Beautiful UI showing locked/unlocked status
- **Achievements Include**:
- 🏆 First Victory - Complete your first puzzle
- ⚡ Speed Demon - Complete an Easy puzzle in under 3 minutes
- 💎 Perfectionist - Complete a puzzle without using hints
- 🔥 Streak Master - Achieve a 5-day streak
- 💯 Century Club - Complete 100 puzzles
- 🌙 Lunatic Legend - Complete 10 Lunatic puzzles
- 📅 Daily Devotee - Complete 30 daily puzzles
- ✨ Flawless - Complete a Hard puzzle without auto-check
### 🎨 Customization
- **Theme Support**: Customizable color themes
- **Adaptive Colors**: Dynamic color schemes for different UI elements
- **Gradient Effects**: Beautiful gradient text and borders
- **Configurable Settings**: Auto-check, timer, and more
### 💾 Data Persistence
- **SQLite Database**: Robust data storage for games and achievements
- **JSON Stats**: Human-readable statistics files
- **Save Games**: Resume puzzles later
- **Cross-Session**: All data persists between sessions
## 📦 Installation
### Prerequisites
- Go 1.21 or higher
### Build from Source
```bash
# Clone the repository
git clone https://gitlab.com/bereckobrian/termdoku.git
cd termdoku
make build
# Run
./termdoku
```
### Using Go Install
```bash
go install github.com/bereckobrian/termdoku/cmd/termdoku@latest
```
## 🎮 How to Play
### Controls
#### Menu Navigation
- `↑/↓` or `k/j` - Navigate menu items
- `←/→` or `h/l` - Navigate menu items (horizontal)
- `Enter` - Select menu item
- `a` - Toggle auto-check mode
- `t` - Toggle timer
- `q` - Quit
#### In-Game
- `Arrow Keys` or `h/j/k/l` - Move cursor
- `1-9` - Enter number
- `0` or `Backspace` - Clear cell
- `n` - Toggle notes mode
- `u` - Undo move
- `r` - Redo move
- `?` or `h` - Get hint
- `c` - Check solution
- `s` - Solve puzzle
- `m` or `Esc` - Return to menu
- `q` - Quit game
### Game Modes
1. **Easy** - 38 empty cells, perfect for beginners
2. **Normal** - 46 empty cells, balanced difficulty
3. **Hard** - 52 empty cells, challenging puzzles
4. **Expert** - 56 empty cells, very difficult
5. **Lunatic** - 60 empty cells, extreme challenge
6. **Daily** - One puzzle per day, same for everyone
## 🏗️ Project Structure
```
termdoku/
├── cmd/
│ └── termdoku/ # Main application entry point
├── internal/
│ ├── achievements/ # Achievement system
│ ├── config/ # Configuration management
│ ├── database/ # SQLite database layer
│ ├── game/ # Game board logic
│ ├── generator/ # Puzzle generation engine
│ │ ├── api.go # Grid analysis & validation
│ │ ├── core.go # Generation algorithms
│ │ ├── generator.go # Main generation interface
│ │ ├── benchmark.go # Performance benchmarking
│ │ └── utils.go # Utility functions
│ ├── savegame/ # Save/load game state
│ ├── solver/ # Sudoku solver
│ ├── stats/ # Statistics tracking
│ ├── theme/ # Theme system
│ └── ui/ # Terminal UI (Bubble Tea)
├── go.mod
├── go.sum
├── LICENSE
├── Makefile
└── README.md
```
## 🔧 Advanced Features
### Puzzle Generation API
The generator package provides advanced puzzle generation capabilities:
```go
import "termdoku/internal/generator"
// Generate a puzzle with specific difficulty
puzzle, err := generator.Generate(generator.Hard, "seed")
// Generate with symmetry
puzzle, err := generator.GenerateWithSymmetry(
generator.Normal,
"seed",
generator.SymmetryRotational180,
)
// Generate with analysis
result, err := generator.GenerateWithAnalysis(generator.Expert, "seed")
fmt.Printf("Difficulty Rating: %d/100\n", generator.RatePuzzle(result.Puzzle))
// Analyze a puzzle
analysis := puzzle.Analyze()
fmt.Printf("Symmetry: %s\n", analysis.SymmetryType)
fmt.Printf("Techniques: %v\n", analysis.SolvingTechniques)
```
### Puzzle Analysis
Every puzzle can be analyzed for:
- **Filled/Empty cells count**
- **Candidate distribution** (min, max, average)
- **Solution uniqueness**
- **Estimated difficulty**
- **Required solving techniques**
- **Symmetry pattern**
- **Complexity score**
### Benchmarking
```go
import "termdoku/internal/generator"
// Benchmark generation performance
result := generator.BenchmarkGeneration(generator.Hard, 10)
fmt.Println(result.String())
// Compare generation methods
standard, symmetric := generator.CompareGenerationMethods(generator.Normal, 10)
```
## 📊 Data Storage
Termdoku stores data in `~/.termdoku/`:
- `termdoku.db` - SQLite database (games, achievements, stats)
- `stats.json` - Statistics backup (JSON format)
- `achievements.json` - Achievements backup (JSON format)
- `config.json` - User configuration
- `themes/` - Custom theme files
## 🎨 Themes
Create custom themes in `~/.termdoku/themes/`:
```json
{
"name": "My Theme",
"palette": {
"background": "#1a1b26",
"foreground": "#c0caf5",
"accent": "#7aa2f7",
"error": "#f7768e",
"success": "#9ece6a",
"warning": "#e0af68"
}
}
```
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) - A powerful TUI framework
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Style definitions for nice terminal layouts

30
cmd/termdoku/main.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"os"
"termdoku/internal/config"
"termdoku/internal/theme"
"termdoku/internal/ui"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
// Legacy flags kept but ignored when menu is used
_ = flag.Bool("daily", false, "Generate daily puzzle")
_ = flag.String("difficulty", "normal", "Difficulty: easy|normal|hard|lunatic")
flag.Parse()
// Create example theme on first run (if it doesn't exist)
_ = theme.CreateExampleTheme()
cfg, _ := config.Load()
app := ui.NewApp(cfg)
if _, err := tea.NewProgram(app, tea.WithAltScreen()).Run(); err != nil {
fmt.Fprintln(os.Stderr, "ui error:", err)
os.Exit(1)
}
}

10
dist/checksums.txt vendored Normal file
View File

@@ -0,0 +1,10 @@
fad9edb554c86030293282ef774dc440c02b93d91753412c1de8b5f5eceb83ea termdoku-darwin-amd64
381420f2d134c108f4fdc08fc39bb768b68ddf9043ea272cdb58bff0e1cdb129 termdoku-darwin-arm64
d6ce30e6745c2d6bea08312390c4a743220ee5139175437b66fdb1e64f01c55d termdoku-freebsd-amd64
24537c007931dd72841544c898a4d1b8a95315bea3802a2fb6a66f7802a8610a termdoku-linux-386
127b2b48f6099fac1e6a37decaa567c06523abeb4b884ea581adfa9918a9d35a termdoku-linux-amd64
345f50a633f241149b36d4bb4b9667c04ba77ac834c25d27081ebf616262dfd5 termdoku-linux-arm
de5fb97cb2268cb49294ec2ab3e655049b809b16ce44b46df284a2a92c293565 termdoku-linux-arm64
b288e4acbf0d32e0cbd6c7f017c48a59d7b095d42e95f02d249c18aee2e8b482 termdoku-windows-386.exe
93c16a9d36d4f0ce27c4016e9a4d1af9cc3bd61cc16a5c4f936b9d5bac0cdd77 termdoku-windows-amd64.exe
5bf5d568e947defaab09277d64f75c5428d093b0b7308aa49e2b038d2edac502 termdoku-windows-arm64.exe

BIN
dist/termdoku-darwin-amd64 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-darwin-arm64 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-freebsd-amd64 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-linux-386 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-linux-amd64 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-linux-arm vendored Executable file

Binary file not shown.

BIN
dist/termdoku-linux-arm64 vendored Executable file

Binary file not shown.

BIN
dist/termdoku-windows-386.exe vendored Executable file

Binary file not shown.

BIN
dist/termdoku-windows-amd64.exe vendored Executable file

Binary file not shown.

BIN
dist/termdoku-windows-arm64.exe vendored Executable file

Binary file not shown.

43
go.mod Normal file
View File

@@ -0,0 +1,43 @@
module termdoku
go 1.24.0
toolchain go1.24.6
require (
github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/muesli/termenv v0.16.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

93
go.sum Normal file
View File

@@ -0,0 +1,93 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,162 @@
package achievements
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
)
type Achievement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Unlocked bool `json:"unlocked"`
Progress int `json:"progress"`
Target int `json:"target"`
}
type Manager struct {
Achievements map[string]*Achievement `json:"achievements"`
NewUnlocks []string `json:"-"`
}
func New() *Manager {
return &Manager{
Achievements: map[string]*Achievement{
"first_win": {
ID: "first_win",
Name: "First Victory",
Description: "Complete your first puzzle",
Icon: "🏆",
Target: 1,
},
"speed_demon": {
ID: "speed_demon",
Name: "Speed Demon",
Description: "Complete an Easy puzzle in under 3 minutes",
Icon: "⚡",
Target: 1,
},
"perfectionist": {
ID: "perfectionist",
Name: "Perfectionist",
Description: "Complete a puzzle without using hints",
Icon: "💎",
Target: 1,
},
"streak_master": {
ID: "streak_master",
Name: "Streak Master",
Description: "Achieve a 5-day streak",
Icon: "🔥",
Target: 5,
},
"century": {
ID: "century",
Name: "Century Club",
Description: "Complete 100 puzzles",
Icon: "💯",
Target: 100,
},
"lunatic_legend": {
ID: "lunatic_legend",
Name: "Lunatic Legend",
Description: "Complete 10 Lunatic puzzles",
Icon: "🌙",
Target: 10,
},
"daily_devotee": {
ID: "daily_devotee",
Name: "Daily Devotee",
Description: "Complete 30 daily puzzles",
Icon: "📅",
Target: 30,
},
"no_mistakes": {
ID: "no_mistakes",
Name: "Flawless",
Description: "Complete a Hard puzzle without auto-check",
Icon: "✨",
Target: 1,
},
},
NewUnlocks: []string{},
}
}
func path() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".termdoku", "achievements.json"), nil
}
func Load() (*Manager, error) {
m := New()
p, err := path()
if err != nil {
return m, err
}
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return m, nil
}
return m, err
}
if err := json.Unmarshal(b, m); err != nil {
return m, err
}
return m, nil
}
func Save(m *Manager) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
func (m *Manager) CheckAndUnlock(id string, progress int) bool {
if ach, ok := m.Achievements[id]; ok {
if !ach.Unlocked {
ach.Progress = progress
if ach.Progress >= ach.Target {
ach.Unlocked = true
m.NewUnlocks = append(m.NewUnlocks, id)
return true
}
}
}
return false
}
func (m *Manager) GetUnlockedCount() int {
count := 0
for _, ach := range m.Achievements {
if ach.Unlocked {
count++
}
}
return count
}
func (m *Manager) GetTotalCount() int {
return len(m.Achievements)
}
func (m *Manager) ClearNewUnlocks() {
m.NewUnlocks = []string{}
}

68
internal/config/config.go Normal file
View File

@@ -0,0 +1,68 @@
package config
import (
"errors"
"io/fs"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Theme string `yaml:"theme"`
AutoCheck bool `yaml:"autoCheck"`
TimerEnabled bool `yaml:"timerEnabled"`
Bindings map[string][]string `yaml:"bindings"`
}
func Default() Config {
return Config{
Theme: "dark",
AutoCheck: true,
TimerEnabled: true,
Bindings: map[string][]string{},
}
}
func path() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".termdoku", "config.yaml"), nil
}
func Load() (Config, error) {
cfg := Default()
p, err := path()
if err != nil {
return cfg, err
}
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
return cfg, err
}
if err := yaml.Unmarshal(b, &cfg); err != nil {
return cfg, err
}
return cfg, nil
}
func Save(cfg Config) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}

View File

@@ -0,0 +1,327 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
type DB struct {
conn *sql.DB
}
func Open() (*DB, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dbDir := filepath.Join(home, ".termdoku")
if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err
}
dbPath := filepath.Join(dbDir, "termdoku.db")
conn, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.initialize(); err != nil {
conn.Close()
return nil, err
}
return db, nil
}
func (db *DB) Close() error {
if db.conn != nil {
return db.conn.Close()
}
return nil
}
func (db *DB) initialize() error {
schema := `
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
difficulty TEXT NOT NULL,
completed BOOLEAN NOT NULL,
time_seconds INTEGER NOT NULL,
hints_used INTEGER NOT NULL,
date DATETIME NOT NULL,
is_daily BOOLEAN NOT NULL,
daily_seed TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS achievements (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
unlocked BOOLEAN NOT NULL DEFAULT 0,
progress INTEGER NOT NULL DEFAULT 0,
target INTEGER NOT NULL,
unlocked_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_games INTEGER NOT NULL DEFAULT 0,
completed_games INTEGER NOT NULL DEFAULT 0,
current_streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
last_played_date TEXT,
hints_used INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS best_times (
difficulty TEXT PRIMARY KEY,
time_seconds INTEGER NOT NULL,
hints_used INTEGER NOT NULL,
achieved_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_games_difficulty ON games(difficulty);
CREATE INDEX IF NOT EXISTS idx_games_date ON games(date);
CREATE INDEX IF NOT EXISTS idx_games_daily_seed ON games(daily_seed);
`
_, err := db.conn.Exec(schema)
if err != nil {
return fmt.Errorf("failed to initialize schema: %w", err)
}
var count int
err = db.conn.QueryRow("SELECT COUNT(*) FROM stats").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = db.conn.Exec("INSERT INTO stats (id) VALUES (1)")
if err != nil {
return err
}
}
return nil
}
type GameRecord struct {
ID int
Difficulty string
Completed bool
TimeSeconds int
HintsUsed int
Date time.Time
IsDaily bool
DailySeed string
}
func (db *DB) SaveGame(record GameRecord) error {
query := `
INSERT INTO games (difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
_, err := db.conn.Exec(query,
record.Difficulty,
record.Completed,
record.TimeSeconds,
record.HintsUsed,
record.Date,
record.IsDaily,
record.DailySeed,
)
return err
}
func (db *DB) GetLeaderboard(difficulty string, limit int) ([]GameRecord, error) {
query := `
SELECT id, difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed
FROM games
WHERE difficulty = ? AND completed = 1
ORDER BY time_seconds ASC, hints_used ASC
LIMIT ?
`
rows, err := db.conn.Query(query, difficulty, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []GameRecord
for rows.Next() {
var r GameRecord
var dailySeed sql.NullString
err := rows.Scan(&r.ID, &r.Difficulty, &r.Completed, &r.TimeSeconds, &r.HintsUsed, &r.Date, &r.IsDaily, &dailySeed)
if err != nil {
return nil, err
}
if dailySeed.Valid {
r.DailySeed = dailySeed.String
}
records = append(records, r)
}
return records, rows.Err()
}
type Stats struct {
TotalGames int
CompletedGames int
CurrentStreak int
BestStreak int
LastPlayedDate string
HintsUsed int
}
func (db *DB) GetStats() (*Stats, error) {
query := "SELECT total_games, completed_games, current_streak, best_streak, last_played_date, hints_used FROM stats WHERE id = 1"
var stats Stats
var lastPlayed sql.NullString
err := db.conn.QueryRow(query).Scan(
&stats.TotalGames,
&stats.CompletedGames,
&stats.CurrentStreak,
&stats.BestStreak,
&lastPlayed,
&stats.HintsUsed,
)
if err != nil {
return nil, err
}
if lastPlayed.Valid {
stats.LastPlayedDate = lastPlayed.String
}
return &stats, nil
}
func (db *DB) UpdateStats(stats *Stats) error {
query := `
UPDATE stats
SET total_games = ?, completed_games = ?, current_streak = ?,
best_streak = ?, last_played_date = ?, hints_used = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`
_, err := db.conn.Exec(query,
stats.TotalGames,
stats.CompletedGames,
stats.CurrentStreak,
stats.BestStreak,
stats.LastPlayedDate,
stats.HintsUsed,
)
return err
}
type Achievement struct {
ID string
Name string
Description string
Icon string
Unlocked bool
Progress int
Target int
UnlockedAt *time.Time
}
func (db *DB) GetAchievements() (map[string]*Achievement, error) {
query := "SELECT id, name, description, icon, unlocked, progress, target, unlocked_at FROM achievements"
rows, err := db.conn.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
achievements := make(map[string]*Achievement)
for rows.Next() {
var a Achievement
var unlockedAt sql.NullTime
err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Icon, &a.Unlocked, &a.Progress, &a.Target, &unlockedAt)
if err != nil {
return nil, err
}
if unlockedAt.Valid {
a.UnlockedAt = &unlockedAt.Time
}
achievements[a.ID] = &a
}
return achievements, rows.Err()
}
func (db *DB) SaveAchievement(ach *Achievement) error {
query := `
INSERT OR REPLACE INTO achievements (id, name, description, icon, unlocked, progress, target, unlocked_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`
_, err := db.conn.Exec(query,
ach.ID,
ach.Name,
ach.Description,
ach.Icon,
ach.Unlocked,
ach.Progress,
ach.Target,
ach.UnlockedAt,
)
return err
}
func (db *DB) GetBestTime(difficulty string) (int, bool, error) {
query := "SELECT time_seconds FROM best_times WHERE difficulty = ?"
var timeSeconds int
err := db.conn.QueryRow(query, difficulty).Scan(&timeSeconds)
if err == sql.ErrNoRows {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return timeSeconds, true, nil
}
func (db *DB) UpdateBestTime(difficulty string, timeSeconds int, hintsUsed int) error {
query := `
INSERT OR REPLACE INTO best_times (difficulty, time_seconds, hints_used, achieved_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`
_, err := db.conn.Exec(query, difficulty, timeSeconds, hintsUsed)
return err
}
func (db *DB) GetRecentGames(limit int) ([]GameRecord, error) {
query := `
SELECT id, difficulty, completed, time_seconds, hints_used, date, is_daily, daily_seed
FROM games
ORDER BY date DESC
LIMIT ?
`
rows, err := db.conn.Query(query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []GameRecord
for rows.Next() {
var r GameRecord
var dailySeed sql.NullString
err := rows.Scan(&r.ID, &r.Difficulty, &r.Completed, &r.TimeSeconds, &r.HintsUsed, &r.Date, &r.IsDaily, &dailySeed)
if err != nil {
return nil, err
}
if dailySeed.Valid {
r.DailySeed = dailySeed.String
}
records = append(records, r)
}
return records, rows.Err()
}

149
internal/game/board.go Normal file
View File

@@ -0,0 +1,149 @@
package game
import (
"time"
)
type Grid [9][9]uint8
type Move struct {
Row int
Col int
Prev uint8
Next uint8
At time.Time
}
type Board struct {
Given [9][9]bool
Values Grid
}
func NewBoardFromPuzzle(p Grid) Board {
var b Board
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
v := p[r][c]
if v != 0 {
b.Given[r][c] = true
}
b.Values[r][c] = v
}
}
return b
}
func (b *Board) IsGiven(row, col int) bool { return b.Given[row][col] }
func (b *Board) SetValue(row, col int, v uint8) (prev uint8, ok bool) {
if b.Given[row][col] {
return b.Values[row][col], false
}
prev = b.Values[row][col]
b.Values[row][col] = v
return prev, true
}
func InBounds(row, col int) bool { return row >= 0 && row < 9 && col >= 0 && col < 9 }
// DuplicateMap marks cells that duplicate the selected cell's value across row/col/box.
func DuplicateMap(g Grid, selRow, selCol int) [9][9]bool {
var dup [9][9]bool
v := g[selRow][selCol]
if v == 0 {
return dup
}
for i := 0; i < 9; i++ {
if g[selRow][i] == v && i != selCol {
dup[selRow][i] = true
}
if g[i][selCol] == v && i != selRow {
dup[i][selCol] = true
}
}
r0 := (selRow / 3) * 3
c0 := (selCol / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
if (r != selRow || c != selCol) && g[r][c] == v {
dup[r][c] = true
}
}
}
return dup
}
// DuplicateMapAll marks any duplicates in rows, columns, or 3x3 blocks across the entire grid.
func DuplicateMapAll(g Grid) [9][9]bool {
var dup [9][9]bool
// rows
for r := 0; r < 9; r++ {
count := map[uint8]int{}
for c := 0; c < 9; c++ {
v := g[r][c]
if v != 0 {
count[v]++
}
}
for c := 0; c < 9; c++ {
v := g[r][c]
if v != 0 && count[v] > 1 {
dup[r][c] = true
}
}
}
// cols
for c := 0; c < 9; c++ {
count := map[uint8]int{}
for r := 0; r < 9; r++ {
v := g[r][c]
if v != 0 {
count[v]++
}
}
for r := 0; r < 9; r++ {
v := g[r][c]
if v != 0 && count[v] > 1 {
dup[r][c] = true
}
}
}
// blocks
for br := 0; br < 3; br++ {
for bc := 0; bc < 3; bc++ {
count := map[uint8]int{}
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v != 0 {
count[v]++
}
}
}
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v != 0 && count[v] > 1 {
dup[r][c] = true
}
}
}
}
}
return dup
}
// ConflictMap marks cells that violate Sudoku constraints (duplicates), excluding givens.
func ConflictMap(values Grid, given [9][9]bool) [9][9]bool {
all := DuplicateMapAll(values)
var bad [9][9]bool
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if given[r][c] {
continue
}
bad[r][c] = all[r][c]
}
}
return bad
}

484
internal/generator/api.go Normal file
View File

@@ -0,0 +1,484 @@
package generator
import (
"slices"
"termdoku/internal/solver"
"time"
)
// Puzzle returns a copy of the grid suitable for rendering: 0 means blank.
func (g Grid) Puzzle() [9][9]uint8 {
return g
}
// Clone creates a deep copy of the grid.
func (g Grid) Clone() Grid {
var clone Grid
for r := range 9 {
for c := range 9 {
clone[r][c] = g[r][c]
}
}
return clone
}
// IsValid checks if the current grid state is valid (no conflicts).
func (g Grid) IsValid() bool {
for r := range 9 {
seen := make(map[uint8]bool)
for c := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check columns
for c := range 9 {
seen := make(map[uint8]bool)
for r := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
seen := make(map[uint8]bool)
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
}
}
return true
}
// IsSolved checks if the grid is completely filled and valid.
func (g Grid) IsSolved() bool {
if !g.IsValid() {
return false
}
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
return false
}
}
}
return true
}
// CountFilledCells returns the number of non-zero cells.
func (g Grid) CountFilledCells() int {
count := 0
for r := range 9 {
for c := range 9 {
if g[r][c] != 0 {
count++
}
}
}
return count
}
// GetCandidates returns possible values for a given cell.
func (g Grid) GetCandidates(row, col int) []uint8 {
if g[row][col] != 0 {
return nil
}
used := make(map[uint8]bool)
for c := range 9 {
if g[row][c] != 0 {
used[g[row][c]] = true
}
}
for r := range 9 {
if g[r][col] != 0 {
used[g[r][col]] = true
}
}
// Check 3x3 block
r0 := (row / 3) * 3
c0 := (col / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
if g[r][c] != 0 {
used[g[r][c]] = true
}
}
}
var candidates []uint8
for v := uint8(1); v <= 9; v++ {
if !used[v] {
candidates = append(candidates, v)
}
}
return candidates
}
// Hint represents a hint for the player.
type Hint struct {
Row int
Col int
Value uint8
Type HintType
}
// HintType categorizes different hint strategies.
type HintType int
const (
HintNakedSingle HintType = iota // Only one candidate for a cell
HintHiddenSingle // Only cell in row/col/box for a value
HintRandom // Random valid cell (fallback)
)
// GenerateHint provides a hint for the puzzle based on solving techniques.
func (g Grid) GenerateHint(solution Grid) *Hint {
// First, try to find a naked single (cell with only one candidate)
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if len(candidates) == 1 {
return &Hint{
Row: r,
Col: c,
Value: candidates[0],
Type: HintNakedSingle,
}
}
}
}
}
// Try to find a hidden single
if hint := g.findHiddenSingle(); hint != nil {
return hint
}
// Fallback: return a random empty cell with its solution
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
return &Hint{
Row: r,
Col: c,
Value: solution[r][c],
Type: HintRandom,
}
}
}
}
return nil
}
// findHiddenSingle finds a cell where a value can only go in one place in a row/col/box.
func (g Grid) findHiddenSingle() *Hint {
// Check rows
for r := range 9 {
for v := uint8(1); v <= 9; v++ {
var possibleCols []int
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if slices.Contains(candidates, v) {
possibleCols = append(possibleCols, c)
}
}
}
if len(possibleCols) == 1 {
return &Hint{
Row: r,
Col: possibleCols[0],
Value: v,
Type: HintHiddenSingle,
}
}
}
}
// Check columns
for c := range 9 {
for v := uint8(1); v <= 9; v++ {
var possibleRows []int
for r := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
for _, cand := range candidates {
if cand == v {
possibleRows = append(possibleRows, r)
break
}
}
}
}
if len(possibleRows) == 1 {
return &Hint{
Row: possibleRows[0],
Col: c,
Value: v,
Type: HintHiddenSingle,
}
}
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
for v := uint8(1); v <= 9; v++ {
type pos struct{ r, c int }
var possiblePos []pos
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
for _, cand := range candidates {
if cand == v {
possiblePos = append(possiblePos, pos{r, c})
break
}
}
}
}
}
if len(possiblePos) == 1 {
return &Hint{
Row: possiblePos[0].r,
Col: possiblePos[0].c,
Value: v,
Type: HintHiddenSingle,
}
}
}
}
}
return nil
}
// PuzzleAnalysis contains metrics about puzzle difficulty and characteristics.
type PuzzleAnalysis struct {
FilledCells int
EmptyCells int
MinCandidates int
MaxCandidates int
AvgCandidates float64
HasUniqueSolution bool
EstimatedDifficulty Difficulty
SolvingTechniques []string
SymmetryType SymmetryType
}
// SymmetryType represents the symmetry pattern of the puzzle.
type SymmetryType int
const (
SymmetryNone SymmetryType = iota
SymmetryRotational180
SymmetryRotational90
SymmetryVertical
SymmetryHorizontal
SymmetryDiagonal
)
func (s SymmetryType) String() string {
switch s {
case SymmetryRotational180:
return "180° Rotational"
case SymmetryRotational90:
return "90° Rotational"
case SymmetryVertical:
return "Vertical"
case SymmetryHorizontal:
return "Horizontal"
case SymmetryDiagonal:
return "Diagonal"
default:
return "None"
}
}
// Analyze performs a comprehensive analysis of the puzzle.
func (g Grid) Analyze() PuzzleAnalysis {
analysis := PuzzleAnalysis{
FilledCells: g.CountFilledCells(),
EmptyCells: 81 - g.CountFilledCells(),
}
// Analyze candidates
var totalCandidates int
var emptyCellCount int
analysis.MinCandidates = 9
analysis.MaxCandidates = 0
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
emptyCellCount++
candidates := g.GetCandidates(r, c)
count := len(candidates)
totalCandidates += count
if count < analysis.MinCandidates {
analysis.MinCandidates = count
}
if count > analysis.MaxCandidates {
analysis.MaxCandidates = count
}
}
}
}
if emptyCellCount > 0 {
analysis.AvgCandidates = float64(totalCandidates) / float64(emptyCellCount)
}
solverGrid := convertToSolverGrid(g)
analysis.HasUniqueSolution = solver.CountSolutions(solverGrid, 100*time.Millisecond, 2) == 1
analysis.SolvingTechniques = g.detectSolvingTechniques()
analysis.EstimatedDifficulty = g.estimateDifficulty(analysis)
analysis.SymmetryType = g.detectSymmetry()
return analysis
}
// detectSolvingTechniques identifies which solving techniques are needed.
func (g Grid) detectSolvingTechniques() []string {
var techniques []string
hasNakedSingle := false
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
if len(g.GetCandidates(r, c)) == 1 {
hasNakedSingle = true
break
}
}
}
}
if hasNakedSingle {
techniques = append(techniques, "Naked Singles")
}
if g.findHiddenSingle() != nil {
techniques = append(techniques, "Hidden Singles")
}
if len(techniques) == 0 {
techniques = append(techniques, "Advanced Techniques Required")
}
return techniques
}
// estimateDifficulty estimates puzzle difficulty based on analysis.
func (g Grid) estimateDifficulty(analysis PuzzleAnalysis) Difficulty {
// Use multiple factors to estimate difficulty
emptyCells := analysis.EmptyCells
avgCandidates := analysis.AvgCandidates
minCandidates := analysis.MinCandidates
// More empty cells generally means harder
if emptyCells >= 58 {
return Lunatic
} else if emptyCells >= 52 {
// Check if it requires advanced techniques
if minCandidates <= 2 || avgCandidates < 3.5 {
return Lunatic
}
return Hard
} else if emptyCells >= 46 {
if minCandidates <= 2 {
return Hard
}
return Normal
} else if emptyCells >= 38 {
return Normal
}
return Easy
}
// detectSymmetry detects the symmetry pattern of empty cells.
func (g Grid) detectSymmetry() SymmetryType {
if g.hasRotational180Symmetry() {
return SymmetryRotational180
}
if g.hasVerticalSymmetry() {
return SymmetryVertical
}
if g.hasHorizontalSymmetry() {
return SymmetryHorizontal
}
return SymmetryNone
}
func (g Grid) hasRotational180Symmetry() bool {
for r := range 9 {
for c := range 9 {
// Check if empty cells are symmetric
if (g[r][c] == 0) != (g[8-r][8-c] == 0) {
return false
}
}
}
return true
}
func (g Grid) hasVerticalSymmetry() bool {
for r := range 9 {
for c := range 9 {
if (g[r][c] == 0) != (g[r][8-c] == 0) {
return false
}
}
}
return true
}
func (g Grid) hasHorizontalSymmetry() bool {
for r := range 9 {
for c := range 9 {
if (g[r][c] == 0) != (g[8-r][c] == 0) {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,220 @@
package generator
import (
"fmt"
"time"
)
// BenchmarkResult contains statistics from puzzle generation benchmarking.
type BenchmarkResult struct {
Difficulty Difficulty
TotalAttempts int
SuccessfulPuzzles int
FailedPuzzles int
AverageTime time.Duration
MinTime time.Duration
MaxTime time.Duration
AverageRating float64
Ratings []int
}
// BenchmarkGeneration tests puzzle generation performance for a given difficulty.
func BenchmarkGeneration(d Difficulty, attempts int) BenchmarkResult {
result := BenchmarkResult{
Difficulty: d,
TotalAttempts: attempts,
MinTime: time.Hour, // Start with a large value
Ratings: make([]int, 0, attempts),
}
var totalDuration time.Duration
for i := 0; i < attempts; i++ {
seed := fmt.Sprintf("benchmark-%d-%d", time.Now().UnixNano(), i)
start := time.Now()
puzzle, err := Generate(d, seed)
elapsed := time.Since(start)
if err != nil {
result.FailedPuzzles++
continue
}
result.SuccessfulPuzzles++
totalDuration += elapsed
if elapsed < result.MinTime {
result.MinTime = elapsed
}
if elapsed > result.MaxTime {
result.MaxTime = elapsed
}
rating := RatePuzzle(puzzle)
result.Ratings = append(result.Ratings, rating)
}
if result.SuccessfulPuzzles > 0 {
result.AverageTime = totalDuration / time.Duration(result.SuccessfulPuzzles)
var totalRating int
for _, r := range result.Ratings {
totalRating += r
}
result.AverageRating = float64(totalRating) / float64(len(result.Ratings))
}
return result
}
// String returns a formatted string representation of the benchmark result.
func (br BenchmarkResult) String() string {
successRate := float64(br.SuccessfulPuzzles) / float64(br.TotalAttempts) * 100
return fmt.Sprintf(`Benchmark Results for %s:
Total Attempts: %d
Successful: %d (%.1f%%)
Failed: %d
Average Time: %v
Min Time: %v
Max Time: %v
Average Rating: %.1f/100
`,
br.Difficulty.String(),
br.TotalAttempts,
br.SuccessfulPuzzles,
successRate,
br.FailedPuzzles,
br.AverageTime,
br.MinTime,
br.MaxTime,
br.AverageRating,
)
}
// CompareGenerationMethods compares standard vs symmetric generation.
func CompareGenerationMethods(d Difficulty, attempts int) (standard, symmetric BenchmarkResult) {
// Benchmark standard generation
standard = BenchmarkGeneration(d, attempts)
// Benchmark symmetric generation
symmetric = BenchmarkResult{
Difficulty: d,
TotalAttempts: attempts,
MinTime: time.Hour,
Ratings: make([]int, 0, attempts),
}
var totalDuration time.Duration
for i := 0; i < attempts; i++ {
seed := fmt.Sprintf("symmetric-benchmark-%d-%d", time.Now().UnixNano(), i)
start := time.Now()
puzzle, err := GenerateWithSymmetry(d, seed, SymmetryRotational180)
elapsed := time.Since(start)
if err != nil {
symmetric.FailedPuzzles++
continue
}
symmetric.SuccessfulPuzzles++
totalDuration += elapsed
if elapsed < symmetric.MinTime {
symmetric.MinTime = elapsed
}
if elapsed > symmetric.MaxTime {
symmetric.MaxTime = elapsed
}
rating := RatePuzzle(puzzle)
symmetric.Ratings = append(symmetric.Ratings, rating)
}
if symmetric.SuccessfulPuzzles > 0 {
symmetric.AverageTime = totalDuration / time.Duration(symmetric.SuccessfulPuzzles)
var totalRating int
for _, r := range symmetric.Ratings {
totalRating += r
}
symmetric.AverageRating = float64(totalRating) / float64(len(symmetric.Ratings))
}
return standard, symmetric
}
// PuzzleStatistics provides detailed statistics about a generated puzzle.
type PuzzleStatistics struct {
Grid Grid
Analysis PuzzleAnalysis
Rating int
EstimatedSolveTime time.Duration
ComplexityScore float64
}
// GetPuzzleStatistics performs comprehensive analysis on a puzzle.
func GetPuzzleStatistics(g Grid) PuzzleStatistics {
analysis := g.Analyze()
rating := RatePuzzle(g)
// Estimate solve time based on difficulty (rough approximation)
var estimatedTime time.Duration
switch analysis.EstimatedDifficulty {
case Easy:
estimatedTime = 3 * time.Minute
case Normal:
estimatedTime = 8 * time.Minute
case Hard:
estimatedTime = 15 * time.Minute
case Expert:
estimatedTime = 25 * time.Minute
case Lunatic:
estimatedTime = 45 * time.Minute
}
// Calculate complexity score (0-1)
complexityScore := float64(rating) / 100.0
return PuzzleStatistics{
Grid: g,
Analysis: analysis,
Rating: rating,
EstimatedSolveTime: estimatedTime,
ComplexityScore: complexityScore,
}
}
// String returns a formatted string of puzzle statistics.
func (ps PuzzleStatistics) String() string {
return fmt.Sprintf(`Puzzle Statistics:
Difficulty: %s
Rating: %d/100
Filled Cells: %d
Empty Cells: %d
Min Candidates: %d
Max Candidates: %d
Avg Candidates: %.2f
Unique Solution: %v
Symmetry: %s
Techniques: %v
Complexity Score: %.2f
Est. Solve Time: %v
`,
ps.Analysis.EstimatedDifficulty.String(),
ps.Rating,
ps.Analysis.FilledCells,
ps.Analysis.EmptyCells,
ps.Analysis.MinCandidates,
ps.Analysis.MaxCandidates,
ps.Analysis.AvgCandidates,
ps.Analysis.HasUniqueSolution,
ps.Analysis.SymmetryType.String(),
ps.Analysis.SolvingTechniques,
ps.ComplexityScore,
ps.EstimatedSolveTime,
)
}

275
internal/generator/core.go Normal file
View File

@@ -0,0 +1,275 @@
package generator
import (
"math/rand"
"time"
"termdoku/internal/solver"
)
// randomizedFullSolution builds a complete valid Sudoku solution using randomized DFS.
func randomizedFullSolution(seed string, timeout time.Duration) (Grid, error) {
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed))))
}
deadline := time.Now().Add(timeout)
var g Grid
if fillCellRandom(&g, 0, 0, rng, deadline) {
return g, nil
}
return Grid{}, ErrTimeout
}
func fillCellRandom(g *Grid, row, col int, rng *rand.Rand, deadline time.Time) bool {
if time.Now().After(deadline) {
return false
}
nextRow, nextCol := row, col+1
if nextCol == 9 {
nextRow++
nextCol = 0
}
if row == 9 {
return true
}
vals := []uint8{1, 2, 3, 4, 5, 6, 7, 8, 9}
rng.Shuffle(len(vals), func(i, j int) { vals[i], vals[j] = vals[j], vals[i] })
for _, v := range vals {
if isSafe(*g, row, col, v) {
g[row][col] = v
if fillCellRandom(g, nextRow, nextCol, rng, deadline) {
return true
}
g[row][col] = 0
}
}
return false
}
func isSafe(g Grid, row, col int, v uint8) bool {
for i := 0; i < 9; i++ {
if g[row][i] == v || g[i][col] == v {
return false
}
}
r0 := (row / 3) * 3
c0 := (col / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
if g[r][c] == v {
return false
}
}
}
return true
}
// carveCellsUnique removes cells while trying to keep a single solution.
func carveCellsUnique(full Grid, targetRemoved int, seed string, timeout time.Duration) (Grid, error) {
puzzle := full
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
}
deadline := time.Now().Add(timeout)
cells := make([]int, 81)
for i := 0; i < 81; i++ {
cells[i] = i
}
rng.Shuffle(len(cells), func(i, j int) { cells[i], cells[j] = cells[j], cells[i] })
removed := 0
for _, idx := range cells {
if time.Now().After(deadline) {
break
}
r := idx / 9
c := idx % 9
backup := puzzle[r][c]
puzzle[r][c] = 0
// Check uniqueness using solver.CountSolutions up to 2
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
puzzle[r][c] = backup
continue
}
removed++
if removed >= targetRemoved {
break
}
}
return puzzle, nil
}
// carveCellsSymmetric removes cells with symmetry pattern while maintaining uniqueness.
func carveCellsSymmetric(full Grid, targetRemoved int, seed string, timeout time.Duration, symmetry SymmetryType) (Grid, error) {
puzzle := full
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
}
deadline := time.Now().Add(timeout)
// Generate cell pairs based on symmetry type
var cellPairs [][]struct{ r, c int }
switch symmetry {
case SymmetryRotational180:
cellPairs = generateRotational180Pairs()
case SymmetryVertical:
cellPairs = generateVerticalPairs()
case SymmetryHorizontal:
cellPairs = generateHorizontalPairs()
default:
// Fallback to non-symmetric
return carveCellsUnique(full, targetRemoved, seed, timeout)
}
rng.Shuffle(len(cellPairs), func(i, j int) { cellPairs[i], cellPairs[j] = cellPairs[j], cellPairs[i] })
removed := 0
for _, pair := range cellPairs {
if time.Now().After(deadline) {
break
}
// Try removing all cells in the pair
backups := make([]uint8, len(pair))
for i, pos := range pair {
backups[i] = puzzle[pos.r][pos.c]
puzzle[pos.r][pos.c] = 0
}
// Check if still unique
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
// Restore if not unique
for i, pos := range pair {
puzzle[pos.r][pos.c] = backups[i]
}
continue
}
removed += len(pair)
if removed >= targetRemoved {
break
}
}
return puzzle, nil
}
// generateRotational180Pairs creates cell pairs with 180° rotational symmetry.
func generateRotational180Pairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
r2 := 8 - r
c2 := 8 - c
idx2 := r2*9 + c2
if idx == idx2 {
// Center cell (4,4)
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c2}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
// generateVerticalPairs creates cell pairs with vertical symmetry.
func generateVerticalPairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
c2 := 8 - c
idx2 := r*9 + c2
if c == c2 {
// Center column
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r, c2}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
// generateHorizontalPairs creates cell pairs with horizontal symmetry.
func generateHorizontalPairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
r2 := 8 - r
idx2 := r2*9 + c
if r == r2 {
// Center row
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
func convertToSolverGrid(g Grid) solver.Grid {
var s solver.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
s[r][c] = g[r][c]
}
}
return s
}
// Simple FNV-1a 64-bit hash for seed strings.
func hashStringToUint64(s string) uint64 {
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
h := uint64(offset64)
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return h
}

View File

@@ -0,0 +1,248 @@
package generator
import (
"errors"
"termdoku/internal/solver"
"time"
)
// Difficulty represents puzzle difficulty tiers.
type Difficulty int
const (
Easy Difficulty = iota
Normal
Hard
Expert
Lunatic
)
// String returns the string representation of the difficulty.
func (d Difficulty) String() string {
switch d {
case Easy:
return "Easy"
case Normal:
return "Normal"
case Hard:
return "Hard"
case Expert:
return "Expert"
case Lunatic:
return "Lunatic"
default:
return "Unknown"
}
}
// DailySeed returns a stable seed based on UTC date (YYYY-MM-DD).
func DailySeed(t time.Time) string {
utc := t.UTC()
return utc.Format("2006-01-02")
}
// Params controls generation knobs derived from difficulty.
type Params struct {
// number of blanks/removed cells; higher -> harder
RemovedCells int
// backtracking timeout to avoid worst-cases
Timeout time.Duration
// symmetry pattern to use (optional)
Symmetry SymmetryType
// whether to enforce symmetry
UseSymmetry bool
}
// paramsFor maps Difficulty to generation parameters.
func paramsFor(d Difficulty) Params {
switch d {
case Easy:
return Params{
RemovedCells: 38,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
case Normal:
return Params{
RemovedCells: 46,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
case Hard:
return Params{
RemovedCells: 52,
Timeout: 200 * time.Millisecond,
UseSymmetry: false,
}
case Expert:
return Params{
RemovedCells: 56,
Timeout: 250 * time.Millisecond,
UseSymmetry: false,
}
case Lunatic:
return Params{
RemovedCells: 60,
Timeout: 300 * time.Millisecond,
UseSymmetry: false,
}
default:
return Params{
RemovedCells: 46,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
}
}
// Grid is a 9x9 Sudoku grid. 0 represents empty.
type Grid [9][9]uint8
// ErrTimeout is returned when generation exceeds the configured timeout.
var ErrTimeout = errors.New("generation timed out")
// Generate creates a Sudoku puzzle with the given difficulty and seed.
// - If seed is empty, uses current time for randomness.
// - For Daily mode, pass seed from DailySeed(date).
// Returns a puzzle grid with 0 as blanks, aimed at single-solution.
func Generate(d Difficulty, seed string) (Grid, error) {
p := paramsFor(d)
return generateWithParams(p, seed)
}
// GenerateDaily creates a daily puzzle based on UTC date.
func GenerateDaily(date time.Time) (Grid, error) {
return Generate(Normal, DailySeed(date))
}
// GenerateWithSymmetry creates a puzzle with a specific symmetry pattern.
func GenerateWithSymmetry(d Difficulty, seed string, symmetry SymmetryType) (Grid, error) {
p := paramsFor(d)
p.UseSymmetry = true
p.Symmetry = symmetry
return generateWithParams(p, seed)
}
// GenerateCustom creates a puzzle with custom parameters.
func GenerateCustom(removedCells int, seed string, useSymmetry bool, symmetry SymmetryType) (Grid, error) {
p := Params{
RemovedCells: removedCells,
Timeout: 300 * time.Millisecond,
UseSymmetry: useSymmetry,
Symmetry: symmetry,
}
return generateWithParams(p, seed)
}
// PuzzleWithSolution represents a puzzle along with its solution.
type PuzzleWithSolution struct {
Puzzle Grid
Solution Grid
Analysis PuzzleAnalysis
}
// GenerateWithAnalysis creates a puzzle and returns it with its solution and analysis.
func GenerateWithAnalysis(d Difficulty, seed string) (PuzzleWithSolution, error) {
puzzle, err := Generate(d, seed)
if err != nil {
return PuzzleWithSolution{}, err
}
// Solve to get the solution
solution := puzzle.Clone()
solverGrid := convertToSolverGrid(solution)
if !solver.Solve(&solverGrid, 500*time.Millisecond) {
return PuzzleWithSolution{}, errors.New("failed to solve generated puzzle")
}
// Convert back to Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
solution[r][c] = solverGrid[r][c]
}
}
// Analyze the puzzle
analysis := puzzle.Analyze()
return PuzzleWithSolution{
Puzzle: puzzle,
Solution: solution,
Analysis: analysis,
}, nil
}
// RatePuzzle provides a difficulty rating from 0-100 based on puzzle characteristics.
func RatePuzzle(g Grid) int {
analysis := g.Analyze()
// Base score from empty cells (0-40 points)
emptyScore := (analysis.EmptyCells * 40) / 81
// Candidate complexity (0-30 points)
candidateScore := 0
if analysis.AvgCandidates > 0 {
// Lower average candidates = harder
candidateScore = int((9.0 - analysis.AvgCandidates) * 3.3)
if candidateScore < 0 {
candidateScore = 0
}
if candidateScore > 30 {
candidateScore = 30
}
}
// Technique complexity (0-30 points)
techniqueScore := 0
for _, tech := range analysis.SolvingTechniques {
if tech == "Advanced Techniques Required" {
techniqueScore = 30
break
} else if tech == "Hidden Singles" {
techniqueScore = 15
} else if tech == "Naked Singles" {
techniqueScore = 5
}
}
totalScore := emptyScore + candidateScore + techniqueScore
if totalScore > 100 {
totalScore = 100
}
return totalScore
}
// DifficultyFromRating converts a rating (0-100) to a Difficulty level.
func DifficultyFromRating(rating int) Difficulty {
if rating >= 85 {
return Lunatic
} else if rating >= 70 {
return Expert
} else if rating >= 50 {
return Hard
} else if rating >= 30 {
return Normal
}
return Easy
}
// generateWithParams contains the core generation pipeline.
func generateWithParams(p Params, seed string) (Grid, error) {
// 1) Create a full valid solution via randomized backtracking
full, err := randomizedFullSolution(seed, p.Timeout)
if err != nil {
return Grid{}, err
}
// 2) Remove cells according to difficulty while keeping uniqueness if possible
var puzzle Grid
if p.UseSymmetry {
puzzle, err = carveCellsSymmetric(full, p.RemovedCells, seed, p.Timeout, p.Symmetry)
} else {
puzzle, err = carveCellsUnique(full, p.RemovedCells, seed, p.Timeout)
}
if err != nil {
return Grid{}, err
}
return puzzle, nil
}

298
internal/generator/utils.go Normal file
View File

@@ -0,0 +1,298 @@
package generator
import (
"fmt"
"strings"
"termdoku/internal/solver"
"time"
)
// PrintGrid prints the grid in a human-readable format.
func PrintGrid(g Grid) string {
var sb strings.Builder
sb.WriteString("┌───────┬───────┬───────┐\n")
for r := range 9 {
sb.WriteString("│ ")
for c := range 9 {
if g[r][c] == 0 {
sb.WriteString(".")
} else {
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
}
if c%3 == 2 {
sb.WriteString(" │ ")
} else {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
if r%3 == 2 && r != 8 {
sb.WriteString("├───────┼───────┼───────┤\n")
}
}
sb.WriteString("└───────┴───────┴───────┘\n")
return sb.String()
}
// GridToString converts a grid to a compact string representation.
func GridToString(g Grid) string {
var sb strings.Builder
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
sb.WriteString(".")
} else {
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
}
}
}
return sb.String()
}
// StringToGrid converts a string representation back to a grid.
// The string should be 81 characters long, with '0' or '.' for empty cells.
func StringToGrid(s string) (Grid, error) {
if len(s) != 81 {
return Grid{}, fmt.Errorf("invalid string length: expected 81, got %d", len(s))
}
var g Grid
for i, ch := range s {
r := i / 9
c := i % 9
if ch == '.' || ch == '0' {
g[r][c] = 0
} else if ch >= '1' && ch <= '9' {
g[r][c] = uint8(ch - '0')
} else {
return Grid{}, fmt.Errorf("invalid character at position %d: %c", i, ch)
}
}
return g, nil
}
// CompareGrids returns the differences between two grids.
func CompareGrids(g1, g2 Grid) []struct {
Row, Col int
Val1, Val2 uint8
} {
var diffs []struct {
Row, Col int
Val1, Val2 uint8
}
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g1[r][c] != g2[r][c] {
diffs = append(diffs, struct {
Row, Col int
Val1, Val2 uint8
}{
Row: r,
Col: c,
Val1: g1[r][c],
Val2: g2[r][c],
})
}
}
}
return diffs
}
// GetEmptyCells returns a list of all empty cell positions.
func GetEmptyCells(g Grid) []struct{ Row, Col int } {
var empty []struct{ Row, Col int }
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
empty = append(empty, struct{ Row, Col int }{r, c})
}
}
}
return empty
}
// GetFilledCells returns a list of all filled cell positions with their values.
func GetFilledCells(g Grid) []struct {
Row, Col int
Value uint8
} {
var filled []struct {
Row, Col int
Value uint8
}
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] != 0 {
filled = append(filled, struct {
Row, Col int
Value uint8
}{r, c, g[r][c]})
}
}
}
return filled
}
// GetRegionCells returns all cell positions in a 3x3 region.
func GetRegionCells(blockRow, blockCol int) []struct{ Row, Col int } {
if blockRow < 0 || blockRow > 2 || blockCol < 0 || blockCol > 2 {
return nil
}
var cells []struct{ Row, Col int }
startRow := blockRow * 3
startCol := blockCol * 3
for r := startRow; r < startRow+3; r++ {
for c := startCol; c < startCol+3; c++ {
cells = append(cells, struct{ Row, Col int }{r, c})
}
}
return cells
}
// ValidateGridStructure checks if a grid has valid structure (no duplicates).
func ValidateGridStructure(g Grid) []string {
var errors []string
// Check rows
for r := range 9 {
seen := make(map[uint8]bool)
for c := 0; c < 9; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in row %d", v, r+1))
}
seen[v] = true
}
}
// Check columns
for c := range 9 {
seen := make(map[uint8]bool)
for r := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in column %d", v, c+1))
}
seen[v] = true
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
seen := make(map[uint8]bool)
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in block (%d,%d)", v, br+1, bc+1))
}
seen[v] = true
}
}
}
}
return errors
}
// GetCandidateMap returns a map of all candidates for all empty cells.
func GetCandidateMap(g Grid) map[struct{ Row, Col int }][]uint8 {
candidateMap := make(map[struct{ Row, Col int }][]uint8)
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if len(candidates) > 0 {
candidateMap[struct{ Row, Col int }{r, c}] = candidates
}
}
}
}
return candidateMap
}
// CalculateCompletionPercentage returns the percentage of filled cells.
func CalculateCompletionPercentage(g Grid) float64 {
filled := g.CountFilledCells()
return (float64(filled) / 81.0) * 100.0
}
// GetMostConstrainedCell returns the empty cell with the fewest candidates.
// This is useful for implementing solving strategies.
func GetMostConstrainedCell(g Grid) (row, col int, candidates []uint8, found bool) {
minCandidates := 10
found = false
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
cands := g.GetCandidates(r, c)
if len(cands) < minCandidates {
minCandidates = len(cands)
row = r
col = c
candidates = cands
found = true
}
}
}
}
return
}
// IsMinimalPuzzle checks if removing any filled cell would result in multiple solutions.
// This is computationally expensive and should be used sparingly.
func IsMinimalPuzzle(g Grid) bool {
// For each filled cell, try removing it and check if puzzle still has unique solution
for r := range 9 {
for c := range 9 {
if g[r][c] != 0 {
// Try removing this cell
backup := g[r][c]
g[r][c] = 0
// Check if still unique
solverGrid := convertToSolverGrid(g)
solutionCount := solver.CountSolutions(solverGrid, 100*time.Millisecond, 2)
// Restore the cell
g[r][c] = backup
// If removing this cell doesn't maintain uniqueness, it's not minimal
if solutionCount != 1 {
return false
}
}
}
}
return true
}

View File

@@ -0,0 +1,91 @@
package savegame
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"time"
"termdoku/internal/game"
)
// SavedGame represents a saved game state
type SavedGame struct {
Board game.Grid `json:"board"`
Solution game.Grid `json:"solution"`
Given [9][9]bool `json:"given"`
Difficulty string `json:"difficulty"`
Elapsed int64 `json:"elapsed"` // seconds
StartTime time.Time `json:"startTime"`
HintsUsed int `json:"hintsUsed"`
Notes map[string][]uint8 `json:"notes"` // JSON keys must be strings
SavedAt time.Time `json:"savedAt"`
AutoCheck bool `json:"autoCheck"`
TimerEnabled bool `json:"timerEnabled"`
}
// path returns the save file path
func path() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".termdoku", "savegame.json"), nil
}
// Save writes a game state to disk
func Save(sg SavedGame) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(sg, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
// Load reads a game state from disk
func Load() (SavedGame, error) {
var sg SavedGame
p, err := path()
if err != nil {
return sg, err
}
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return sg, errors.New("no saved game found")
}
return sg, err
}
if err := json.Unmarshal(b, &sg); err != nil {
return sg, err
}
return sg, nil
}
// Exists checks if a saved game exists
func Exists() bool {
p, err := path()
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
// Delete removes the saved game
func Delete() error {
p, err := path()
if err != nil {
return err
}
return os.Remove(p)
}

288
internal/solver/solver.go Normal file
View File

@@ -0,0 +1,288 @@
package solver
import "time"
// Grid matches generator's grid representation
type Grid [9][9]uint8
// Solve attempts to fill the grid in-place using backtracking.
// Returns whether a solution was found before timeout.
func Solve(g *Grid, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
return solveBacktrack(g, deadline)
}
// CountSolutions counts up to maxCount solutions for uniqueness check.
func CountSolutions(g Grid, timeout time.Duration, maxCount int) int {
deadline := time.Now().Add(timeout)
count := 0
var dfs func(*Grid) bool
dfs = func(cur *Grid) bool {
if time.Now().After(deadline) {
return true
}
// Use most-constrained heuristic for faster counting
row, col, ok := findMostConstrained(*cur)
if !ok {
count++
return count >= maxCount
}
cands := candidates(*cur, row, col)
if len(cands) == 0 {
return false
}
for _, v := range cands {
cur[row][col] = v
if dfs(cur) {
return true
}
cur[row][col] = 0
}
return false
}
copyGrid := g
dfs(&copyGrid)
return count
}
func solveBacktrack(g *Grid, deadline time.Time) bool {
if time.Now().After(deadline) {
return false
}
// Use most-constrained-first heuristic for better performance
row, col, ok := findMostConstrained(*g)
if !ok {
return true
}
cands := candidates(*g, row, col)
// If no candidates available, this path is invalid
if len(cands) == 0 {
return false
}
for _, v := range cands {
g[row][col] = v
if solveBacktrack(g, deadline) {
return true
}
g[row][col] = 0
}
return false
}
func findEmpty(g Grid) (int, int, bool) {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
return r, c, true
}
}
}
return 0, 0, false
}
// findMostConstrained finds the empty cell with the fewest candidates.
// This heuristic significantly improves solving performance.
func findMostConstrained(g Grid) (int, int, bool) {
minCands := 10
bestRow, bestCol := -1, -1
found := false
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
cands := candidates(g, r, c)
if len(cands) < minCands {
minCands = len(cands)
bestRow = r
bestCol = c
found = true
// If we find a cell with only one candidate, use it immediately
if minCands == 1 {
return bestRow, bestCol, true
}
}
}
}
}
return bestRow, bestCol, found
}
func candidates(g Grid, row, col int) []uint8 {
used := [10]bool{}
for i := 0; i < 9; i++ {
used[g[row][i]] = true
used[g[i][col]] = true
}
r0 := (row / 3) * 3
c0 := (col / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
used[g[r][c]] = true
}
}
var out []uint8
for v := 1; v <= 9; v++ {
if !used[v] {
out = append(out, uint8(v))
}
}
return out
}
// IsValid checks if the grid is in a valid state (no conflicts).
func IsValid(g Grid) bool {
// Check rows
for r := 0; r < 9; r++ {
seen := [10]bool{}
for c := 0; c < 9; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check columns
for c := 0; c < 9; c++ {
seen := [10]bool{}
for r := 0; r < 9; r++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check 3x3 blocks
for br := 0; br < 3; br++ {
for bc := 0; bc < 3; bc++ {
seen := [10]bool{}
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
}
}
return true
}
// IsSolved checks if the grid is completely filled and valid.
func IsSolved(g Grid) bool {
if !IsValid(g) {
return false
}
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
return false
}
}
}
return true
}
// SolveStep represents a single step in the solving process.
type SolveStep struct {
Row int
Col int
Value uint8
Candidates []uint8
StepNumber int
}
// SolveWithSteps solves the puzzle and returns all steps taken.
func SolveWithSteps(g Grid, timeout time.Duration) ([]SolveStep, bool) {
deadline := time.Now().Add(timeout)
var steps []SolveStep
stepNum := 0
var solve func(*Grid) bool
solve = func(cur *Grid) bool {
if time.Now().After(deadline) {
return false
}
row, col, ok := findMostConstrained(*cur)
if !ok {
return true
}
cands := candidates(*cur, row, col)
if len(cands) == 0 {
return false
}
for _, v := range cands {
stepNum++
steps = append(steps, SolveStep{
Row: row,
Col: col,
Value: v,
Candidates: cands,
StepNumber: stepNum,
})
cur[row][col] = v
if solve(cur) {
return true
}
cur[row][col] = 0
}
return false
}
copyGrid := g
success := solve(&copyGrid)
return steps, success
}
// GetAllSolutions returns all possible solutions for a puzzle (up to maxSolutions).
func GetAllSolutions(g Grid, timeout time.Duration, maxSolutions int) []Grid {
deadline := time.Now().Add(timeout)
var solutions []Grid
var dfs func(*Grid)
dfs = func(cur *Grid) {
if time.Now().After(deadline) || len(solutions) >= maxSolutions {
return
}
row, col, ok := findMostConstrained(*cur)
if !ok {
// Found a solution, save a copy
var solution Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
solution[r][c] = cur[r][c]
}
}
solutions = append(solutions, solution)
return
}
cands := candidates(*cur, row, col)
for _, v := range cands {
cur[row][col] = v
dfs(cur)
cur[row][col] = 0
}
}
copyGrid := g
dfs(&copyGrid)
return solutions
}

184
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,184 @@
package stats
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
)
// Stats tracks player statistics and achievements
type Stats struct {
TotalGames int `json:"totalGames"`
CompletedGames int `json:"completedGames"`
CurrentStreak int `json:"currentStreak"`
BestStreak int `json:"bestStreak"`
LastPlayedDate string `json:"lastPlayedDate"` // YYYY-MM-DD format
BestTimes map[string]int `json:"bestTimes"` // difficulty -> seconds
CompletionCounts map[string]int `json:"completionCounts"`
HintsUsed int `json:"hintsUsed"`
RecentGames []GameRecord `json:"recentGames"`
DailyHistory map[string]GameRecord `json:"dailyHistory"` // date -> game record
}
// GameRecord represents a completed game
type GameRecord struct {
Difficulty string `json:"difficulty"`
Completed bool `json:"completed"`
Time int `json:"time"` // seconds
HintsUsed int `json:"hintsUsed"`
Date time.Time `json:"date"`
IsDaily bool `json:"isDaily"`
DailySeed string `json:"dailySeed,omitempty"`
}
// Default returns a new Stats with zero values
func Default() Stats {
return Stats{
BestTimes: make(map[string]int),
CompletionCounts: make(map[string]int),
RecentGames: []GameRecord{},
DailyHistory: make(map[string]GameRecord),
}
}
// path returns the stats file path
func path() (string, error) {
h, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(h, ".termdoku", "stats.json"), nil
}
// Load reads stats from disk
func Load() (Stats, error) {
st := Default()
p, err := path()
if err != nil {
return st, err
}
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return st, nil
}
return st, err
}
if err := json.Unmarshal(b, &st); err != nil {
return st, err
}
return st, nil
}
// Save writes stats to disk
func Save(st Stats) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
// RecordGame records a completed or abandoned game
func (s *Stats) RecordGame(record GameRecord) {
s.TotalGames++
if record.Completed {
s.CompletedGames++
today := time.Now().Format("2006-01-02")
if s.LastPlayedDate == "" {
s.CurrentStreak = 1
} else {
lastDate, _ := time.Parse("2006-01-02", s.LastPlayedDate)
daysDiff := int(time.Since(lastDate).Hours() / 24)
switch daysDiff {
case 0:
// Same day, maintain streak
case 1:
// Consecutive day
s.CurrentStreak++
default:
// Streak broken
s.CurrentStreak = 1
}
}
s.LastPlayedDate = today
if s.CurrentStreak > s.BestStreak {
s.BestStreak = s.CurrentStreak
}
if record.Time > 0 {
if existing, ok := s.BestTimes[record.Difficulty]; !ok || record.Time < existing {
s.BestTimes[record.Difficulty] = record.Time
}
}
s.CompletionCounts[record.Difficulty]++
if record.IsDaily && record.DailySeed != "" {
s.DailyHistory[record.DailySeed] = record
}
}
// Track hints
s.HintsUsed += record.HintsUsed
// Add to recent games (keep last 50)
s.RecentGames = append([]GameRecord{record}, s.RecentGames...)
if len(s.RecentGames) > 50 {
s.RecentGames = s.RecentGames[:50]
}
}
// GetLeaderboard returns top N game records by time for a difficulty
func (s *Stats) GetLeaderboard(difficulty string, limit int) []GameRecord {
var records []GameRecord
for _, game := range s.RecentGames {
if game.Difficulty == difficulty && game.Completed && game.Time > 0 {
records = append(records, game)
}
}
// Sort by time (ascending)
sort.Slice(records, func(i, j int) bool {
return records[i].Time < records[j].Time
})
if len(records) > limit {
records = records[:limit]
}
return records
}
// HasPlayedDaily checks if the user has played today's daily
func (s *Stats) HasPlayedDaily(seed string) bool {
_, ok := s.DailyHistory[seed]
return ok
}
// GetDailyRecord returns the record for a specific daily seed
func (s *Stats) GetDailyRecord(seed string) (GameRecord, bool) {
record, ok := s.DailyHistory[seed]
return record, ok
}
// FormatTime formats seconds into MM:SS
func FormatTime(seconds int) string {
mins := seconds / 60
secs := seconds % 60
return fmt.Sprintf("%02d:%02d", mins, secs)
}

292
internal/theme/theme.go Normal file
View File

@@ -0,0 +1,292 @@
package theme
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
)
type Palette struct {
Background string
Foreground string
GridLine string
CellBaseBG string
CellBaseFG string
CellFixedFG string
CellFixedBG string
CellSelectedBG string
CellSelectedFG string
CellDuplicateBG string
CellConflictBG string
Accent string
}
type Theme struct {
Name string
Palette Palette
}
// Light defines a theme inspired by Solarized Light.
func Light() Theme {
return Theme{
Name: "solarized-light",
Palette: Palette{
Background: "#fdf6e3", // base3
Foreground: "#586e75", // base01
GridLine: "#93a1a1", // base1
CellBaseBG: "",
CellBaseFG: "#586e75", // base01
CellFixedFG: "#839496", // base0
CellFixedBG: "",
CellSelectedBG: "#eee8d5", // base2
CellSelectedFG: "#586e75", // base01
CellDuplicateBG: "#f5e8c1", // Slightly darker variant
CellConflictBG: "#ffe0e0", // Reddish conflict, still light
Accent: "#dc322f", // red
},
}
}
// Darcula returns a dark theme inspired by Darcula.
func Darcula() Theme {
return Theme{
Name: "dracula",
Palette: Palette{
Background: "#282a36", // background
Foreground: "#f8f8f2", // foreground
GridLine: "#44475a", // current line
CellBaseBG: "",
CellBaseFG: "#f8f8f2", // foreground
CellFixedFG: "#6272a4", // comment
CellFixedBG: "",
CellSelectedBG: "#44475a", // current line
CellSelectedFG: "#f8f8f2", // foreground
CellDuplicateBG: "#50fa7b", // green - using a highlight for duplicate
CellConflictBG: "#ff5555", // red
Accent: "#bd93f9", // purple
},
}
}
// DetectTheme automatically detects the terminal background and returns appropriate theme
func DetectTheme() Theme {
if hasLightBackground() {
return Light()
}
return Darcula()
}
// hasLightBackground attempts to detect if the terminal has a light background
func hasLightBackground() bool {
// Primary detection using termenv
if !termenv.HasDarkBackground() {
return true
}
// Additional environment variable checks
term := strings.ToLower(os.Getenv("TERM"))
colorterm := strings.ToLower(os.Getenv("COLORTERM"))
termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
// Check for light theme indicators in environment variables
lightIndicators := []string{"light", "bright", "white"}
for _, indicator := range lightIndicators {
if strings.Contains(term, indicator) ||
strings.Contains(colorterm, indicator) ||
strings.Contains(termProgram, indicator) {
return true
}
}
// Check iTerm2 profile
if iterm2Profile := strings.ToLower(os.Getenv("ITERM_PROFILE")); iterm2Profile != "" {
for _, indicator := range lightIndicators {
if strings.Contains(iterm2Profile, indicator) {
return true
}
}
}
return false
}
// AdaptiveColors provides theme-aware color mappings
type AdaptiveColors struct {
theme Theme
}
func NewAdaptiveColors(t Theme) AdaptiveColors {
return AdaptiveColors{theme: t}
}
// GetDifficultyColors returns colors for each difficulty level adapted to the theme
func (ac AdaptiveColors) GetDifficultyColors() map[string]string {
if ac.theme.Name == "solarized-light" {
// Solarized Light theme colors
return map[string]string{
"Easy": "#2aa198", // cyan
"Normal": "#859900", // green
"Hard": "#cb4b16", // orange
"Lunatic": "#6c71c4", // violet
"Daily": "#859900", // green
}
}
// Dracula theme colors
return map[string]string{
"Easy": "#8be9fd", // cyan
"Normal": "#50fa7b", // green
"Hard": "#ffb86c", // orange
"Lunatic": "#bd93f9", // purple
"Daily": "#50fa7b", // green
}
}
// GetGradientColors returns gradient color pairs adapted to the theme
func (ac AdaptiveColors) GetGradientColors() map[string][2]string {
if ac.theme.Name == "solarized-light" {
// Solarized Light theme gradients
return map[string][2]string{
"banner": {"#6c71c4", "#b58900"}, // violet to yellow
"easy": {"#2aa198", "#268bd2"}, // cyan to blue
"normal": {"#859900", "#cb4b16"}, // green to orange
"daily": {"#859900", "#cb4b16"}, // green to orange
"hard": {"#dc322f", "#cb4b16"}, // red to orange
"lunatic": {"#6c71c4", "#d33682"}, // violet to magenta
"complete": {"#859900", "#268bd2"}, // success green to blue
}
}
// Dracula theme gradients
return map[string][2]string{
"banner": {"#bd93f9", "#ff79c6"}, // purple to pink
"easy": {"#8be9fd", "#6272a4"}, // cyan to comment
"normal": {"#50fa7b", "#ffb86c"}, // green to orange
"daily": {"#50fa7b", "#ffb86c"}, // green to orange
"hard": {"#ffb86c", "#ff5555"}, // orange to red
"lunatic": {"#bd93f9", "#ff79c6"}, // purple to pink
"complete": {"#50fa7b", "#ff79c6"}, // green to pink
}
}
// GetAccentColors returns various accent colors adapted to the theme
func (ac AdaptiveColors) GetAccentColors() map[string]string {
if ac.theme.Name == "solarized-light" {
// Solarized Light theme accents
return map[string]string{
"selected": "#cb4b16", // orange
"panel": "#839496", // base0
"success": "#859900", // green
"error": "#dc322f", // red
}
}
// Dracula theme accents
return map[string]string{
"selected": "#ff79c6", // pink
"panel": "#44475a", // current line
"success": "#50fa7b", // green
"error": "#ff5555", // red
}
}
func BaseStyle(t Theme) lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)).Background(lipgloss.Color(t.Palette.Background))
}
// LoadCustomTheme loads a custom theme from ~/.termdoku/themes/
func LoadCustomTheme(name string) (Theme, error) {
h, err := os.UserHomeDir()
if err != nil {
return Theme{}, err
}
themePath := filepath.Join(h, ".termdoku", "themes", name+".toml")
data, err := os.ReadFile(themePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Theme{}, errors.New("custom theme not found: " + name)
}
return Theme{}, err
}
var theme Theme
if err := toml.Unmarshal(data, &theme); err != nil {
return Theme{}, err
}
// Set name if not specified in file
if theme.Name == "" {
theme.Name = name
}
return theme, nil
}
// GetTheme returns the appropriate theme based on config or auto-detection
func GetTheme(configTheme string) Theme {
switch configTheme {
case "light":
return Light()
case "dark":
return Darcula()
case "auto", "":
return DetectTheme()
default:
if theme, err := LoadCustomTheme(configTheme); err == nil {
return theme
}
// Fall back to auto-detection
return DetectTheme()
}
}
// CreateExampleTheme creates an example custom theme file for users
func CreateExampleTheme() error {
h, err := os.UserHomeDir()
if err != nil {
return err
}
themesDir := filepath.Join(h, ".termdoku", "themes")
if err := os.MkdirAll(themesDir, 0o755); err != nil {
return err
}
examplePath := filepath.Join(themesDir, "example.toml")
// Don't overwrite if exists
if _, err := os.Stat(examplePath); err == nil {
return nil
}
// Example theme inspired by Monokai
example := Theme{
Name: "monokai-example",
Palette: Palette{
Background: "#272822",
Foreground: "#f8f8f2",
GridLine: "#49483e",
CellBaseBG: "",
CellBaseFG: "#f8f8f2",
CellFixedFG: "#75715e",
CellFixedBG: "",
CellSelectedBG: "#3e3d32",
CellSelectedFG: "#f8f8f2",
CellDuplicateBG: "#a6e22e", // Green for duplicates
CellConflictBG: "#f92672", // Pink/Red for conflicts
Accent: "#e6db74", // Yellow accent
},
}
data, err := toml.Marshal(example)
if err != nil {
return err
}
return os.WriteFile(examplePath, data, 0o644)
}

View File

@@ -0,0 +1,16 @@
,----,
,/ .`|
,` .' : ____ ,---, ,-.
; ; / ,' , `. .' .' `\ ,--/ /|
.'___,/ ,' __ ,-. ,-+-,.' _ |,---.' \ ,---. ,--. :/ | ,--,
| : | ,' ,'/ /| ,-+-. ; , ||| | .`\ | ' ,'\ : : ' / ,'_ /|
; |.'; ; ,---. ' | |' | ,--.'|' | ||: : | ' | / / || ' / .--. | | :
`----' | | / \ | | ,'| | ,', | |,| ' ' ; :. ; ,. :' | : ,'_ /| : . |
' : ; / / |' : / | | / | |--' ' | ; . |' | |: :| | \ | ' | | . .
| | '. ' / || | ' | : | | , | | : | '' | .; :' : |. \ | | ' | | |
' : |' ; /|; : | | : | |/ ' : | / ; | : || | ' \ \: | : ; ; |
; |.' ' | / || , ; | | |`-' | | '` ,/ \ \ / ' : |--' ' : `--' \
'---' | : | ---' | ;/ ; : .' `----' ; |,' : , .-./
\ \ / '---' | ,.' '--' `--`----'
`----' '---'

20
internal/ui/example.go Normal file
View File

@@ -0,0 +1,20 @@
package ui
import (
"strings"
"termdoku/internal/theme"
)
// ExampleRenderSample returns a sample rendering string for docs/tests.
func ExampleRenderSample() string {
_th := theme.Darcula()
styles := BuildStyles(_th)
var b strings.Builder
b.WriteString(styles.CellSelected.Render("5"))
b.WriteString(" ")
b.WriteString(styles.CellDuplicate.Render("5"))
b.WriteString(" ")
b.WriteString(styles.CellConflict.Render("3"))
b.WriteString("\n")
return styles.App.Render(b.String())
}

473
internal/ui/game.go Normal file
View File

@@ -0,0 +1,473 @@
package ui
import (
"fmt"
"strconv"
"strings"
"time"
"termdoku/internal/config"
"termdoku/internal/game"
"termdoku/internal/generator"
"termdoku/internal/savegame"
"termdoku/internal/solver"
"termdoku/internal/theme"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type timerTickMsg struct{}
type flashDoneMsg struct{ Row, Col int }
type Model struct {
keymap KeyMap
styles UIStyles
theme theme.Theme
board game.Board
solution game.Grid
cursorRow int
cursorCol int
autoCheck bool
timerEnabled bool
startTime time.Time
elapsed time.Duration
completed bool
paused bool
difficulty string
undoStack []game.Move
redoStack []game.Move
flashes map[[2]int]time.Time
showHelp bool
hintsUsed int
noteMode bool
notes map[[2]int][]uint8 // cell -> candidate numbers
showWinAnim bool
winAnimStart time.Time
}
func New(p generator.Grid, th theme.Theme, cfg config.Config) Model {
b := game.NewBoardFromPuzzle(game.Grid(p))
// Solve once for auto-check
sg := b.Values
if s := solveCopy(b.Values); s != nil {
sg = *s
}
km := DefaultKeyMap()
km.ApplyBindings(cfg.Bindings)
m := Model{
keymap: km,
styles: BuildStyles(th),
theme: th,
board: b,
solution: sg,
cursorRow: 0,
cursorCol: 0,
autoCheck: cfg.AutoCheck,
timerEnabled: cfg.TimerEnabled,
startTime: time.Now(),
flashes: map[[2]int]time.Time{},
notes: make(map[[2]int][]uint8),
}
return m
}
func solveCopy(g game.Grid) *game.Grid {
var sg solver.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
sg[r][c] = g[r][c]
}
}
if solver.Solve(&sg, 2*time.Second) {
var out game.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
out[r][c] = sg[r][c]
}
}
return &out
}
return nil
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if m.timerEnabled {
cmds = append(cmds, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} }))
}
return tea.Batch(cmds...)
}
func (m Model) View() string { return Render(m) }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case timerTickMsg:
if m.timerEnabled && !m.completed {
m.elapsed = time.Since(m.startTime)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
case flashDoneMsg:
delete(m.flashes, [2]int{msg.Row, msg.Col})
return m, nil
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg
// Help overlay toggle
if key.Matches(k, m.keymap.Help) {
m.showHelp = !m.showHelp
return m, nil
}
// When help is shown, only allow closing it
if m.showHelp {
return m, nil
}
// Pause toggle
if key.Matches(k, m.keymap.Pause) {
m.paused = !m.paused
return m, nil
}
// When paused, only allow unpause or quit
if m.paused {
if k.String() == "q" || k.String() == "esc" || k.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
// Toggle features
if key.Matches(k, m.keymap.ToggleAuto) {
m.autoCheck = !m.autoCheck
return m, nil
}
if key.Matches(k, m.keymap.ToggleTimer) {
m.timerEnabled = !m.timerEnabled
if m.timerEnabled && !m.completed {
m.startTime = time.Now().Add(-m.elapsed)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
}
if key.Matches(k, m.keymap.ToggleNote) {
m.noteMode = !m.noteMode
return m, nil
}
// Hint
if key.Matches(k, m.keymap.Hint) && !m.completed {
m = m.applyHint()
return m, nil
}
// Undo/Redo
if key.Matches(k, m.keymap.Undo) {
m = m.applyUndo()
return m, nil
}
if key.Matches(k, m.keymap.Redo) {
m = m.applyRedo()
return m, nil
}
// Save/Load
if key.Matches(k, m.keymap.Save) && !m.completed {
_ = m.saveGame() // Ignore errors for now
return m, nil
}
if key.Matches(k, m.keymap.Load) {
if loadedModel, err := m.loadGame(); err == nil {
m = loadedModel
}
return m, nil
}
s := k.String()
switch s {
case "up", "k":
m.cursorRow = clamp(m.cursorRow-1, 0, 8)
case "down", "j":
m.cursorRow = clamp(m.cursorRow+1, 0, 8)
case "left", "h":
m.cursorCol = clamp(m.cursorCol-1, 0, 8)
case "right", "l":
m.cursorCol = clamp(m.cursorCol+1, 0, 8)
case " ", "0":
return m.applyInput(0)
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
v := uint8(s[0] - '0')
return m.applyInput(v)
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) applyInput(v uint8) (tea.Model, tea.Cmd) {
if m.board.IsGiven(m.cursorRow, m.cursorCol) {
return m, nil
}
// Note mode: toggle candidate number
if m.noteMode && v != 0 {
key := [2]int{m.cursorRow, m.cursorCol}
notes := m.notes[key]
// Toggle the note
found := false
for i, n := range notes {
if n == v {
// Remove note
notes = append(notes[:i], notes[i+1:]...)
found = true
break
}
}
if !found {
// Add note
notes = append(notes, v)
}
if len(notes) > 0 {
m.notes[key] = notes
} else {
delete(m.notes, key)
}
return m, nil
}
// Normal mode: set value
prev, ok := m.board.SetValue(m.cursorRow, m.cursorCol, v)
if !ok {
return m, nil
}
// Clear notes for this cell when setting a value
if v != 0 {
delete(m.notes, [2]int{m.cursorRow, m.cursorCol})
}
mv := game.Move{Row: m.cursorRow, Col: m.cursorCol, Prev: prev, Next: v, At: time.Now()}
m.undoStack = append(m.undoStack, mv)
m.redoStack = nil
m.flashes[[2]int{m.cursorRow, m.cursorCol}] = time.Now().Add(120 * time.Millisecond)
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m, tea.Tick(130*time.Millisecond, func(time.Time) tea.Msg { return flashDoneMsg{Row: mv.Row, Col: mv.Col} })
}
func (m Model) applyUndo() Model {
if len(m.undoStack) == 0 {
return m
}
last := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.board.Values[last.Row][last.Col] = last.Prev
m.redoStack = append(m.redoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyRedo() Model {
if len(m.redoStack) == 0 {
return m
}
last := m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
m.board.Values[last.Row][last.Col] = last.Next
m.undoStack = append(m.undoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyHint() Model {
// Find first empty cell and fill it with solution value
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if !m.board.Given[r][c] && m.board.Values[r][c] == 0 {
solutionVal := m.solution[r][c]
if solutionVal != 0 {
m.board.Values[r][c] = solutionVal
m.cursorRow, m.cursorCol = r, c
m.hintsUsed++
m.flashes[[2]int{r, c}] = time.Now().Add(500 * time.Millisecond)
// Clear notes for hinted cell
delete(m.notes, [2]int{r, c})
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m
}
}
}
}
return m
}
func (m Model) saveGame() error {
// Convert notes map to string keys for JSON
notesJSON := make(map[string][]uint8)
for key, notes := range m.notes {
keyStr := strconv.Itoa(key[0]) + "," + strconv.Itoa(key[1])
notesJSON[keyStr] = notes
}
sg := savegame.SavedGame{
Board: m.board.Values,
Solution: m.solution,
Given: m.board.Given,
Difficulty: m.difficulty,
Elapsed: int64(m.elapsed.Seconds()),
StartTime: m.startTime,
HintsUsed: m.hintsUsed,
Notes: notesJSON,
SavedAt: time.Now(),
AutoCheck: m.autoCheck,
TimerEnabled: m.timerEnabled,
}
return savegame.Save(sg)
}
func (m Model) loadGame() (Model, error) {
sg, err := savegame.Load()
if err != nil {
return m, err
}
// Restore board
m.board.Values = sg.Board
m.board.Given = sg.Given
m.solution = sg.Solution
m.difficulty = sg.Difficulty
m.hintsUsed = sg.HintsUsed
m.autoCheck = sg.AutoCheck
m.timerEnabled = sg.TimerEnabled
// Restore notes (convert string keys back to [2]int)
m.notes = make(map[[2]int][]uint8)
for keyStr, notes := range sg.Notes {
// Parse "r,c" format
var r, c int
fmt.Sscanf(keyStr, "%d,%d", &r, &c)
m.notes[[2]int{r, c}] = notes
}
// Restore timer state
if m.timerEnabled {
m.elapsed = time.Duration(sg.Elapsed) * time.Second
m.startTime = time.Now().Add(-m.elapsed)
}
m.completed = isSolved(m.board.Values, m.solution)
return m, nil
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func (m Model) StatusLine() string {
// Completed UI
if m.completed {
adaptiveColors := theme.NewAdaptiveColors(m.theme)
gradientColors := adaptiveColors.GetGradientColors()
completeGrad := gradientColors["complete"]
var completeText string
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeStr := fmt.Sprintf("%02d:%02d", mins, s)
if m.hintsUsed > 0 {
completeText = fmt.Sprintf("✭ Clear %s (%d hints) ! Press 'm' for menu ✭", timeStr, m.hintsUsed)
} else {
completeText = fmt.Sprintf("✭ Clear %s ! Press 'm' for menu ✭", timeStr)
}
} else {
completeText = "✭ Clear! Press 'm' for menu ✭"
}
return gradientText(completeText, completeGrad[0], completeGrad[1])
}
// All filled but not solved → Try again
if allFilled(m.board.Values) && !isSolved(m.board.Values, m.solution) {
return m.styles.StatusError.Render("✭ Try again... ✭")
}
// Normal status (fixed width segments)
var parts []string
// Timer
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeValue := fmt.Sprintf("%02d:%02d", mins, s)
parts = append(parts, m.styles.Status.Render("Timer: ")+m.styles.BoolTrue.Render(timeValue))
}
// Hints used
if m.hintsUsed > 0 {
parts = append(parts, m.styles.Status.Render(fmt.Sprintf("Hints: %d", m.hintsUsed)))
}
// Note mode indicator
if m.noteMode {
parts = append(parts, m.styles.BoolTrue.Render("NOTE MODE"))
}
// Help hint
parts = append(parts, m.styles.Status.Render("Help: ?"))
separator := m.styles.Status.Render(" | ")
return strings.Join(parts, separator)
}
func allFilled(g game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
return false
}
}
}
return true
}
func isSolved(cur game.Grid, sol game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if cur[r][c] != sol[r][c] {
return false
}
}
}
return true
}

97
internal/ui/keymap.go Normal file
View File

@@ -0,0 +1,97 @@
package ui
import (
"github.com/charmbracelet/bubbles/key"
"strings"
)
type KeyMap struct {
Up, Down, Left, Right key.Binding
Undo, Redo key.Binding
ToggleAuto key.Binding
ToggleTimer key.Binding
Help key.Binding
MainMenu key.Binding
Hint key.Binding
Pause key.Binding
ToggleNote key.Binding
Save key.Binding
Load key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "Up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "Down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "Left")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "Right")),
Undo: key.NewBinding(key.WithKeys("ctrl+z", "u"), key.WithHelp("u/Ctrl+Z", "Undo")),
Redo: key.NewBinding(key.WithKeys("ctrl+y", "ctrl+r"), key.WithHelp("Ctrl+Y/R", "Redo")),
ToggleAuto: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "Auto-Check")),
ToggleTimer: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Timer")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "Help")),
MainMenu: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "Main Menu")),
Hint: key.NewBinding(key.WithKeys("ctrl+h"), key.WithHelp("Ctrl+H", "Hint")),
Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "Pause")),
ToggleNote: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "Note Mode")),
Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("Ctrl+S", "Save")),
Load: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("Ctrl+L", "Load")),
}
}
func (km *KeyMap) ApplyBindings(bindings map[string][]string) {
if bindings == nil {
return
}
set := func(b *key.Binding, keys []string, help string) {
if len(keys) == 0 {
return
}
*b = key.NewBinding(key.WithKeys(keys...), key.WithHelp(strings.Join(keys, "/"), help))
}
if v, ok := bindings["up"]; ok {
set(&km.Up, v, "Up")
}
if v, ok := bindings["down"]; ok {
set(&km.Down, v, "Down")
}
if v, ok := bindings["left"]; ok {
set(&km.Left, v, "Left")
}
if v, ok := bindings["right"]; ok {
set(&km.Right, v, "Right")
}
if v, ok := bindings["undo"]; ok {
set(&km.Undo, v, "Undo")
}
if v, ok := bindings["redo"]; ok {
set(&km.Redo, v, "Redo")
}
if v, ok := bindings["auto"]; ok {
set(&km.ToggleAuto, v, "Auto-Check")
}
if v, ok := bindings["timer"]; ok {
set(&km.ToggleTimer, v, "Timer")
}
if v, ok := bindings["help"]; ok {
set(&km.Help, v, "Help")
}
if v, ok := bindings["main"]; ok {
set(&km.MainMenu, v, "Main Menu")
}
if v, ok := bindings["hint"]; ok {
set(&km.Hint, v, "Hint")
}
if v, ok := bindings["pause"]; ok {
set(&km.Pause, v, "Pause")
}
if v, ok := bindings["note"]; ok {
set(&km.ToggleNote, v, "Note Mode")
}
if v, ok := bindings["save"]; ok {
set(&km.Save, v, "Save")
}
if v, ok := bindings["load"]; ok {
set(&km.Load, v, "Load")
}
}

634
internal/ui/menu.go Normal file
View File

@@ -0,0 +1,634 @@
package ui
import (
_ "embed"
"fmt"
"strings"
"time"
"termdoku/internal/achievements"
"termdoku/internal/config"
"termdoku/internal/generator"
"termdoku/internal/stats"
"termdoku/internal/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
//go:embed assets/banner.txt
var bannerArt string
type appState int
const (
stateMenu appState = iota
stateGame
stateStats
stateAchievements
stateLeaderboard
stateProfile
stateProfileSubmenu
stateDatabase
)
type App struct {
state appState
cfg config.Config
th theme.Theme
styles UIStyles
stats stats.Stats
achievements *achievements.Manager
menuItems []string
selectedIdx int
autoCheck bool
timerEnabled bool
width int
height int
currentDiff string
game Model
// Profile submenu
profileMenuItems []string
profileSelectedIdx int
}
func NewApp(cfg config.Config) App {
th := theme.GetTheme(cfg.Theme)
st, _ := stats.Load()
ach, _ := achievements.Load()
return App{
state: stateMenu,
cfg: cfg,
th: th,
styles: BuildStyles(th),
stats: st,
achievements: ach,
menuItems: []string{"Easy", "Normal", "Hard", "Expert", "Lunatic", "Daily", "Profile"},
selectedIdx: 1,
autoCheck: cfg.AutoCheck,
timerEnabled: cfg.TimerEnabled,
profileMenuItems: []string{"Achievements", "Leaderboard"},
profileSelectedIdx: 0,
}
}
func (a App) Init() tea.Cmd { return nil }
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch a.state {
case stateMenu:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "up", "k":
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
case "down", "j":
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
case "left", "h":
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
case "right", "l":
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
case "a":
a.autoCheck = !a.autoCheck
case "t":
a.timerEnabled = !a.timerEnabled
case "enter":
sel := a.menuItems[a.selectedIdx]
switch sel {
case "Achievements":
a.state = stateAchievements
return a, nil
case "Leaderboard":
a.state = stateLeaderboard
return a, nil
case "Profile":
a.state = stateProfile
return a, nil
default:
gm, cmd := a.startGame()
a.game = gm
a.state = stateGame
return a, cmd
}
case "q", "esc", "ctrl+c":
return a, tea.Quit
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateStats:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateGame:
// Check if game was just completed
wasCompleted := a.game.completed
// intercept main menu key
if kmsg, isKey := msg.(tea.KeyMsg); isKey {
if kmsg.String() == "m" {
// Record game if it was completed but not yet recorded
if a.game.completed && !wasCompleted {
a.recordGameCompletion()
}
a.state = stateMenu
return a, nil
}
}
gm, cmd := a.game.Update(msg)
if v, ok := gm.(Model); ok {
// Check if game just became completed
if v.completed && !wasCompleted {
a.game = v
a.recordGameCompletion()
return a, cmd
}
a.game = v
}
return a, cmd
case stateAchievements:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateLeaderboard:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateProfile:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
case "s":
a.state = stateProfileSubmenu
return a, nil
case "d":
a.state = stateDatabase
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateProfileSubmenu:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc":
a.state = stateProfile
return a, nil
case "up", "k":
a.profileSelectedIdx = clamp(a.profileSelectedIdx-1, 0, len(a.profileMenuItems)-1)
case "down", "j":
a.profileSelectedIdx = clamp(a.profileSelectedIdx+1, 0, len(a.profileMenuItems)-1)
case "enter":
sel := a.profileMenuItems[a.profileSelectedIdx]
switch sel {
case "Stats":
a.state = stateStats
return a, nil
case "Achievements":
a.state = stateAchievements
return a, nil
case "Leaderboard":
a.state = stateLeaderboard
return a, nil
}
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateDatabase:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
case "p":
a.state = stateProfile
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
}
return a, nil
}
func (a App) View() string {
switch a.state {
case stateMenu:
return a.viewMenu()
case stateGame:
return a.viewGame()
case stateAchievements:
return a.viewAchievements()
case stateProfile:
return a.viewProfile()
case stateProfileSubmenu:
return a.viewProfileSubmenu()
case stateDatabase:
return a.viewDatabaseInfo()
case stateLeaderboard:
return a.viewLeaderboard()
}
return ""
}
func (a App) recordGameCompletion() {
record := stats.GameRecord{
Difficulty: a.currentDiff,
Completed: a.game.completed,
Time: int(a.game.elapsed.Seconds()),
HintsUsed: a.game.hintsUsed,
Date: time.Now(),
IsDaily: a.currentDiff == "Daily",
}
if a.currentDiff == "Daily" {
record.DailySeed = time.Now().Format("2006-01-02")
}
a.stats.RecordGame(record)
_ = stats.Save(a.stats)
if a.game.completed {
a.achievements.CheckAndUnlock("first_win", a.stats.CompletedGames)
if a.game.hintsUsed == 0 {
a.achievements.CheckAndUnlock("perfectionist", 1)
}
if a.currentDiff == "Easy" && int(a.game.elapsed.Seconds()) < 180 {
a.achievements.CheckAndUnlock("speed_demon", 1)
}
if a.currentDiff == "Hard" && !a.autoCheck {
a.achievements.CheckAndUnlock("no_mistakes", 1)
}
a.achievements.CheckAndUnlock("streak_master", a.stats.CurrentStreak)
a.achievements.CheckAndUnlock("century", a.stats.CompletedGames)
if a.currentDiff == "Lunatic" {
lunaticCount := a.stats.CompletionCounts["Lunatic"]
a.achievements.CheckAndUnlock("lunatic_legend", lunaticCount)
}
if a.currentDiff == "Daily" {
dailyCount := len(a.stats.DailyHistory)
a.achievements.CheckAndUnlock("daily_devotee", dailyCount)
}
_ = achievements.Save(a.achievements)
}
}
func (a *App) startGame() (Model, tea.Cmd) {
var g generator.Grid
var err error
sel := a.menuItems[a.selectedIdx]
switch sel {
case "Daily":
g, err = generator.GenerateDaily(time.Now())
case "Easy":
g, err = generator.Generate(generator.Easy, "")
case "Normal":
g, err = generator.Generate(generator.Normal, "")
case "Hard":
g, err = generator.Generate(generator.Hard, "")
case "Expert":
g, err = generator.Generate(generator.Expert, "")
case "Lunatic":
g, err = generator.Generate(generator.Lunatic, "")
}
if err != nil {
return a.game, nil
}
cfg := a.cfg
cfg.AutoCheck = a.autoCheck
cfg.TimerEnabled = a.timerEnabled
a.currentDiff = sel
m := New(g, a.th, cfg)
m.difficulty = sel
adaptiveColors := theme.NewAdaptiveColors(a.th)
diffColors := adaptiveColors.GetDifficultyColors()
hex := diffColors[sel]
if hex == "" {
hex = a.th.Palette.Accent
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
m.styles.RowSep = style
m.styles.ColSep = style
m.styles.CellFixed = m.styles.CellFixed.Foreground(lipgloss.Color(hex))
return m, m.Init()
}
func (a App) viewMenu() string {
banner := bannerArt
// Options
optAC := fmt.Sprintf("Auto-Check (a): %s", boolText(a.styles, a.autoCheck))
optTM := fmt.Sprintf("Timer (t): %s", boolText(a.styles, a.timerEnabled))
// Adaptive colors
adaptiveColors := theme.NewAdaptiveColors(a.th)
accentColors := adaptiveColors.GetAccentColors()
// Display all menu items
var items []string
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["selected"])).Bold(true)
for i, name := range a.menuItems {
prefix := " "
if i == a.selectedIdx {
prefix := "✭ "
label := prefix + name
items = append(items, selectedStyle.Render(label))
} else {
label := prefix + name
items = append(items, a.styles.MenuItem.Render(label))
}
}
gap := strings.Repeat(" ", 2)
diffRow := strings.Join(items, gap)
// Adaptive gradient colors
gradientColors := adaptiveColors.GetGradientColors()
bannerGrad := gradientColors["banner"]
leftHex := bannerGrad[0]
rightHex := bannerGrad[1]
title := gradientText("Select option", leftHex, rightHex)
box := renderGradientBox(diffRow, 2, leftHex, rightHex)
// Gradient banner (line by line)
var gb strings.Builder
for i, l := range strings.Split(strings.TrimRight(banner, "\n"), "\n") {
gb.WriteString(gradientText(l, leftHex, rightHex))
if i < len(strings.Split(strings.TrimRight(banner, "\n"), "\n"))-1 {
gb.WriteString("\n")
}
}
gradientBanner := gb.String()
// Compose content with explicit 2-line top/bottom padding
content := "\n\n" + gradientBanner + "\n\n\n" + optAC + "\n" + optTM + "\n\n\n" + title + "\n" + box + "\n\n"
panel := a.styles.Panel.Render(content)
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
func boolText(s UIStyles, v bool) string {
if v {
return s.BoolTrue.Render("ON")
}
return s.BoolFalse.Render("OFF")
}
func (a App) viewGame() string {
innerWidth := 58
boardAndStatus := Render(a.game)
label := a.currentDiff
if a.currentDiff == "Daily" {
label = "Daily Seed"
}
headerText := label + " Mode"
// Adaptive colors for headers
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
var header string
switch a.currentDiff {
case "Easy":
easyGrad := gradientColors["easy"]
header = gradientText(headerText, easyGrad[0], easyGrad[1])
case "Normal":
normalGrad := gradientColors["normal"]
header = gradientText(headerText, normalGrad[0], normalGrad[1])
case "Hard":
hardGrad := gradientColors["hard"]
header = gradientText(headerText, hardGrad[0], hardGrad[1])
case "Lunatic":
lunaticGrad := gradientColors["lunatic"]
header = gradientText(headerText, lunaticGrad[0], lunaticGrad[1])
case "Daily":
dailyGrad := gradientColors["daily"]
header = gradientText(headerText, dailyGrad[0], dailyGrad[1])
default:
header = lipgloss.NewStyle().Foreground(lipgloss.Color(a.th.Palette.Accent)).Bold(true).Render(headerText)
}
headerCentered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, header)
centered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, boardAndStatus)
body := "\n" + headerCentered + "\n\n" + centered + "\n"
panel := a.styles.Panel.Render(body)
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
// Helpers: gradient text and gradient bordered box
func renderGradientBox(content string, padX int, leftHex, rightHex string) string {
w := lipgloss.Width(content) + padX*2
top := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╭") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╮")
bottom := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╰") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╯")
left := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("│")
right := lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("│")
middle := left + strings.Repeat(" ", padX) + content + strings.Repeat(" ", padX) + right
return strings.Join([]string{top, middle, bottom}, "\n")
}
func gradientLine(ch string, width int, fromHex, toHex string) string {
colors := gradientColors(fromHex, toHex, width)
var b strings.Builder
for i := 0; i < width; i++ {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[i])).Render(ch))
}
return b.String()
}
func gradientText(text, leftHex, rightHex string) string {
colors := gradientColors(leftHex, rightHex, len(text))
var b strings.Builder
idx := 0
for _, ch := range text { // rune-safe
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[idx])).Bold(true).Render(string(ch)))
idx++
}
return b.String()
}
func gradientColors(fromHex, toHex string, steps int) []string {
r1, g1, b1 := hexToRGB(fromHex)
r2, g2, b2 := hexToRGB(toHex)
out := make([]string, steps)
for i := 0; i < steps; i++ {
if steps == 1 {
out[i] = fromHex
continue
}
t := float64(i) / float64(steps-1)
r := int(float64(r1) + (float64(r2)-float64(r1))*t)
g := int(float64(g1) + (float64(g2)-float64(g1))*t)
b := int(float64(b1) + (float64(b2)-float64(b1))*t)
out[i] = fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
return out
}
func hexToRGB(hex string) (int, int, int) {
h := strings.TrimPrefix(hex, "#")
if len(h) != 6 {
return 255, 255, 255
}
var r, g, b int
fmt.Sscanf(h, "%02x%02x%02x", &r, &g, &b)
return r, g, b
}
func (a App) viewAchievements() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
achGrad := gradientColors["banner"]
title := gradientText("Achievements", achGrad[0], achGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
unlockedCount := a.achievements.GetUnlockedCount()
totalCount := a.achievements.GetTotalCount()
progressText := fmt.Sprintf("Unlocked: %d/%d\n\n", unlockedCount, totalCount)
content.WriteString(a.styles.Status.Render(progressText))
achievementOrder := []string{
"first_win", "perfectionist", "speed_demon", "no_mistakes",
"streak_master", "lunatic_legend", "daily_devotee", "century",
}
for _, id := range achievementOrder {
if ach, ok := a.achievements.Achievements[id]; ok {
var line string
if ach.Unlocked {
line = fmt.Sprintf("%s %s - %s",
ach.Icon,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#10b981")).Render(ach.Name),
ach.Description)
} else {
progressBar := ""
if ach.Target > 1 {
progressBar = fmt.Sprintf(" [%d/%d]", ach.Progress, ach.Target)
}
line = fmt.Sprintf("%s %s - %s%s",
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render("🔒"),
lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca3af")).Render(ach.Name),
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(ach.Description),
progressBar)
}
content.WriteString(line + "\n")
}
}
content.WriteString("\n" + a.styles.Status.Render("Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
func (a App) viewLeaderboard() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
leaderGrad := gradientColors["banner"]
title := gradientText("Leaderboard", leaderGrad[0], leaderGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
difficulties := []string{"Easy", "Normal", "Hard", "Lunatic"}
for _, diff := range difficulties {
diffColors := adaptiveColors.GetDifficultyColors()
diffColor := diffColors[diff]
diffHeader := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(diffColor)).Render(diff)
content.WriteString(diffHeader + ":\n")
leaderboard := a.stats.GetLeaderboard(diff, 5)
if len(leaderboard) == 0 {
content.WriteString(a.styles.Status.Render(" No records yet\n\n"))
} else {
for i, record := range leaderboard {
medal := " "
switch i {
case 0:
medal = "🥇"
case 1:
medal = "🥈"
case 2:
medal = "🥉"
}
timeStr := stats.FormatTime(record.Time)
line := fmt.Sprintf("%s %d. %s", medal, i+1, timeStr)
if record.HintsUsed > 0 {
line += fmt.Sprintf(" (%d hints)", record.HintsUsed)
}
content.WriteString(a.styles.Status.Render(line + "\n"))
}
content.WriteString("\n")
}
}
content.WriteString(a.styles.Status.Render("Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}

287
internal/ui/profile.go Normal file
View File

@@ -0,0 +1,287 @@
package ui
import (
"fmt"
"strings"
"termdoku/internal/database"
"termdoku/internal/stats"
"termdoku/internal/theme"
"github.com/charmbracelet/lipgloss"
)
// viewProfile displays a simple profile landing page with navigation options.
func (a App) viewProfile() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
profileGrad := gradientColors["banner"]
title := gradientText("User Profile", profileGrad[0], profileGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
content.WriteString(a.renderProfileSummary())
content.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Bold(true)
content.WriteString(instructionStyle.Render("Navigation:") + "\n")
content.WriteString(a.styles.Status.Render(" Press 's' to view detailed profile data (Stats, Achievements, Leaderboard)"))
content.WriteString("\n")
content.WriteString(a.styles.Status.Render(" Press 'd' to view database info"))
content.WriteString("\n")
content.WriteString(a.styles.Status.Render(" Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
// viewProfileSubmenu displays a dropdown menu for selecting profile data type.
func (a App) viewProfileSubmenu() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
profileGrad := gradientColors["banner"]
title := gradientText(">> Select Profile Data", profileGrad[0], profileGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
var items []string
accentColors := adaptiveColors.GetAccentColors()
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(accentColors["selected"])).
Bold(true)
for i, name := range a.profileMenuItems {
if i == a.profileSelectedIdx {
prefix := "▶ "
label := prefix + name
items = append(items, selectedStyle.Render(label))
} else {
prefix := " "
label := prefix + name
items = append(items, a.styles.MenuItem.Render(label))
}
}
menuContent := strings.Join(items, "\n")
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(profileGrad[0])).
Padding(1, 2).
MarginTop(1).
MarginBottom(1)
content.WriteString(menuStyle.Render(menuContent))
content.WriteString("\n\n")
content.WriteString(a.styles.Status.Render("Use ↑/↓ or k/j to navigate, Enter to select"))
content.WriteString("\n")
content.WriteString(a.styles.Status.Render("Press 'm' or Esc to return to profile"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
// renderProfileSummary renders the user summary card.
func (a App) renderProfileSummary() string {
var sb strings.Builder
totalGames := a.stats.TotalGames
completed := a.stats.CompletedGames
winRate := 0.0
if totalGames > 0 {
winRate = float64(completed) / float64(totalGames) * 100
}
rank := a.calculateRank(completed)
rankEmoji := a.getRankEmoji(rank)
summaryStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
Padding(1, 2).
MarginTop(1)
summaryContent := fmt.Sprintf(
"%s Rank: %s"+
"\n>> Total Games: %d"+
"\n>> Completed: %d"+
"\n>> Win Rate: %.1f%%"+
"\n>> Current Streak: %d days"+
"\n>> Best Streak: %d days",
rankEmoji, rank,
totalGames,
completed,
winRate,
a.stats.CurrentStreak,
a.stats.BestStreak,
)
sb.WriteString(summaryStyle.Render(summaryContent))
return sb.String()
}
// calculateRank determines user rank based on completed games.
func (a App) calculateRank(completed int) string {
switch {
case completed >= 1000:
return "Grandmaster"
case completed >= 500:
return "Master"
case completed >= 250:
return "Expert"
case completed >= 100:
return "Advanced"
case completed >= 50:
return "Intermediate"
case completed >= 25:
return "Apprentice"
case completed >= 10:
return "Novice"
case completed >= 1:
return "Beginner"
default:
return "Newcomer"
}
}
// getRankEmoji returns an emoji for the rank.
func (a App) getRankEmoji(rank string) string {
switch rank {
case "Grandmaster":
return "👑"
case "Master":
return "🎖️"
case "Expert":
return "🏅"
case "Advanced":
return "⭐"
case "Intermediate":
return "🌟"
case "Apprentice":
return "✨"
case "Novice":
return "🔰"
case "Beginner":
return "🌱"
default:
return "👤"
}
}
// viewDatabaseInfo displays detailed database information.
func (a App) viewDatabaseInfo() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
dbGrad := gradientColors["banner"]
title := gradientText("Database Info", dbGrad[0], dbGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
db, err := database.Open()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error opening database: %v\n", err)))
} else {
defer db.Close()
dbStats, err := db.GetStats()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading stats: %v\n", err)))
} else {
statsStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
Padding(1, 2)
statsContent := fmt.Sprintf(
"Total Games: %d"+
"\n>> Completed Games: %d"+
"\n>> Current Streak: %d"+
"\n>> Best Streak: %d"+
"\n>> Hints Used: %d"+
"\n>> Last Played: %s",
dbStats.TotalGames,
dbStats.CompletedGames,
dbStats.CurrentStreak,
dbStats.BestStreak,
dbStats.HintsUsed,
dbStats.LastPlayedDate,
)
content.WriteString(statsStyle.Render(statsContent))
}
content.WriteString("\n\n")
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Render("Achievements")
content.WriteString(headerStyle + "\n")
achievements, err := db.GetAchievements()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading achievements: %v\n", err)))
} else {
unlockedCount := 0
for _, ach := range achievements {
if ach.Unlocked {
unlockedCount++
}
}
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Unlocked: %d/%d\n", unlockedCount, len(achievements))))
}
content.WriteString("\n")
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Render("Recent Games")
content.WriteString(headerStyle + "\n")
recentGames, err := db.GetRecentGames(5)
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading games: %v\n", err)))
} else {
if len(recentGames) == 0 {
content.WriteString(a.styles.Status.Render("No games in database\n"))
} else {
for _, game := range recentGames {
statusIcon := "✅"
if !game.Completed {
statusIcon = "❌"
}
timeStr := stats.FormatTime(game.TimeSeconds)
line := fmt.Sprintf("%s %s - %s", statusIcon, game.Difficulty, timeStr)
if game.IsDaily {
line += " 📅"
}
content.WriteString(a.styles.Status.Render(line + "\n"))
}
}
}
}
content.WriteString("\n" + a.styles.Status.Render("Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}

234
internal/ui/renderer.go Normal file
View File

@@ -0,0 +1,234 @@
package ui
import (
"fmt"
"strings"
"time"
"termdoku/internal/game"
"github.com/charmbracelet/lipgloss"
)
func boardString(m Model) string {
var b strings.Builder
var dup [9][9]bool
if m.autoCheck {
dup = game.DuplicateMap(m.board.Values, m.cursorRow, m.cursorCol)
}
var conf [9][9]bool
if m.autoCheck {
conf = game.ConflictMap(m.board.Values, m.board.Given)
}
cellWidth := lipgloss.Width(m.styles.Cell.Render("0"))
buildLine := func(left, mid, right string) string {
seg := strings.Repeat("─", cellWidth)
var sb strings.Builder
sb.WriteString(left)
for c := range 9 {
sb.WriteString(seg)
switch c {
case 8:
sb.WriteString(right)
case 2, 5:
sb.WriteString(mid)
}
}
return sb.String()
}
topBorder := m.styles.RowSep.Render(buildLine("╭", "┬", "╮"))
midBorder := m.styles.RowSep.Render(buildLine("├", "┼", "┤"))
botBorder := m.styles.RowSep.Render(buildLine("╰", "┴", "╯"))
b.WriteString(topBorder)
b.WriteString("\n")
for r := range 9 {
b.WriteString(m.styles.ColSep.Render("│"))
for c := range 9 {
if c > 0 && c%3 == 0 {
b.WriteString(m.styles.ColSep.Render("│"))
}
cell := m.cellView(r, c, dup[r][c], conf[r][c])
b.WriteString(cell)
}
b.WriteString(m.styles.ColSep.Render("│"))
b.WriteString("\n")
if r == 2 || r == 5 {
b.WriteString(midBorder)
b.WriteString("\n")
}
}
b.WriteString(botBorder)
return b.String()
}
func Render(m Model) string {
if m.showHelp {
return renderHelpOverlay(m)
}
if m.paused {
return renderPauseScreen(m)
}
board := boardString(m)
status := lipgloss.PlaceHorizontal(46, lipgloss.Center, m.StatusLine())
// Win animation
if m.showWinAnim && time.Since(m.winAnimStart) < 2*time.Second {
winMsg := renderWinAnimation(m)
return board + "\n\n" + winMsg + "\n" + status
}
return board + "\n\n\n" + status
}
func (m Model) cellView(r, c int, isDup, isConf bool) string {
v := m.board.Values[r][c]
str := "·"
// Show notes if cell is empty and has notes
if v == 0 {
if notes, ok := m.notes[[2]int{r, c}]; ok && len(notes) > 0 {
// Show first note as indicator
str = string('₀' + rune(notes[0])) // subscript numbers
}
} else {
str = string('0' + v)
}
style := m.styles.Cell
if !m.showHelp && !m.paused && !m.completed {
inSameRow := r == m.cursorRow
inSameCol := c == m.cursorCol
inSameBox := (r/3 == m.cursorRow/3) && (c/3 == m.cursorCol/3)
if !inSameRow && !inSameCol && !inSameBox {
dimColor := "#555555"
if m.theme.Name == "light" {
dimColor = "#d1d5db"
}
style = style.Foreground(lipgloss.Color(dimColor))
} else if inSameRow || inSameCol || inSameBox {
highlightColor := "#6b7280"
if m.theme.Name == "light" {
highlightColor = "#9ca3af"
}
if r != m.cursorRow || c != m.cursorCol {
style = style.Foreground(lipgloss.Color(highlightColor))
}
}
}
if m.board.Given[r][c] {
style = m.styles.CellFixed
}
if isDup {
style = m.styles.CellDuplicate
}
if isConf {
style = m.styles.CellConflict
}
if r == m.cursorRow && c == m.cursorCol {
style = m.styles.CellSelected
}
if deadline, ok := m.flashes[[2]int{r, c}]; ok {
if time.Now().Before(deadline) {
style = style.Bold(true)
}
}
return style.Render(str)
}
func renderPauseScreen(m Model) string {
pauseMsg := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#fbbf24")).
Render("⏸ PAUSED")
hint := m.styles.Status.Render("Press 'p' to resume")
content := pauseMsg + "\n\n" + hint
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#fbbf24")).
Padding(2, 4).
Render(content)
return lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, box)
}
func renderHelpOverlay(m Model) string {
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(m.theme.Palette.Accent)).
Render("KEYBOARD SHORTCUTS")
helpItems := []struct {
keys string
desc string
}{
{"↑↓←→ / hjkl", "Move cursor"},
{"1-9", "Enter number"},
{"0 / Space", "Clear cell"},
{"n", "Toggle note mode"},
{"u / Ctrl+Z", "Undo"},
{"Ctrl+Y / Ctrl+R", "Redo"},
{"Ctrl+H", "Get hint"},
{"a", "Toggle auto-check"},
{"t", "Toggle timer"},
{"p", "Pause game"},
{"m", "Main menu"},
{"?", "Toggle this help"},
{"q / Esc", "Quit"},
}
var helpText strings.Builder
for _, item := range helpItems {
key := lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")).Bold(true).Render(item.keys)
desc := m.styles.Status.Render(item.desc)
helpText.WriteString(fmt.Sprintf(" %-25s %s\n", key, desc))
}
content := title + "\n\n" + helpText.String() + "\n" +
m.styles.Status.Render("Press '?' again to close")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(m.theme.Palette.Accent)).
Padding(1, 2).
Render(content)
return lipgloss.Place(80, 30, lipgloss.Center, lipgloss.Center, box)
}
func renderWinAnimation(m Model) string {
elapsed := time.Since(m.winAnimStart).Milliseconds()
confetti := []string{"✨", "🎉", "🎊", "⭐", "💫", "🌟", "✦", "★"}
animPhase := int(elapsed / 150)
var confettiLine strings.Builder
for i := 0; i < 20; i++ {
if (i+animPhase)%3 == 0 {
symbol := confetti[(i+animPhase)%len(confetti)]
confettiLine.WriteString(symbol)
} else {
confettiLine.WriteString(" ")
}
}
colors := []string{"#10b981", "#3b82f6", "#8b5cf6", "#f59e0b", "#ef4444"}
colorIdx := (animPhase / 2) % len(colors)
mainMsg := "🏆 PUZZLE COMPLETE! 🏆"
styledMsg := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colors[colorIdx])).
Render(mainMsg)
return confettiLine.String() + "\n" + styledMsg + "\n" + confettiLine.String()
}

78
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,78 @@
package ui
import (
"termdoku/internal/theme"
"github.com/charmbracelet/lipgloss"
)
type UIStyles struct {
App lipgloss.Style
Panel lipgloss.Style
Banner lipgloss.Style
MenuItem lipgloss.Style
MenuItemSelected lipgloss.Style
Hint lipgloss.Style
BoolTrue lipgloss.Style
BoolFalse lipgloss.Style
Board lipgloss.Style
RowSep lipgloss.Style
ColSep lipgloss.Style
Cell lipgloss.Style
CellFixed lipgloss.Style
CellSelected lipgloss.Style
CellDuplicate lipgloss.Style
CellConflict lipgloss.Style
Status lipgloss.Style
StatusError lipgloss.Style
DiffBox lipgloss.Style
}
func BuildStyles(t theme.Theme) UIStyles {
gridColor := lipgloss.Color(t.Palette.GridLine)
accent := lipgloss.Color(t.Palette.Accent)
// Adaptive colors
adaptiveColors := theme.NewAdaptiveColors(t)
accentColors := adaptiveColors.GetAccentColors()
gray := lipgloss.Color("#9ca3af")
if t.Name == "light" {
gray = lipgloss.Color("#6b7280") // darker gray for light theme
}
menuItemColor := lipgloss.Color(t.Palette.Foreground)
statusColor := gray
if t.Name == "light" {
menuItemColor = lipgloss.Color("#000000")
statusColor = menuItemColor
}
return UIStyles{
App: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)),
Panel: lipgloss.NewStyle().Padding(0, 4).Margin(1, 4).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(accentColors["panel"])),
Banner: lipgloss.NewStyle().Foreground(accent).Bold(true),
MenuItem: lipgloss.NewStyle().Foreground(menuItemColor),
MenuItemSelected: lipgloss.NewStyle().Foreground(accent).Bold(true),
Hint: lipgloss.NewStyle().Foreground(accent),
BoolTrue: lipgloss.NewStyle().Foreground(lipgloss.Color("#16a34a")).Bold(true),
BoolFalse: lipgloss.NewStyle().Foreground(gray),
Board: lipgloss.NewStyle(),
RowSep: lipgloss.NewStyle().Foreground(gridColor),
ColSep: lipgloss.NewStyle().Foreground(gridColor),
Cell: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellBaseBG)).Foreground(lipgloss.Color(t.Palette.CellBaseFG)).Padding(0, 1),
CellFixed: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellFixedBG)).Foreground(lipgloss.Color(t.Palette.CellFixedFG)).Padding(0, 1).Bold(true),
CellSelected: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellSelectedBG)).Foreground(lipgloss.Color(t.Palette.CellSelectedFG)).Padding(0, 1).Bold(true),
CellDuplicate: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellDuplicateBG)).Padding(0, 1),
CellConflict: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellConflictBG)).Padding(0, 1).Bold(true),
Status: lipgloss.NewStyle().Foreground(statusColor),
StatusError: lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["error"])).Bold(true),
DiffBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 4),
}
}

11
things-todo.md Normal file
View File

@@ -0,0 +1,11 @@
## 📈 Roadmap
- [ ] Multiplayer mode
- [ ] Puzzle import/export
- [ ] More solving techniques detection
- [ ] Puzzle difficulty predictor using ML
- [ ] Mobile app version
- [ ] Web version
- [ ] Tournament mode
- [ ] Custom puzzle editor