diff --git a/dashboard-spec.md b/dashboard-spec.md new file mode 100644 index 0000000..86edae7 --- /dev/null +++ b/dashboard-spec.md @@ -0,0 +1,271 @@ +# Pi Dashboard — Project Spec + +## Goal + +An always-on Raspberry Pi Zero W plugged into a TV, driving a rotating +family dashboard at the calm, steady cadence of The Weather Channel's +"Local on the 8s." Weather is the first slide; more will follow over time. + +## Hardware Target + +- **Raspberry Pi Zero W** (single-core ARM1176 @ 1GHz, 512 MB RAM) +- HDMI output to a TV +- Running **Raspberry Pi OS Lite 32-bit** (Bookworm) — no desktop environment +- Co-located with **Pi-hole** (~60-80 MB RAM, negligible CPU) +- Always on, boots unattended + +## Architecture + +### Display: Pillow → `/dev/fb0` via mmap + +No window manager, no browser, no X11. Render each slide as a +`PIL.Image`, then blit it directly to the Linux framebuffer. + +``` +┌─────────────────────────────────────────────────┐ +│ main loop │ +│ │ +│ for each slide in rotation: │ +│ data = slide.fetch() # network I/O │ +│ image = slide.render(w, h, theme) │ +│ fb.write(image) # mmap blit │ +│ sleep(interval) │ +└─────────────────────────────────────────────────┘ +``` + +Each frame: ~200-500 ms Pillow render + ~50 ms framebuffer write. +Refresh interval: 10-30 seconds. CPU is idle >95% of the time. + +### Dual output (optional, for development) + +- **Framebuffer** (`/dev/fb0`): primary output to the TV +- **Curses** (terminal): secondary output for SSH debugging — + renders the same data as coloured text, adapts to terminal size + +The curses path is not required for v1 but the data/render separation +makes it easy to add later. + +### File structure + +``` +dashboard/ +├── main.py # entry point: slide loop, timing, signal handling +├── framebuffer.py # open /dev/fb0, mmap, query size, blit PIL.Image +├── render.py # shared drawing helpers (header bar, separator, +│ # centered text, detail columns, etc.) +├── theme.py # colour palettes (WeatherStar blue/gold is the +│ # first; more can be added later) +├── config.py # settings: API keys, slide order, intervals, +│ # location, units (F/C), theme selection +├── slides/ +│ ├── __init__.py +│ ├── base.py # Slide base class / protocol +│ ├── weather.py # current conditions + forecast (first slide) +│ └── clock.py # large clock display (simple second slide) +└── fonts/ # TTF fonts bundled for consistent rendering + └── (a small open-licensed font, e.g. DejaVu Sans Mono or Inter) +``` + +## Slide contract + +Each slide is a module that implements: + +```python +def fetch(config) -> dict: + """Fetch data from network/system. Called before render. + May be skipped if cached data is fresh enough.""" + +def render(data: dict, width: int, height: int, theme: Theme) -> PIL.Image: + """Return a PIL.Image sized to the framebuffer dimensions. + Must not do network I/O — all data comes from fetch().""" +``` + +Separating fetch from render means: +- Network errors don't block display (show stale data with a staleness indicator) +- Slides are testable without a framebuffer (render returns an Image you can .save()) +- New slides are added by creating a new file in `slides/` and adding it to config + +## Framebuffer interface (`framebuffer.py`) + +```python +class Framebuffer: + def __init__(self, device="/dev/fb0"): + # open device, ioctl FBIOGET_VSCREENINFO for width/height/bpp + # mmap the device for direct pixel writes + + @property + def size(self) -> tuple[int, int]: + """(width, height) in pixels, queried from hardware.""" + + def write(self, image: PIL.Image): + """Blit a PIL.Image to the screen. + Handles RGB→BGR conversion (Pi framebuffer is BGR). + Resizes image if it doesn't match framebuffer dimensions.""" + + def close(self): + """Unmap and close.""" +``` + +### Pi-specific notes + +- Pixel format is typically XRGB8888 (32-bit) or RGB565 (16-bit) — + detect via `ioctl`, don't hardcode +- Pi framebuffer byte order is **BGR**, not RGB — + use `image.tobytes("raw", "BGRA")` for 32-bit +- Set `hdmi_force_hotplug=1` in `/boot/firmware/config.txt` + so `/dev/fb0` exists even if the TV is off at boot +- User must be in the `video` group (default `pi` user already is) + +## Theme system (`theme.py`) + +```python +@dataclass +class Theme: + bg_top: tuple[int, int, int] # background gradient top + bg_bottom: tuple[int, int, int] # background gradient bottom + header_bg: tuple[int, int, int] + accent: tuple[int, int, int] # gold bars, separators + text: tuple[int, int, int] # primary text (white) + text_dim: tuple[int, int, int] # secondary text (light gray) + info_label: tuple[int, int, int] # cyan labels + temp_hi: tuple[int, int, int] + temp_lo: tuple[int, int, int] + +WEATHERSTAR = Theme( + bg_top=(20, 40, 120), bg_bottom=(5, 15, 60), + header_bg=(40, 70, 170), accent=(255, 200, 50), + text=(255, 255, 255), text_dim=(180, 190, 210), + info_label=(100, 220, 255), + temp_hi=(255, 100, 80), temp_lo=(100, 180, 255), +) +``` + +## Weather slide (`slides/weather.py`) + +### Data source + +Any free weather API that serves JSON. Candidates: +- **Open-Meteo** (free, no API key, has hourly + daily forecasts) +- **OpenWeatherMap** (free tier, requires API key) +- **National Weather Service API** (free, US only, no key) + +`fetch()` returns a dict: + +```python +{ + "location": "San Francisco, CA", + "temp": 62, + "condition": "Partly Cloudy", + "humidity": 72, + "wind": "W 12 mph", + "barometer": "30.12 in", + "dewpoint": 54, + "visibility": "10 mi", + "uv_index": "3 Moderate", + "forecast": [ + {"day": "SAT", "hi": 68, "lo": 52, "condition": "Sunny"}, + {"day": "SUN", "hi": 65, "lo": 50, "condition": "Cloudy"}, + ... + ], + "fetched_at": "2026-02-23T14:30:00", +} +``` + +### Layout + +Adapts to framebuffer resolution. The WeatherStar layout from the +C prototype is the reference design: + +``` +┌─────────────────────────────────────────────┐ +│ ▬▬▬▬▬▬▬▬ gold accent bar ▬▬▬▬▬▬▬▬▬▬▬▬▬▬ │ +│ FAMILY DASHBOARD │ +│ ═══════════════════════════════════════════ │ +│ LOCAL FORECAST │ +│ ─────────────────────────────────────────── │ +│ San Francisco, CA │ +│ ┌───────────────────────────────────────┐ │ +│ │ Current Conditions │ │ +│ │ ☀☁ 62°F Partly Cloudy │ │ +│ │ Humidity: 72% Dewpoint: 54°F │ │ +│ │ Wind: W 12 mph Visibility: 10 mi │ │ +│ └───────────────────────────────────────┘ │ +│ ═══════════════════════════════════════════ │ +│ ┌─────┬─────┬─────┬─────┬─────┐ │ +│ │ SAT │ SUN │ MON │ TUE │ WED │ │ +│ │Hi 68│Hi 65│Hi 58│Hi 61│Hi 70│ │ +│ │Lo 52│Lo 50│Lo 48│Lo 49│Lo 54│ │ +│ └─────┴─────┴─────┴─────┴─────┘ │ +│ Saturday Feb 21, 2026 10:25 PM │ +└─────────────────────────────────────────────┘ +``` + +## Main loop (`main.py`) + +``` +1. open framebuffer +2. load config (slide list, intervals, API keys, theme) +3. loop forever: + for slide_module in config.slides: + data = slide_module.fetch(config) # with cache / error handling + image = slide_module.render(data, fb.width, fb.height, theme) + fb.write(image) + sleep(config.interval) # 10-30 seconds +``` + +### Error handling + +- Network failures: show last cached data with a "last updated" timestamp +- Framebuffer errors: log and retry +- Unhandled exceptions in a slide: skip it, log, continue to next slide +- SIGTERM/SIGINT: clean shutdown (unmap framebuffer, clear screen) + +## Dependencies + +``` +# requirements.txt +pillow # image rendering — pre-installed on RPi OS +requests # HTTP for weather API (or use stdlib urllib) +``` + +No compiled extensions. No C libraries beyond what ships with RPi OS. + +## Deployment + +- Clone to the Pi +- `pip install -r requirements.txt` (likely already satisfied) +- Auto-start via systemd unit: + +```ini +[Unit] +Description=Family Dashboard +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/bin/python3 /home/pi/dashboard/main.py +Restart=always +RestartSec=5 +User=pi +Group=video + +[Install] +WantedBy=multi-user.target +``` + +## Non-goals for v1 + +- No touch input / interactivity +- No web interface for configuration (edit config.py directly) +- No animations or transitions between slides (instant swap) +- No remote management beyond SSH + +## Future slide ideas + +- **Clock** — large readable time/date display +- **Calendar** — upcoming events from an ICS feed +- **Transit** — next bus/train departure times +- **Photo** — rotating family photos from a directory +- **Chores / reminders** — simple text list +- **Air quality / pollen** — additional weather data +- **Pi-hole stats** — queries blocked today (since it's right there) diff --git a/misc/task-timer.html b/misc/task-timer.html new file mode 100644 index 0000000..214441e --- /dev/null +++ b/misc/task-timer.html @@ -0,0 +1,339 @@ + + + + + + 15-Minute Task Triage Timer + + + +
+

15-Minute Task Triage

+ +
Ready to start
+ +
15:00
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/prompts-library/README.md b/prompts-library/README.md index a203f3a..73636dd 100644 --- a/prompts-library/README.md +++ b/prompts-library/README.md @@ -4,3 +4,8 @@ Useful prompts I'd like to share. - [learn-interactive-general.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/learn-interactive-general.md): Short, general interactive learning prompt with step-by-step guidance and checks for understanding. - [learn-interactive-general-variation-longer.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/learn-interactive-general-variation-longer.md): Longer variation with explicit teaching rules, practical examples, checkpoints, and exercises. + +**Coding** + +- [CODER-interactive.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/CODER-interactive.md): Interactive coding role prompt focused on test-driven developemnt, frequent commits, and small implementation steps. +- [DOC-REVIEW-COACH.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md) and [SPEC-ANALYSIS-COACH.md](https://github.com/devp/psychic-meme/blob/main/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md) for reviewing code-related documents diff --git a/prompts-library/coding/roles/CODER.md b/prompts-library/coding/roles/CODER.md new file mode 100644 index 0000000..5b47fa2 --- /dev/null +++ b/prompts-library/coding/roles/CODER.md @@ -0,0 +1,35 @@ +# Role: CODER +You are implementing an engineering task. +This is a coding + verification task. + +# Implementation Prompt +Goal: implement the next step, prove it with tests, and get user approval. Then repeat until all steps are completed. + +## Runtime parameter +`EXECUTION_MODE`: +- `interactive` (default): stop after each step and wait for user approval. +- `autopilot`: continue step-by-step without pausing; only stop when scope is done, blocked, or risk changes materially. + +## Hard constraints +- One step at a time; stop when that step is done. +- Prefer minimal diffs; do not change unrelated files. +- Avoid formatting-only changes unless required for the step. +- If blocked by ambiguity, ask questions. +- Stop and ask before proceeding if risk changes materially (for example: schema/data migration impact, auth/security/privacy behavior, public API contract changes, or production reliability risk). +- Tests should be high-signal: validate behavior/risk, not implementation details; prefer fewer robust tests over brittle change-detectors. +- If an existing test is low-value, redundant, or a change-detector, improve it or remove it in the same step. + +## Required loop (per step) +1) Propose: 1–3 bullets (what you’ll do; target files/tests; risks). +2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit. +3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit. +4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit. +5) LOOP REVIEW (brief): review the RED/GREEN/REFACTOR commits for side effects, test quality, and obvious simplifications; keep it shallow. + - If issues are found, fix immediately and re-run minimal relevant tests. + +## End-of-scope gate (before checking with user) +When all scoped steps are complete, run a full branch review before asking for user input: +1) Review the cumulative diff and commit series for correctness, regressions, and maintainability. +2) Re-check test quality (remove/replace weak tests; keep only high-signal coverage). +3) Run an appropriate final verification pass and report results + residual risk. +4) Then present summary, findings/fixes, and any open questions to user. diff --git a/prompts-library/coding/roles/wip/ARCHITECT.md b/prompts-library/coding/roles/wip/ARCHITECT.md new file mode 100644 index 0000000..b55ffdf --- /dev/null +++ b/prompts-library/coding/roles/wip/ARCHITECT.md @@ -0,0 +1,36 @@ +# Role: ARCHITECT +You are decomposing a user-provided *Work Packet* into a repo-aware execution plan. +This is a reasoning + repo-reading task. It is NOT a coding task. +This work emerges from an agile user-focused startup where requirements may be vague or subject to change. + +# Decomposition Prompt +Goal: turn a Work Packet + target repo into `coder_plan.md`: a set of concrete assertions (what files/symbols matter) and numbered, CODER-ready steps. + +Suggest changes, then if approved, write `coder_plan.md` and commit it. This may be an incremental loop. + +## Inputs +- The Work Packet (requirements, constraints, acceptance criteria, plan). +- The repo state on disk (read files; map likely touch points). + +## What you produce (`coder_plan.md`) +- A short restatement of the goal + key constraints/non-goals. +- Repo map: the specific files/symbols that likely matter (and why). +- Tests: the most relevant test command(s) to run first, and what “green” means. +- A numbered step list sized for CODER: + - Each step is a single, incremental change that CODER can implement with its normal RED→GREEN→(optional)REFACTOR loop. + - Each step should name the target files/symbols and the test focus (what to assert, not how to code it). + - Keep steps small enough to fit clean, reviewable commits. + - Prefer `1. [ ] ...` so CODER/CODER-batch can check off progress. +- Open questions / risks that block accurate slicing. + +## Defaults (unless the Work Packet overrides) +- Write `coder_plan.md` next to the Work Packet; if that’s ambiguous, ask where to place it. +- Prefer 5–15 steps. +- Prefer steps that introduce/adjust behavior behind stable boundaries (APIs, adapters) and keep blast radius contained. + +## Interactive loop +- Ask only high-leverage questions needed to slice work into safe increments. +- If you find a mismatch between Work Packet and repo reality, call it out and propose the least risky resolution. + +## Stopping condition +Stop after `coder_plan.md` is approved, written, and committed. diff --git a/prompts-library/coding/roles/wip/CODER-batch.md b/prompts-library/coding/roles/wip/CODER-batch.md new file mode 100644 index 0000000..80122f5 --- /dev/null +++ b/prompts-library/coding/roles/wip/CODER-batch.md @@ -0,0 +1,29 @@ +# Role: CODER +You are implementing an engineering task from a user-provided *Work Packet*. +This is a coding + verification task. +This work emerges from an agile user-focused startup where requirements may be vague or subject to change. + +# Implementation Prompt +Goal: implement `coder_plan.md` steps, prove each with tests, refactor if needed. Continue until all planned steps are complete. + +## Source of truth +- Read the Work Packet the user points you at. +- Read `coder_plan.md` (generated by ARCHITECT). +- Implement ONLY the steps in `coder_plan.md`, in order, treating them as confirmed. + +## Hard constraints +- Prefer minimal diffs; do not change unrelated files. +- Avoid formatting-only changes unless required for the step. +- If blocked by ambiguity, ask questions. + +## Required loop (per step) +1) Output: 1–3 bullets (what you’ll do; target files/tests; risks). Then proceed to RED. +2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit. +3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit. +4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit. + +After completing a step, check it off in `coder_plan.md` (e.g. `1. [x] ...`). + +You can make commits on your branch without pausing for user approval. You may move onto other steps of your task without approval. + +Never change other branches. Never push changes to remotes. diff --git a/prompts-library/coding/roles/wip/CODER.md b/prompts-library/coding/roles/wip/CODER.md new file mode 100644 index 0000000..a5e5844 --- /dev/null +++ b/prompts-library/coding/roles/wip/CODER.md @@ -0,0 +1,26 @@ +# Role: CODER +You are implementing an engineering task from a user-provided *Work Packet*. +This is a coding + verification task. +This work emerges from an agile user-focused startup where requirements may be vague or subject to change. + +# Implementation Prompt +Goal: implement the next step, prove it with tests, and get user approval. Then repeat until all steps are completed. + +## Source of truth +- Read the Work Packet the user points you at. +- If present, read `coder_plan.md` (generated by ARCHITECT) and use it to drive step selection. +- Implement ONLY the next step that the user confirms (typically the next unchecked step in `coder_plan.md`). + +## Hard constraints +- One step at a time; stop when that step is done. +- Prefer minimal diffs; do not change unrelated files. +- Avoid formatting-only changes unless required for the step. +- If blocked by ambiguity, ask questions. + +## Required loop (per step) +1) Propose: 1–3 bullets (what you’ll do; target files/tests; risks). +2) RED: add failing test(s) for essential/non-trivial logic; run the smallest relevant test command; confirm failure. Commit. +3) GREEN: implement the smallest change that makes tests pass; re-run tests; confirm success. Commit. +4) REFACTOR (optional): only if it reduces complexity/risk; prove tests still pass. Commit. + +If using `coder_plan.md`, check off the step you completed. diff --git a/prompts-library/coding/roles/wip/FINDER.md b/prompts-library/coding/roles/wip/FINDER.md new file mode 100644 index 0000000..3326b90 --- /dev/null +++ b/prompts-library/coding/roles/wip/FINDER.md @@ -0,0 +1,25 @@ +# Role: FINDER +You are investigating a product/engineering question across systems. +This is a research + synthesis task. + +## Investigation Prompt +Goal: answer the question with evidence, reconcile cross-repo/domain differences, and clearly state confidence and unknowns. The sources to use will be listed in the prompt. + +## Hard constraints +- Evidence first: every substantive claim must point to source evidence. +- Prefer primary sources (code, schema, migrations, API contracts, runbooks) over opinion/docs. +- Distinguish facts from inference explicitly. +- If terms differ by repo/system, identify and reconcile them before final conclusions. +- Keep scope bounded; if new high-risk ambiguity appears, stop and ask. +- Do not propose implementation changes unless asked; this role is for understanding. + +---- + +# WIP / revisit later +- use when the question is cross-repo/system behavior, not single-repo text search +- require evidence-backed output with confidence and explicit unknowns +- invocation pattern to keep context small: + - source manifest in prompt (repo/doc path + priority + expected relevance) + - two-pass investigation: map first, then deepen into only top files + - budget knobs: `MAX_FILES_PER_REPO`, `MAX_TOTAL_FILES`, `MAX_OPEN_QUESTIONS` + - output compact trace + evidence table (claims must cite source) diff --git a/prompts-library/coding/roles/wip/PLANNER.md b/prompts-library/coding/roles/wip/PLANNER.md new file mode 100644 index 0000000..a7f1af0 --- /dev/null +++ b/prompts-library/coding/roles/wip/PLANNER.md @@ -0,0 +1,15 @@ +# Role: PLANNER +You are refining a *Work Packet* for an engineering task from user-provided inputs. +This is a reasoning + synthesis task. It is NOT a coding task. +This work emerges from an agile user-focused startup where requirements may be vague or subject to change. + +# Refinement Interview Prompt +Goal: revise task into a refined work packet, implementation plan, testable acceptance criteria, and decisions. + +Suggest changes, then if approved, modify the work packet (add/change files) and commit changes. This may be an incremental loop. + +If the Work Packet plan is still too high-level to execute safely, propose running ARCHITECT to generate `coder_plan.md`. + +You may ask questions until there is enough context. +You may challenge assumptions and identify vagueness. +You may call out things that have not been considered. diff --git a/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md b/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md new file mode 100644 index 0000000..cba6ac1 --- /dev/null +++ b/prompts-library/coding/roles/work/DOC-REVIEW-COACH.md @@ -0,0 +1,21 @@ +You are my “Spec-Intuition Coach.” I want to understand this doc and leave useful comments, nothing more. + +Spec excerpt(s): [PASTE] +My current understanding (rough): [BULLETS] +My relevant experience: [BULLETS] +Time box: 15–20 min + +Rules: +- Ask up to 3 questions, then wait. +- Teach me interactively: step by step, ask questions to check my understanding. +- Correct misconceptions; introduce concepts only when needed. +- Focus on mental models and reasoning, not just explanations. +- Treat this as a collaborative lesson, not a lecture. +- Use falsification checks to challenge my assumptions. +- Move fast: if unclear, request a quick spike or propose a minimal default. +- Capture only design-impact risks (cost/state/UX/concurrency/compliance). +- No long analysis; minimal prose. + +Output only: +1) 5–10 targeted comments/questions I can paste into the doc +2) 3 “watchouts” (if any) that could change design later diff --git a/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md b/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md new file mode 100644 index 0000000..fa16268 --- /dev/null +++ b/prompts-library/coding/roles/work/SPEC-ANALYSIS-COACH.md @@ -0,0 +1,27 @@ +You are my “Spec-Intuition Coach.” I want to turn this spec into a concrete, safe action plan. + +Spec excerpt(s): [PASTE] +My current understanding (rough): [BULLETS] +My relevant experience: [BULLETS] +Constraints/non-goals (if known): [BULLETS or "unknown"] +Time box: 20–30 min + +Rules: +- Ask up to 3 questions, then wait. +- Teach me interactively: step by step, ask questions to check my understanding. +- Correct misconceptions; introduce concepts only when needed. +- Focus on mental models and reasoning, not just explanations. +- Treat this as a collaborative lesson, not a lecture. +- Challenge my understanding with “what would disprove this?” +- Move fast: if unclear, request a quick spike or propose a minimal default. +- Capture only design-impact risks. +- Minimal prose. + +Finish with a Work Packet: +- Outcome (1–3 bullets) +- Non-goals +- Decisions needed (with defaults) +- Interfaces (API/data/worker) +- Invariants +- First vertical slice +- Next commit (<1h) diff --git a/weatherstar/.gitignore b/weatherstar/.gitignore new file mode 100644 index 0000000..c634997 --- /dev/null +++ b/weatherstar/.gitignore @@ -0,0 +1 @@ +weatherstar diff --git a/weatherstar/Makefile b/weatherstar/Makefile new file mode 100644 index 0000000..903d4be --- /dev/null +++ b/weatherstar/Makefile @@ -0,0 +1,18 @@ +CC ?= gcc +CFLAGS ?= -O2 -Wall -Wextra +LDFLAGS = -lpng -lz -lm +TARGET = weatherstar +HEADERS = fb.h font.h icons.h display.h screenshot.h + +all: $(TARGET) + +$(TARGET): main.c $(HEADERS) + $(CC) $(CFLAGS) -o $@ main.c $(LDFLAGS) + +screenshot: $(TARGET) + ./$(TARGET) --no-ansi --screenshot screenshot.png + +clean: + rm -f $(TARGET) screenshot.png + +.PHONY: all clean screenshot diff --git a/weatherstar/display.h b/weatherstar/display.h new file mode 100644 index 0000000..5626cde --- /dev/null +++ b/weatherstar/display.h @@ -0,0 +1,293 @@ +/* + * display.h — WeatherStar 4000 screen layout composition + * + * ═══════════════════════════════════════════════════════════════════ + * OVERVIEW + * ═══════════════════════════════════════════════════════════════════ + * + * This file contains the single function that paints every element + * of the classic "Local on the 8s" display onto the framebuffer. + * Think of it as the "scene graph" — calling compose_display() fills + * the framebuffer from top to bottom with: + * + * ┌─────────────────────────────────────────────┐ + * │ ████████ gold accent bar (4px) ████████ │ y = 0..3 + * │ THE WEATHER CHANNEL │ y = 4..39 + * │ ════════ gold separator ════════ │ y = 40..41 + * │ LOCAL FORECAST │ y = 42..71 + * │ ──────── rule line ──────── │ y = 73 + * │ San Francisco, CA │ y = 80 + * │ ┌──────────────────────────────────────┐ │ + * │ │ Current Conditions │ │ y = 104..243 + * │ │ ☀ ☁ 62°F Partly Cloudy │ │ + * │ │ Humidity: 72% Dewpoint: 54 F │ │ + * │ │ Wind: W 12 mph Visibility: 10 mi │ │ + * │ │ Barometer: 30.12 UV Index: 3 Mod │ │ + * │ └──────────────────────────────────────┘ │ + * │ ════════ gold separator ════════ │ y = 252 + * │ EXTENDED FORECAST │ + * │ ┌─────┬─────┬─────┬─────┬─────┐ │ + * │ │ SAT │ SUN │ MON │ TUE │ WED │ │ y = 278..372 + * │ └─────┴─────┴─────┴─────┴─────┘ │ + * │ ════════ gold separator ════════ │ y = 370 + * │ Saturday Feb 07 Local on 8s 10:25 PM │ y = 370..399 + * └─────────────────────────────────────────────┘ + * + * ═══════════════════════════════════════════════════════════════════ + * COORDINATE SYSTEM + * ═══════════════════════════════════════════════════════════════════ + * + * All positions are in absolute pixel coordinates (origin = top-left). + * The layout is hardcoded for a 640×400 framebuffer. If you want to + * change the resolution, you'd need to adjust these coordinates — there + * is no relative/responsive layout engine here, and for a nostalgic + * recreation of fixed-resolution hardware, that's by design. + * + * ═══════════════════════════════════════════════════════════════════ + * WEATHER DATA + * ═══════════════════════════════════════════════════════════════════ + * + * The weather data is hardcoded (San Francisco, 62°F, etc.). In a + * real application you'd feed this from an API or config file, but + * for this demo the focus is on faithful visual reproduction of the + * WeatherStar 4000 aesthetic. + */ + +#ifndef DISPLAY_H +#define DISPLAY_H + +#include "fb.h" +#include "font.h" +#include "icons.h" +#include +#include + +static void compose_display(void) { + /* + * ── time and date strings ──────────────────────────────────── + * We format the current local time for the bottom status bar. + * strftime %I gives 12-hour with leading zero, so "08:30 PM". + * We strip the leading zero for the classic WeatherStar look + * ("8:30 PM" not "08:30 PM"). + */ + time_t now = time(NULL); + struct tm *lt = localtime(&now); + char timebuf[64], datebuf[64]; + strftime(timebuf, sizeof timebuf, "%I:%M %p", lt); + strftime(datebuf, sizeof datebuf, "%A %b %d, %Y", lt); + const char *timestr = (timebuf[0] == '0') ? timebuf + 1 : timebuf; + + /* + * ── background gradient ────────────────────────────────────── + * The signature WeatherStar look: a deep blue that gets slightly + * darker toward the bottom of the screen. + */ + fb_clear(); + + /* + * ── top gold accent bar ────────────────────────────────────── + * A 4-pixel-tall gold strip across the very top of the screen. + * This is the first visual cue that you're watching The Weather + * Channel. The gold colour was a TWC brand signature. + */ + fb_rect(0, 0, FB_W, 4, COL_GOLD); + + /* + * ── "THE WEATHER CHANNEL" header ───────────────────────────── + * A gradient-filled banner from y=4 to y=39. The gradient goes + * from a lighter blue (COL_HEADER) to a slightly darker blue, + * giving the bar a sense of depth. The text is rendered at + * scale=2 (10×14 pixel characters) and centred horizontally. + * + * The double spaces ("THE WEATHER CHANNEL") add extra letter + * spacing for the formal, broadcast-TV feel. + */ + fb_rect_grad_v(0, 4, FB_W, 36, COL_HEADER, (rgb_t){30, 55, 140}); + fb_string_centered(10, "THE WEATHER CHANNEL", COL_WHITE, 2); + + /* + * ── gold separator (2px thick) ─────────────────────────────── + * Gold horizontal rules are used throughout the WeatherStar UI + * to divide sections. Two adjacent 1px lines give a 2px rule. + */ + fb_hline(0, FB_W - 1, 40, COL_GOLD); + fb_hline(0, FB_W - 1, 41, COL_GOLD); + + /* + * ── "LOCAL FORECAST" sub-header ────────────────────────────── + * Another gradient bar, slightly darker than the main header. + * The cyan text distinguishes it from the white title above. + */ + fb_rect_grad_v(0, 42, FB_W, 30, (rgb_t){30, 50, 140}, + (rgb_t){20, 35, 110}); + fb_string_centered(48, "LOCAL FORECAST", COL_CYAN, 2); + + /* thin blue rule to separate the location bar from content */ + fb_hline(20, FB_W - 21, 73, COL_SEP); + + /* + * ── location name ──────────────────────────────────────────── + * Centred, white, scale=2. On the real WeatherStar this would + * be pulled from your cable system's location configuration. + */ + fb_string_centered(80, "San Francisco, CA", COL_WHITE, 2); + + /* + * ── current conditions card ────────────────────────────────── + * A rounded rectangle that acts as a "card" containing the main + * weather data. The rounded corners (radius=6) add a bit of + * polish over a plain rectangle. + * + * Inside the card: + * - "Current Conditions" subheading in accent blue + * - A sun + cloud icon on the left (the "partly cloudy" icon) + * - Large temperature text (62°F) — scale=5 for the digits + * - Condition description ("Partly Cloudy") in light gray + * - Two columns of detail readings below + */ + int card_y = 104; + draw_rounded_rect(20, card_y, FB_W - 40, 140, 6, (rgb_t){15, 25, 90}); + + /* top edge highlight — a 1px lighter line simulating a bevel */ + fb_hline(22, FB_W - 23, card_y + 1, COL_SEP); + + fb_string(36, card_y + 8, "Current Conditions", COL_ACCENT, 1); + fb_hline(36, FB_W - 57, card_y + 20, (rgb_t){40, 60, 130}); + + /* + * Weather icon: a sun partially behind a cloud. + * The sun is drawn first (at 90, card_y+55), then the cloud is + * drawn on top and slightly to the right (at 110, card_y+60), + * naturally overlapping the sun to create "partly cloudy". + */ + draw_sun(90, card_y + 55, 18); + draw_cloud(110, card_y + 60, COL_LTGRAY); + + /* + * Large temperature display. The "62" is rendered at scale=5 + * (25×35 pixel characters). The degree symbol is rendered + * separately at scale=3 because it needs to sit as a superscript + * near the top of the digits. The "F" is at scale=4. + * + * The x-position arithmetic: + * "62" = 2 chars × 6px × scale5 = 60px wide + * degree symbol starts at 180 + 60 = 240 + * "F" starts at 240 + 12 (degree width) = 252 + */ + fb_string(180, card_y + 30, "62", COL_WHITE, 5); + fb_degree(180 + 5 * 6 * 2, card_y + 30, COL_WHITE, 3); + fb_string(180 + 5 * 6 * 2 + 12, card_y + 30, "F", COL_WHITE, 4); + + fb_string(180, card_y + 75, "Partly Cloudy", COL_LTGRAY, 2); + + /* + * Detail readings in two columns. Labels are cyan, values are + * white. The columns are aligned by giving labels and values + * fixed x positions (val_x = lbl_x + 11 chars × 6px). + */ + int lbl_x = 36, val_x = 36 + 11 * 6; + fb_string(lbl_x, card_y + 100, "Humidity:", COL_CYAN, 1); + fb_string(val_x, card_y + 100, "72%", COL_WHITE, 1); + fb_string(lbl_x, card_y + 112, "Wind:", COL_CYAN, 1); + fb_string(val_x, card_y + 112, "W 12 mph", COL_WHITE, 1); + fb_string(lbl_x, card_y + 124, "Barometer:", COL_CYAN, 1); + fb_string(val_x, card_y + 124, "30.12 in", COL_WHITE, 1); + + int rlbl_x = 320, rval_x = 320 + 12 * 6; + fb_string(rlbl_x, card_y + 100, "Dewpoint:", COL_CYAN, 1); + fb_string(rval_x, card_y + 100, "54 F", COL_WHITE, 1); + fb_string(rlbl_x, card_y + 112, "Visibility:", COL_CYAN, 1); + fb_string(rval_x, card_y + 112, "10 mi", COL_WHITE, 1); + fb_string(rlbl_x, card_y + 124, "UV Index:", COL_CYAN, 1); + fb_string(rval_x, card_y + 124, "3 Moderate", COL_GREEN, 1); + + /* + * ── forecast strip ─────────────────────────────────────────── + * Below the current conditions, a gold-bordered section shows + * the 5-day extended forecast. Each day gets a fixed-width + * box containing the day name, high/low temperatures, and a + * short condition description. + */ + int strip_y = 252; + fb_hline(20, FB_W - 21, strip_y, COL_GOLD); + fb_hline(20, FB_W - 21, strip_y + 1, COL_GOLD); + fb_string(30, strip_y + 8, "EXTENDED FORECAST", COL_GOLD, 1); + fb_hline(20, FB_W - 21, strip_y + 20, (rgb_t){40, 60, 130}); + + /* + * Forecast data — five days of hardcoded weather. In a real + * system these arrays would be populated from API data. + */ + const char *days[] = { "SAT", "SUN", "MON", "TUE", "WED" }; + const char *conds[] = { "Sunny","Cloudy", "Rain","P.Cloud", "Sunny" }; + const int his[] = { 68, 65, 58, 61, 70 }; + const int los[] = { 52, 50, 48, 49, 54 }; + + /* + * Layout: 5 boxes × 110px wide with 10px gaps between them, + * centred horizontally. Total width = 5×110 + 4×10 = 590px. + * Starting x = (640 − 590) / 2 = 25. + */ + int box_w = 110, box_gap = 10; + int box_start = (FB_W - (5 * box_w + 4 * box_gap)) / 2; + + for (int i = 0; i < 5; i++) { + int bx = box_start + i * (box_w + box_gap); + int by = strip_y + 26; + + /* dark blue box background */ + fb_rect(bx, by, box_w, 95, (rgb_t){15, 25, 90}); + /* top edge highlight */ + fb_hline(bx, bx + box_w - 1, by, COL_SEP); + + /* day name, centred within the box */ + int dw = fb_string_width(days[i], 2); + fb_string(bx + (box_w - dw) / 2, by + 4, days[i], COL_WHITE, 2); + + /* high and low temperatures */ + char buf[32]; + snprintf(buf, sizeof buf, "Hi %d", his[i]); + fb_string(bx + 8, by + 30, buf, COL_TEMPHI, 1); + snprintf(buf, sizeof buf, "Lo %d", los[i]); + fb_string(bx + 8, by + 44, buf, COL_TEMPLO, 1); + + /* short condition text */ + fb_string(bx + 8, by + 62, conds[i], COL_LTGRAY, 1); + + /* + * Mini weather indicator dot — a small filled circle in the + * lower-right of each box. Yellow for sunny/fair days, + * cyan for rainy days. A real WeatherStar would show a + * small icon here; we use a coloured dot as a compact hint. + */ + rgb_t dot = (i == 2) ? COL_CYAN : COL_YELLOW; + for (int dy = -3; dy <= 3; dy++) + for (int dx = -3; dx <= 3; dx++) + if (dx * dx + dy * dy <= 9) { + int px = bx + box_w - 18 + dx; + int py = by + 72 + dy; + if (px >= 0 && px < FB_W && py >= 0 && py < FB_H) + fb[py][px] = dot; + } + } + + /* + * ── bottom status bar ──────────────────────────────────────── + * The very bottom of the screen shows the current date on the + * left, "Local on the 8s" centred (the classic TWC segment name), + * and the current time on the right in large text. + */ + int bot_y = FB_H - 30; + fb_rect(0, bot_y, FB_W, 30, (rgb_t){10, 15, 55}); + fb_hline(0, FB_W - 1, bot_y, COL_GOLD); + fb_hline(0, FB_W - 1, bot_y + 1, COL_GOLD); + + fb_string(20, bot_y + 10, datebuf, COL_LTGRAY, 1); + + int tw = fb_string_width(timestr, 2); + fb_string(FB_W - tw - 20, bot_y + 6, timestr, COL_WHITE, 2); + + fb_string_centered(bot_y + 10, "Local on the 8s", COL_GOLD, 1); +} + +#endif /* DISPLAY_H */ diff --git a/weatherstar/fb.h b/weatherstar/fb.h new file mode 100644 index 0000000..8fab258 --- /dev/null +++ b/weatherstar/fb.h @@ -0,0 +1,203 @@ +/* + * fb.h — Framebuffer and primitive drawing + * + * ═══════════════════════════════════════════════════════════════════ + * OVERVIEW + * ═══════════════════════════════════════════════════════════════════ + * + * This file defines the pixel framebuffer at the heart of the renderer. + * Instead of writing to the screen directly, every drawing operation writes + * into a flat 2D array of RGB pixels (fb[][]). Once the full scene is + * composed, the framebuffer is read out either as ANSI terminal sequences + * or as a PNG file — the drawing code never needs to know which. + * + * The framebuffer is sized to 640×400 pixels, which matches the original + * WeatherStar 4000's NTSC-era resolution. + * + * ═══════════════════════════════════════════════════════════════════ + * COLOUR MODEL + * ═══════════════════════════════════════════════════════════════════ + * + * All colours are stored as 24-bit RGB triples (rgb_t). The WeatherStar + * 4000 palette is dominated by deep blues, golds, and cyans — a distinctive + * look that came from the Amiga-based hardware TWC used in the early '90s. + * + * ═══════════════════════════════════════════════════════════════════ + * DRAWING PRIMITIVES + * ═══════════════════════════════════════════════════════════════════ + * + * fb_clear() — Fill the entire framebuffer with a vertical gradient + * from COL_GRADTOP to COL_GRADBOT. This produces the + * subtle dark-blue gradient visible behind all content. + * + * fb_rect() — Solid-colour filled rectangle. + * + * fb_rect_grad_v() — Rectangle with a vertical colour gradient. Used for + * the header bar and location bar, where the colour + * shifts from a lighter blue at the top to a darker + * blue at the bottom. + * + * fb_hline() — Horizontal line spanning [x0, x1] at row y. + * + * draw_rounded_rect() — A filled rectangle with rounded corners. Works by + * first filling the whole rectangle, then "knocking + * out" corner pixels that fall outside the radius + * circle, replacing them with the background gradient. + */ + +#ifndef FB_H_INCLUDED +#define FB_H_INCLUDED + +#include + +/* ── framebuffer dimensions ─────────────────────────────────────── */ +/* + * 640×400 was chosen to approximate the original WeatherStar 4000 + * output resolution. The CELL_W/CELL_H constants are vestigial + * (from a text-cell model) but kept for reference. + */ +#define FB_W 640 +#define FB_H 400 +#define CELL_W 8 +#define CELL_H 16 + +/* ── colour type ────────────────────────────────────────────────── */ +/* + * A single pixel: 8 bits per channel, no alpha. + * "rgb_t" rather than a library type so we stay dependency-free. + */ +typedef struct { uint8_t r, g, b; } rgb_t; + +/* ── the framebuffer itself ─────────────────────────────────────── */ +/* + * A global 640×400 pixel grid. ~750 KB of static BSS memory. + * Every drawing function writes directly into this array. + */ +static rgb_t fb[FB_H][FB_W]; + +/* ── WeatherStar colour palette ─────────────────────────────────── */ +/* + * These are tuned to match the WeatherStar 4000's signature look: + * deep blue backgrounds, gold accent bars, cyan info text, and + * white/light-gray content text. + */ +static const rgb_t COL_HEADER = { 40, 70, 170 }; /* header bar fill */ +static const rgb_t COL_ACCENT = { 60, 120, 210 }; /* subheadings */ +static const rgb_t COL_GOLD = { 255, 200, 50 }; /* gold separator bars */ +static const rgb_t COL_WHITE = { 255, 255, 255 }; +static const rgb_t COL_LTGRAY = { 180, 190, 210 }; +static const rgb_t COL_YELLOW = { 255, 255, 100 }; +static const rgb_t COL_CYAN = { 100, 220, 255 }; /* info label text */ +static const rgb_t COL_GREEN = { 80, 220, 120 }; +static const rgb_t COL_ORANGE = { 255, 160, 50 }; +static const rgb_t COL_GRADTOP = { 20, 40, 120 }; /* background gradient */ +static const rgb_t COL_GRADBOT = { 5, 15, 60 }; /* top → bottom */ +static const rgb_t COL_SEP = { 50, 80, 160 }; /* thin rule lines */ +static const rgb_t COL_TEMPHI = { 255, 100, 80 }; /* high temperature */ +static const rgb_t COL_TEMPLO = { 100, 180, 255 }; /* low temperature */ + +/* ── helper: compute background gradient colour at a given y ──── */ +/* + * The background is a vertical gradient. Several routines need to + * "erase" pixels back to the gradient (e.g. rounded-rect corners), + * so we centralise the interpolation here. + */ +static rgb_t fb_bg_at(int y) { + float t = (float)y / FB_H; + return (rgb_t){ + (uint8_t)(COL_GRADTOP.r + (COL_GRADBOT.r - COL_GRADTOP.r) * t), + (uint8_t)(COL_GRADTOP.g + (COL_GRADBOT.g - COL_GRADTOP.g) * t), + (uint8_t)(COL_GRADTOP.b + (COL_GRADBOT.b - COL_GRADTOP.b) * t) + }; +} + +/* ── fb_clear: fill the framebuffer with the background gradient ── */ +static void fb_clear(void) { + for (int y = 0; y < FB_H; y++) { + rgb_t c = fb_bg_at(y); + for (int x = 0; x < FB_W; x++) + fb[y][x] = c; + } +} + +/* ── fb_rect: filled solid-colour rectangle ─────────────────────── */ +/* + * Clips to framebuffer bounds. Coordinates may be negative + * (partially off-screen) without crashing. + */ +static void fb_rect(int x0, int y0, int w, int h, rgb_t c) { + for (int y = y0; y < y0 + h && y < FB_H; y++) + for (int x = x0; x < x0 + w && x < FB_W; x++) + if (x >= 0 && y >= 0) + fb[y][x] = c; +} + +/* ── fb_rect_grad_v: rectangle with a vertical colour gradient ─── */ +/* + * Linearly interpolates between `top` and `bot` colours from the + * first row to the last row of the rectangle. Used for the header + * and location bars where the colour subtly darkens downward. + */ +static void fb_rect_grad_v(int x0, int y0, int w, int h, + rgb_t top, rgb_t bot) +{ + for (int y = y0; y < y0 + h && y < FB_H; y++) { + float t = (h > 1) ? (float)(y - y0) / (h - 1) : 0; + rgb_t c = { + (uint8_t)(top.r + (bot.r - top.r) * t), + (uint8_t)(top.g + (bot.g - top.g) * t), + (uint8_t)(top.b + (bot.b - top.b) * t) + }; + for (int x = x0; x < x0 + w && x < FB_W; x++) + if (x >= 0 && y >= 0) + fb[y][x] = c; + } +} + +/* ── fb_hline: horizontal line from x0 to x1 at row y ──────────── */ +static void fb_hline(int x0, int x1, int y, rgb_t c) { + if (y < 0 || y >= FB_H) return; + for (int x = x0; x <= x1 && x < FB_W; x++) + if (x >= 0) fb[y][x] = c; +} + +/* ── draw_rounded_rect: filled rectangle with rounded corners ──── */ +/* + * Strategy: fill the entire rectangle with the given colour, then + * iterate over the r×r corner regions. For each pixel, compute its + * distance from the corner's arc centre. If it falls outside the + * radius, overwrite it with the background gradient colour — this + * "punches out" the corners to simulate rounding. + * + * This is a visual approximation (the "erased" pixels assume the + * background gradient is showing through), but it works perfectly + * for our use case since rounded rects are only drawn over the + * gradient background. + */ +static void draw_rounded_rect(int x0, int y0, int w, int h, + int r, rgb_t fill) +{ + fb_rect(x0, y0, w, h, fill); + + int r_sq = r * r; /* compare squared distances — avoids sqrtf */ + for (int dy = 0; dy < r; dy++) + for (int dx = 0; dx < r; dx++) { + int dist_sq = (r-dx)*(r-dx) + (r-dy)*(r-dy); + if (dist_sq <= r_sq) continue; /* inside arc — keep */ + + /* Outside the arc: restore background at all four corners. */ + int corners[4][2] = { + { x0 + dx, y0 + dy }, /* top-left */ + { x0 + w - 1 - dx, y0 + dy }, /* top-right */ + { x0 + dx, y0 + h - 1 - dy }, /* bottom-left */ + { x0 + w - 1 - dx, y0 + h - 1 - dy }, /* bottom-right */ + }; + for (int c = 0; c < 4; c++) { + int cx = corners[c][0], cy = corners[c][1]; + if (cx >= 0 && cx < FB_W && cy >= 0 && cy < FB_H) + fb[cy][cx] = fb_bg_at(cy); + } + } +} + +#endif /* FB_H_INCLUDED */ diff --git a/weatherstar/font.h b/weatherstar/font.h new file mode 100644 index 0000000..b5ad427 --- /dev/null +++ b/weatherstar/font.h @@ -0,0 +1,245 @@ +/* + * font.h — Built-in 5×7 bitmap font and text rendering + * + * ═══════════════════════════════════════════════════════════════════ + * THE FONT + * ═══════════════════════════════════════════════════════════════════ + * + * Each glyph is a 5-pixel-wide, 7-pixel-tall bitmap stored as 7 bytes. + * Within each byte, the 5 most-significant bits represent columns + * left-to-right: + * + * bit 4 (0x10) = leftmost column + * bit 3 (0x08) = second column + * bit 2 (0x04) = centre column + * bit 1 (0x02) = fourth column + * bit 0 (0x01) = rightmost column + * + * For example, the letter 'A' (index 65−32 = 33): + * + * {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11} + * + * row 0: 0x0E = 01110 → .XXX. + * row 1: 0x11 = 10001 → X...X + * row 2: 0x11 = 10001 → X...X + * row 3: 0x1F = 11111 → XXXXX + * row 4: 0x11 = 10001 → X...X + * row 5: 0x11 = 10001 → X...X + * row 6: 0x11 = 10001 → X...X + * + * The font covers printable ASCII (space=32 through tilde=126). + * Characters outside this range silently render as a space. + * + * ═══════════════════════════════════════════════════════════════════ + * TEXT RENDERING + * ═══════════════════════════════════════════════════════════════════ + * + * fb_char() — Render one glyph at (px, py). The `scale` + * parameter integer-scales each pixel, so + * scale=2 produces 10×14 characters. + * + * fb_string() — Walk a C string, rendering each character + * with 6×scale pixel advance (5 glyph + 1 gap). + * + * fb_string_width() — Return the pixel width a string would occupy + * at a given scale. Used for centring. + * + * fb_string_centered()— Render a string horizontally centred on the + * framebuffer at a given y coordinate. + * + * fb_degree() — Render a tiny 3×3 degree symbol (°). This + * is separate from the main font because the + * degree sign needs to sit at the top of the + * line, superscript-style. + */ + +#ifndef FONT_H +#define FONT_H + +#include "fb.h" +#include + +/* ── glyph data: ASCII 32 (' ') through 126 ('~') ──────────────── */ +/* + * 95 glyphs × 7 bytes = 665 bytes. This table is const and lives + * in the read-only data segment. + */ +static const uint8_t font5x7[][7] = { + /* 32 ' ' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x00}, + /* 33 '!' */ {0x04,0x04,0x04,0x04,0x04,0x00,0x04}, + /* 34 '"' */ {0x0A,0x0A,0x00,0x00,0x00,0x00,0x00}, + /* 35 '#' */ {0x0A,0x1F,0x0A,0x0A,0x1F,0x0A,0x00}, + /* 36 '$' */ {0x04,0x0F,0x14,0x0E,0x05,0x1E,0x04}, + /* 37 '%' */ {0x18,0x19,0x02,0x04,0x08,0x13,0x03}, + /* 38 '&' */ {0x08,0x14,0x14,0x08,0x15,0x12,0x0D}, + /* 39 ''' */ {0x04,0x04,0x00,0x00,0x00,0x00,0x00}, + /* 40 '(' */ {0x02,0x04,0x08,0x08,0x08,0x04,0x02}, + /* 41 ')' */ {0x08,0x04,0x02,0x02,0x02,0x04,0x08}, + /* 42 '*' */ {0x00,0x04,0x15,0x0E,0x15,0x04,0x00}, + /* 43 '+' */ {0x00,0x04,0x04,0x1F,0x04,0x04,0x00}, + /* 44 ',' */ {0x00,0x00,0x00,0x00,0x00,0x04,0x08}, + /* 45 '-' */ {0x00,0x00,0x00,0x1F,0x00,0x00,0x00}, + /* 46 '.' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x04}, + /* 47 '/' */ {0x01,0x01,0x02,0x04,0x08,0x10,0x10}, + /* 48 '0' */ {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}, + /* 49 '1' */ {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E}, + /* 50 '2' */ {0x0E,0x11,0x01,0x06,0x08,0x10,0x1F}, + /* 51 '3' */ {0x0E,0x11,0x01,0x06,0x01,0x11,0x0E}, + /* 52 '4' */ {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}, + /* 53 '5' */ {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E}, + /* 54 '6' */ {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}, + /* 55 '7' */ {0x1F,0x01,0x02,0x04,0x08,0x08,0x08}, + /* 56 '8' */ {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}, + /* 57 '9' */ {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C}, + /* 58 ':' */ {0x00,0x00,0x04,0x00,0x00,0x04,0x00}, + /* 59 ';' */ {0x00,0x00,0x04,0x00,0x00,0x04,0x08}, + /* 60 '<' */ {0x02,0x04,0x08,0x10,0x08,0x04,0x02}, + /* 61 '=' */ {0x00,0x00,0x1F,0x00,0x1F,0x00,0x00}, + /* 62 '>' */ {0x08,0x04,0x02,0x01,0x02,0x04,0x08}, + /* 63 '?' */ {0x0E,0x11,0x01,0x02,0x04,0x00,0x04}, + /* 64 '@' */ {0x0E,0x11,0x17,0x15,0x17,0x10,0x0E}, + /* 65 'A' */ {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11}, + /* 66 'B' */ {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E}, + /* 67 'C' */ {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E}, + /* 68 'D' */ {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E}, + /* 69 'E' */ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F}, + /* 70 'F' */ {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10}, + /* 71 'G' */ {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F}, + /* 72 'H' */ {0x11,0x11,0x11,0x1F,0x11,0x11,0x11}, + /* 73 'I' */ {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E}, + /* 74 'J' */ {0x07,0x02,0x02,0x02,0x02,0x12,0x0C}, + /* 75 'K' */ {0x11,0x12,0x14,0x18,0x14,0x12,0x11}, + /* 76 'L' */ {0x10,0x10,0x10,0x10,0x10,0x10,0x1F}, + /* 77 'M' */ {0x11,0x1B,0x15,0x15,0x11,0x11,0x11}, + /* 78 'N' */ {0x11,0x19,0x15,0x13,0x11,0x11,0x11}, + /* 79 'O' */ {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E}, + /* 80 'P' */ {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10}, + /* 81 'Q' */ {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D}, + /* 82 'R' */ {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11}, + /* 83 'S' */ {0x0E,0x11,0x10,0x0E,0x01,0x11,0x0E}, + /* 84 'T' */ {0x1F,0x04,0x04,0x04,0x04,0x04,0x04}, + /* 85 'U' */ {0x11,0x11,0x11,0x11,0x11,0x11,0x0E}, + /* 86 'V' */ {0x11,0x11,0x11,0x11,0x0A,0x0A,0x04}, + /* 87 'W' */ {0x11,0x11,0x11,0x15,0x15,0x1B,0x11}, + /* 88 'X' */ {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11}, + /* 89 'Y' */ {0x11,0x11,0x0A,0x04,0x04,0x04,0x04}, + /* 90 'Z' */ {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F}, + /* 91 '[' */ {0x0E,0x08,0x08,0x08,0x08,0x08,0x0E}, + /* 92 '\' */ {0x10,0x10,0x08,0x04,0x02,0x01,0x01}, + /* 93 ']' */ {0x0E,0x02,0x02,0x02,0x02,0x02,0x0E}, + /* 94 '^' */ {0x04,0x0A,0x11,0x00,0x00,0x00,0x00}, + /* 95 '_' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x1F}, + /* 96 '`' */ {0x08,0x04,0x00,0x00,0x00,0x00,0x00}, + /* 97 'a' */ {0x00,0x00,0x0E,0x01,0x0F,0x11,0x0F}, + /* 98 'b' */ {0x10,0x10,0x1E,0x11,0x11,0x11,0x1E}, + /* 99 'c' */ {0x00,0x00,0x0E,0x11,0x10,0x11,0x0E}, + /*100 'd' */ {0x01,0x01,0x0F,0x11,0x11,0x11,0x0F}, + /*101 'e' */ {0x00,0x00,0x0E,0x11,0x1F,0x10,0x0E}, + /*102 'f' */ {0x06,0x08,0x1E,0x08,0x08,0x08,0x08}, + /*103 'g' */ {0x00,0x00,0x0F,0x11,0x0F,0x01,0x0E}, + /*104 'h' */ {0x10,0x10,0x1E,0x11,0x11,0x11,0x11}, + /*105 'i' */ {0x04,0x00,0x0C,0x04,0x04,0x04,0x0E}, + /*106 'j' */ {0x02,0x00,0x06,0x02,0x02,0x12,0x0C}, + /*107 'k' */ {0x10,0x10,0x12,0x14,0x18,0x14,0x12}, + /*108 'l' */ {0x0C,0x04,0x04,0x04,0x04,0x04,0x0E}, + /*109 'm' */ {0x00,0x00,0x1A,0x15,0x15,0x15,0x15}, + /*110 'n' */ {0x00,0x00,0x1E,0x11,0x11,0x11,0x11}, + /*111 'o' */ {0x00,0x00,0x0E,0x11,0x11,0x11,0x0E}, + /*112 'p' */ {0x00,0x00,0x1E,0x11,0x1E,0x10,0x10}, + /*113 'q' */ {0x00,0x00,0x0F,0x11,0x0F,0x01,0x01}, + /*114 'r' */ {0x00,0x00,0x16,0x19,0x10,0x10,0x10}, + /*115 's' */ {0x00,0x00,0x0F,0x10,0x0E,0x01,0x1E}, + /*116 't' */ {0x08,0x08,0x1E,0x08,0x08,0x09,0x06}, + /*117 'u' */ {0x00,0x00,0x11,0x11,0x11,0x11,0x0F}, + /*118 'v' */ {0x00,0x00,0x11,0x11,0x0A,0x0A,0x04}, + /*119 'w' */ {0x00,0x00,0x11,0x11,0x15,0x15,0x0A}, + /*120 'x' */ {0x00,0x00,0x11,0x0A,0x04,0x0A,0x11}, + /*121 'y' */ {0x00,0x00,0x11,0x11,0x0F,0x01,0x0E}, + /*122 'z' */ {0x00,0x00,0x1F,0x02,0x04,0x08,0x1F}, + /*123 '{' */ {0x02,0x04,0x04,0x08,0x04,0x04,0x02}, + /*124 '|' */ {0x04,0x04,0x04,0x04,0x04,0x04,0x04}, + /*125 '}' */ {0x08,0x04,0x04,0x02,0x04,0x04,0x08}, + /*126 '~' */ {0x00,0x00,0x08,0x15,0x02,0x00,0x00}, +}; + +/* ── fb_char: render a single glyph into the framebuffer ────────── */ +/* + * px, py — top-left pixel position of the character + * ch — ASCII character to draw + * fg — foreground colour + * scale — integer magnification (1 = native 5×7, 2 = 10×14, etc.) + * + * For each "on" pixel in the 5×7 bitmap, we fill a scale×scale + * block of framebuffer pixels with the foreground colour. This + * gives us arbitrary-size text from a single font definition. + * + * The bit-test expression (0x10 >> col) walks columns left-to-right: + * col=0 → test bit 4 (0x10) = leftmost pixel + * col=1 → test bit 3 (0x08) + * col=2 → test bit 2 (0x04) = centre pixel + * col=3 → test bit 1 (0x02) + * col=4 → test bit 0 (0x01) = rightmost pixel + */ +static void fb_char(int px, int py, char ch, rgb_t fg, int scale) { + int idx = ch - 32; + if (idx < 0 || idx > 94) idx = 0; /* out-of-range → space */ + for (int row = 0; row < 7; row++) + for (int col = 0; col < 5; col++) + if (font5x7[idx][row] & (0x10 >> col)) + for (int sy = 0; sy < scale; sy++) + for (int sx = 0; sx < scale; sx++) { + int fx = px + col * scale + sx; + int fy = py + row * scale + sy; + if (fx >= 0 && fx < FB_W && fy >= 0 && fy < FB_H) + fb[fy][fx] = fg; + } +} + +/* ── fb_string: render a NUL-terminated string ──────────────────── */ +/* + * Each character occupies 6×scale pixels horizontally (5 glyph + * pixels + 1 pixel gap between characters). + */ +static void fb_string(int px, int py, const char *s, rgb_t fg, int scale) { + int spacing = 6 * scale; + for (; *s; s++, px += spacing) + fb_char(px, py, *s, fg, scale); +} + +/* ── fb_string_width: compute rendered width in pixels ──────────── */ +static int fb_string_width(const char *s, int scale) { + return (int)strlen(s) * 6 * scale; +} + +/* ── fb_string_centered: render a string centred on the display ─── */ +static void fb_string_centered(int y, const char *s, rgb_t fg, int scale) { + int w = fb_string_width(s, scale); + fb_string((FB_W - w) / 2, y, s, fg, scale); +} + +/* ── fb_degree: render a small superscript degree symbol (°) ────── */ +/* + * The degree symbol is a 3×3 bitmap forming a tiny circle: + * .X. → 0x02 + * X.X → 0x05 + * .X. → 0x02 + * + * It's rendered the same way as font glyphs (scale×scale blocks), + * but using a separate 3-byte bitmap because it doesn't fit the + * 5×7 grid neatly and needs to sit at the top of the line. + */ +static void fb_degree(int px, int py, rgb_t fg, int scale) { + static const uint8_t deg[3] = {0x02, 0x05, 0x02}; + for (int row = 0; row < 3; row++) + for (int col = 0; col < 3; col++) + if (deg[row] & (0x04 >> col)) + for (int sy = 0; sy < scale; sy++) + for (int sx = 0; sx < scale; sx++) { + int fx = px + col * scale + sx; + int fy = py + row * scale + sy; + if (fx >= 0 && fx < FB_W && fy >= 0 && fy < FB_H) + fb[fy][fx] = fg; + } +} + +#endif /* FONT_H */ diff --git a/weatherstar/icons.h b/weatherstar/icons.h new file mode 100644 index 0000000..9df8cf0 --- /dev/null +++ b/weatherstar/icons.h @@ -0,0 +1,110 @@ +/* + * icons.h — Weather icon drawing + * + * ═══════════════════════════════════════════════════════════════════ + * OVERVIEW + * ═══════════════════════════════════════════════════════════════════ + * + * The WeatherStar 4000 displayed simple, iconic weather graphics — + * a sun, clouds, rain drops. We draw these directly into the pixel + * framebuffer using basic geometry (filled circles, lines). + * + * All icons are drawn with a "centre point" coordinate system: + * you specify where the icon's visual centre should be, and the + * drawing functions radiate outward from there. + * + * ═══════════════════════════════════════════════════════════════════ + * HOW THE CIRCLE-DRAWING WORKS + * ═══════════════════════════════════════════════════════════════════ + * + * Both the sun and cloud use a brute-force filled-circle algorithm: + * + * for each (dx, dy) in the bounding box: + * if dx² + dy² ≤ r²: → pixel is inside the circle + * + * This is O(r²) per circle, which is perfectly fine for radii under + * ~20 pixels. A Bresenham or midpoint algorithm would be faster for + * large radii, but we're drawing a handful of tiny circles — the + * brute force approach is clearer and more than fast enough. + */ + +#ifndef ICONS_H +#define ICONS_H + +#include "fb.h" +#include + +/* ── draw_sun: sun icon with rays ───────────────────────────────── */ +/* + * Draws a filled yellow circle for the sun body, then 8 radial rays + * extending outward at 45° intervals (every π/4 radians). + * + * Each ray is a line of pixels drawn by stepping outward from the + * circle edge (r+3) to a fixed length (r+10), computing (x,y) from + * polar coordinates: x = cos(θ)·d, y = sin(θ)·d. + * + * The ray is thickened by also writing the pixel one position to the + * right (px+1), giving it a 2-pixel width for visual weight. + */ +static void draw_sun(int cx, int cy, int r) { + /* filled circle — the sun body */ + for (int dy = -r; dy <= r; dy++) + for (int dx = -r; dx <= r; dx++) + if (dx * dx + dy * dy <= r * r) { + int px = cx + dx, py = cy + dy; + if (px >= 0 && px < FB_W && py >= 0 && py < FB_H) + fb[py][px] = COL_YELLOW; + } + + /* 8 rays at 45° intervals */ + for (int i = 0; i < 8; i++) { + float angle = (float)i * 3.14159f / 4.0f; + for (int d = r + 3; d < r + 10; d++) { + int px = cx + (int)(cosf(angle) * (float)d); + int py = cy + (int)(sinf(angle) * (float)d); + if (px >= 0 && px < FB_W && py >= 0 && py < FB_H) + fb[py][px] = COL_ORANGE; + if (px + 1 >= 0 && px + 1 < FB_W && py >= 0 && py < FB_H) + fb[py][px + 1] = COL_ORANGE; + } + } +} + +/* ── draw_cloud: cloud icon from overlapping circles ────────────── */ +/* + * A cloud shape is approximated by three overlapping filled circles: + * + * - A central circle (radius 12) at the given centre point + * - A left circle (radius 10, offset 10px left and 4px down) + * - A right circle (radius 10, offset 10px right and 4px down) + * + * The overlap of the three circles produces a convincing "puffy + * cloud" silhouette: + * + * ████████ + * ██████████████ + * ████████████████████ + * ██████████████ + * + * The `col` parameter lets the caller choose the cloud colour + * (e.g. light gray for fair-weather clouds, darker for overcast). + */ +static void draw_cloud(int cx, int cy, rgb_t col) { + int offsets[][3] = { + { 0, 0, 12 }, /* centre */ + {-10, 4, 10 }, /* left-bottom lobe */ + { 10, 4, 10 }, /* right-bottom lobe */ + }; + for (int i = 0; i < 3; i++) { + int ox = offsets[i][0], oy = offsets[i][1], r = offsets[i][2]; + for (int dy = -r; dy <= r; dy++) + for (int dx = -r; dx <= r; dx++) + if (dx * dx + dy * dy <= r * r) { + int px = cx + ox + dx, py = cy + oy + dy; + if (px >= 0 && px < FB_W && py >= 0 && py < FB_H) + fb[py][px] = col; + } + } +} + +#endif /* ICONS_H */ diff --git a/weatherstar/main.c b/weatherstar/main.c new file mode 100644 index 0000000..c79fc95 --- /dev/null +++ b/weatherstar/main.c @@ -0,0 +1,94 @@ +/* + * main.c — WeatherStar 4000 console display — entry point + * + * ═══════════════════════════════════════════════════════════════════ + * WHAT THIS PROGRAM DOES + * ═══════════════════════════════════════════════════════════════════ + * + * Renders a faithful recreation of The Weather Channel's "Local on + * the 8s" display (circa early 1990s, WeatherStar 4000 era) using + * nothing but a C compiler and libpng. + * + * The architecture is simple: + * + * 1. compose_display() → Paint the full scene into a 640×400 + * pixel framebuffer (fb.h + display.h) + * + * 2. ansi_output() → Dump the framebuffer to stdout as ANSI + * 24-bit colour escape sequences, using + * Unicode half-block characters (▀) to + * pack two pixel rows per terminal line. + * + * 3. write_png() → Optionally write the framebuffer as a + * PNG image file (screenshot.h). + * + * ═══════════════════════════════════════════════════════════════════ + * FILE STRUCTURE + * ═══════════════════════════════════════════════════════════════════ + * + * main.c — this file; CLI parsing and orchestration + * fb.h — framebuffer type, colour palette, drawing prims + * font.h — 5×7 bitmap font data and text rendering + * icons.h — weather icon shapes (sun, cloud) + * display.h — full WeatherStar screen layout composition + * screenshot.h — PNG file output and ANSI terminal output + * + * All headers use static functions and are included only by main.c, + * so the whole program compiles as a single translation unit: + * + * gcc -O2 -o weatherstar main.c -lpng -lz -lm + * + * This keeps the build trivially simple (no object files, no link + * ordering issues) while still splitting the logic into readable, + * focused modules. + * + * ═══════════════════════════════════════════════════════════════════ + * USAGE + * ═══════════════════════════════════════════════════════════════════ + * + * ./weatherstar # render to terminal + * ./weatherstar --screenshot out.png # terminal + PNG + * ./weatherstar --no-ansi --screenshot out.png # PNG only + * ./weatherstar --help + * + * Build: make + * (or: gcc -O2 -o weatherstar main.c -lpng -lz -lm) + */ + +#include "display.h" +#include "screenshot.h" +#include +#include + +int main(int argc, char **argv) { + const char *screenshot = NULL; + int no_ansi = 0; + + /* ── argument parsing ─────────────────────────────────────── */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--screenshot") == 0 && i + 1 < argc) + screenshot = argv[++i]; + else if (strcmp(argv[i], "--no-ansi") == 0) + no_ansi = 1; + else if (strcmp(argv[i], "--help") == 0) { + printf("Usage: weatherstar " + "[--screenshot FILE.png] [--no-ansi]\n"); + return 0; + } + } + + /* ── render ───────────────────────────────────────────────── */ + compose_display(); + + if (!no_ansi) + ansi_output(); + + if (screenshot) { + if (write_png(screenshot) == 0) + fprintf(stderr, "Screenshot saved to %s\n", screenshot); + else + return 1; + } + + return 0; +} diff --git a/weatherstar/screenshot.h b/weatherstar/screenshot.h new file mode 100644 index 0000000..7c148a7 --- /dev/null +++ b/weatherstar/screenshot.h @@ -0,0 +1,158 @@ +/* + * screenshot.h — PNG and ANSI output from the framebuffer + * + * ═══════════════════════════════════════════════════════════════════ + * PNG OUTPUT + * ═══════════════════════════════════════════════════════════════════ + * + * write_png() converts the in-memory framebuffer into a PNG file + * using libpng. The process is: + * + * 1. Open the output file for binary writing. + * 2. Create the libpng write structures (png_structp, png_infop). + * 3. Set the image header: 640×400, 8-bit RGB, non-interlaced. + * 4. Walk each row of the framebuffer, pack it into a flat + * [R,G,B, R,G,B, ...] byte array, and hand it to libpng. + * 5. Finalise the PNG and close the file. + * + * libpng uses setjmp/longjmp for error handling — if any libpng + * call fails, execution jumps back to the setjmp point, where we + * clean up and return an error code. + * + * ═══════════════════════════════════════════════════════════════════ + * ANSI TERMINAL OUTPUT + * ═══════════════════════════════════════════════════════════════════ + * + * ansi_output() renders the framebuffer to a terminal that supports + * 24-bit ("true color") ANSI escape sequences. + * + * The trick: Unicode character U+2580 (▀, "upper half block") fills + * the top half of a character cell. By setting the foreground colour + * to the upper pixel row and the background colour to the lower pixel + * row, each terminal character cell displays TWO vertical pixels. + * This doubles the effective vertical resolution. + * + * So a 640×400 pixel image requires a 640-column × 200-row terminal. + * That's huge — this mode is primarily useful piped to a file or + * viewed in a very wide terminal. The PNG output is the more + * practical output mode. + * + * The escape sequences used: + * \033[H — move cursor to home position (top-left) + * \033[2J — clear entire screen + * \033[38;2;R;G;Bm — set foreground to 24-bit RGB + * \033[48;2;R;G;Bm — set background to 24-bit RGB + * \033[0m — reset all attributes + */ + +#ifndef SCREENSHOT_H +#define SCREENSHOT_H + +#include "fb.h" +#include +#include +#include + +/* ── write_png: save framebuffer as a PNG file ──────────────────── */ +static int write_png(const char *path) { + FILE *fp = fopen(path, "wb"); + if (!fp) { perror(path); return -1; } + + png_structp png = png_create_write_struct( + PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); + if (!png) { fclose(fp); return -1; } + + png_infop info = png_create_info_struct(png); + if (!info) { + png_destroy_write_struct(&png, NULL); + fclose(fp); + return -1; + } + + /* + * Allocate the row buffer BEFORE setjmp so it can be freed on + * the longjmp error path. If we allocated after setjmp, a + * longjmp triggered between malloc and free would leak the row. + */ + uint8_t *row = malloc(FB_W * 3); + if (!row) { + png_destroy_write_struct(&png, &info); + fclose(fp); + return -1; + } + + /* + * libpng error handling: if any png_* call fails, it will + * longjmp back here. We clean up everything and return failure. + */ + if (setjmp(png_jmpbuf(png))) { + free(row); + png_destroy_write_struct(&png, &info); + fclose(fp); + return -1; + } + + png_init_io(png, fp); + png_set_IHDR(png, info, FB_W, FB_H, 8, PNG_COLOR_TYPE_RGB, + PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, + PNG_FILTER_TYPE_DEFAULT); + png_write_info(png, info); + + for (int y = 0; y < FB_H; y++) { + for (int x = 0; x < FB_W; x++) { + row[x * 3 + 0] = fb[y][x].r; + row[x * 3 + 1] = fb[y][x].g; + row[x * 3 + 2] = fb[y][x].b; + } + png_write_row(png, row); + } + + free(row); + png_write_end(png, NULL); + png_destroy_write_struct(&png, &info); + fclose(fp); + return 0; +} + +/* ── ansi_output: render framebuffer to terminal with true-color ── */ +/* + * Performance note: each pixel needs ~30 bytes of escape sequences. + * 640 pixels × 200 rows × 30 bytes ≈ 3.6 MB of output. Instead of + * calling printf 128,000 times (one per pixel), we write into a + * line buffer and flush once per row. This reduces syscall overhead + * dramatically and makes output roughly 10× faster. + */ +static void ansi_output(void) { + /* + * Worst case per pixel: two SGR sequences + 3-byte UTF-8 char. + * "\033[38;2;RRR;GGG;BBBm\033[48;2;RRR;GGG;BBBm\xe2\x96\x80" + * That's at most ~44 bytes. Plus "\033[0m\n" per line. + * Buffer: 640 × 48 + 16 = ~30 KB per line — fits easily on stack. + */ + char line[640 * 48 + 16]; + + printf("\033[H\033[2J"); /* cursor home + clear screen */ + + for (int y = 0; y < FB_H; y += 2) { + int pos = 0; + for (int x = 0; x < FB_W; x++) { + rgb_t top = fb[y][x]; + rgb_t bot = (y + 1 < FB_H) ? fb[y + 1][x] : top; + /* + * snprintf into the line buffer. The half-block trick: + * foreground = top pixel, background = bottom pixel. + */ + pos += snprintf(line + pos, sizeof(line) - (size_t)pos, + "\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dm" + "\xe2\x96\x80", + top.r, top.g, top.b, bot.r, bot.g, bot.b); + } + pos += snprintf(line + pos, sizeof(line) - (size_t)pos, + "\033[0m\n"); + fwrite(line, 1, (size_t)pos, stdout); + } + printf("\033[0m"); + fflush(stdout); +} + +#endif /* SCREENSHOT_H */ diff --git a/weatherstar/screenshot.png b/weatherstar/screenshot.png new file mode 100644 index 0000000..e3abf42 Binary files /dev/null and b/weatherstar/screenshot.png differ