commit 3a77d9527349c6588660aab95cdc6177b653dab8 Author: wisplite Date: Thu Dec 25 20:05:11 2025 -0600 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfda7db --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +This repository contains the default system libraries, launcher, and applications for Cardstock OS. It currently includes the following components: + +# Applications +Below is a list of the pre-installed applications currently included with Cardstock. +## Paper +Paper is Cardstock’s default launcher and serves as a reference implementation for other launchers. + +In Cardstock, launchers are standard applications. They live in the `launcher/` folder, launch at boot, and act as the default home app. + +# Libraries +The system libraries live in the `syslib/` folder. They are Lua files that provide helper utilities for Cardstock applications. Unlike app-scoped libraries, these are globally accessible. +## gfxplus.lua +`gfxplus` is a graphics helper library with simple functions for tasks like color math and more advanced rendering patterns. +## doublebuf.lua +`doublebuf` is a small helper library that makes the M5Canvas tools easier to use, enabling smooth animations via double-buffering. +## anim.lua +`anim` is a lightweight animation toolkit for smoothly interpolating values over time. + +# Planned Updates +Over time, I plan to add more default system apps and continuously improve `syslib` as I build more applications with it. \ No newline at end of file diff --git a/launcher/main.lua b/launcher/main.lua new file mode 100644 index 0000000..e289e69 --- /dev/null +++ b/launcher/main.lua @@ -0,0 +1,118 @@ +local gfx = require("gfx") +local anim = require("anim") +local keyboard = require("keyboard") +local gfxplus = require("gfxplus") +local doublebuf = require("doublebuf") +local width = gfx.width() +local height = gfx.height() +local GREY = gfxplus.hexConvert("#c1c1c1") +local buf = doublebuf.new(gfx, width, height) +local g = buf.g + +-- Simple screen state + "dirty" redraw flag to avoid full-screen clearing every frame +local screen = "welcome" -- "welcome" | "testing" +local dirty = true +local transitioning = false + +-- Frame limiting for animations to reduce visible "clear then draw" flicker on non-double-buffered displays. +local TARGET_FPS = 30 +local STEP = 1 / TARGET_FPS +local acc = 0 + +local inputArmed = false +local wasPressed = false + +local screenOpacity = { value = 1 } +local a = anim.new() + +local function drawWelcome() + g.clear(0xFFFF) + g.setTextColor(gfxplus.withOpacity(0x0000, 0xFFFF, screenOpacity.value), 0xFFFF) + g.setTextSize(2) + g.drawCenterString("Welcome", math.floor(width / 2), math.floor(height / 2) - 15) + g.setTextSize(1) + g.drawCenterString("Press any button to continue", math.floor(width / 2), math.floor(height / 2) + 10) + g.setTextColor(gfxplus.withOpacity(GREY, 0xFFFF, screenOpacity.value), 0xFFFF) + g.setTextSize(1) + g.drawString("Paper Launcher v0.0.1", 5, height - 10) +end + +local function drawTesting() + g.clear(0xFFFF) + g.setTextColor(gfxplus.withOpacity(0x0000, 0xFFFF, screenOpacity.value), 0xFFFF) + g.setTextSize(2) + g.drawCenterString("Testing", math.floor(width / 2), math.floor(height / 2) - 15) +end + +function init() + -- Draw once on boot + screen = "welcome" + dirty = true +end + +function tick(dt) + local pressed = keyboard.isPressed() > 0 + + -- Don't accept input until we see a clean "no keys pressed" state at least once. + if not inputArmed then + if not pressed then + inputArmed = true + end + wasPressed = pressed + return + end + + local justPressed = pressed and (not wasPressed) + wasPressed = pressed + + if screen == "welcome" and justPressed and (not transitioning) then + transitioning = true + + -- Ensure we don't stack multiple tweens/timers for the same property. + a:clear() + screenOpacity.value = 1 + + a:to(screenOpacity, { value = 0 }, 1, { + ease = anim.ease.outCubic, + onComplete = function() + screen = "testing" + dirty = true + a:to(screenOpacity, { value = 1 }, 1, { + ease = anim.ease.outCubic, + onComplete = function() + transitioning = false + dirty = true + end + }) + end + }) + dirty = true + end + + -- Advance animations at a fixed timestep to avoid redrawing "too fast" for the display. + if type(dt) == "number" and dt > 0 then + acc = acc + dt + if acc > 0.25 then acc = 0.25 end -- prevent spiral-of-death on long frames + + local changed = false + while acc >= STEP do + if a:update(STEP) then changed = true end + acc = acc - STEP + end + if changed then dirty = true end + end +end + +function draw() + if not dirty then return end + dirty = false + + if screen == "welcome" then + drawWelcome() + buf.present() + return + end + + drawTesting() + buf.present() +end \ No newline at end of file diff --git a/syslib/anim.lua b/syslib/anim.lua new file mode 100644 index 0000000..514c595 --- /dev/null +++ b/syslib/anim.lua @@ -0,0 +1,260 @@ +-- syslib/anim.lua +-- +-- Small animation helper built around time-based tweens driven by `dt`. +-- +-- Usage: +-- local anim = require("anim") +-- local a = anim.new() +-- local state = { x = 0, alpha = 0 } +-- +-- a:to(state, { x = 100, alpha = 1 }, 0.25, { ease = anim.ease.outCubic }) +-- a:after(0.25, function() print("done") end) +-- +-- In your tick(dt): +-- local changed = a:update(dt) +-- if changed then dirty = true end +-- +-- Notes: +-- - Tweens interpolate numeric fields only. +-- - `update(dt)` returns true if anything advanced/ran this frame (useful for dirty redraw). + +local anim = {} + +local function clamp01(x) + if x < 0 then return 0 end + if x > 1 then return 1 end + return x +end + +local function lerp(a, b, t) + return a + (b - a) * t +end + +anim.ease = {} +anim.ease.linear = function(t) return t end +anim.ease.inQuad = function(t) return t * t end +anim.ease.outQuad = function(t) return 1 - (1 - t) * (1 - t) end +anim.ease.inOutQuad = function(t) + if t < 0.5 then return 2 * t * t end + return 1 - ((-2 * t + 2) ^ 2) / 2 +end +anim.ease.inCubic = function(t) return t * t * t end +anim.ease.outCubic = function(t) return 1 - (1 - t) ^ 3 end +anim.ease.inOutCubic = function(t) + if t < 0.5 then return 4 * t * t * t end + return 1 - ((-2 * t + 2) ^ 3) / 2 +end +anim.ease.outBack = function(t) + local c1 = 1.70158 + local c3 = c1 + 1 + return 1 + c3 * (t - 1) ^ 3 + c1 * (t - 1) ^ 2 +end + +local Tween = {} +Tween.__index = Tween + +function Tween:cancel() + self._cancelled = true +end + +function Tween:pause() + self._paused = true +end + +function Tween:resume() + self._paused = false +end + +function Tween:isDone() + return self._done or self._cancelled +end + +local Animator = {} +Animator.__index = Animator + +function anim.new() + return setmetatable({ + _tweens = {}, + _timers = {}, -- { t=remaining, fn=function, cancelled=bool } + }, Animator) +end + +local function snapshotFrom(subject, goal) + local from = {} + for k, _ in pairs(goal) do + local v = subject[k] + assert(type(v) == "number", ("anim: subject[%s] must be a number (got %s)"):format(tostring(k), type(v))) + from[k] = v + end + return from +end + +-- Tween numeric fields on `subject` to match `goal` over `duration` seconds. +-- opts: +-- ease (fn): easing function taking t in [0,1] +-- delay (number): seconds to wait before starting +-- loop (number): how many extra times to repeat after the first run (0 = no repeat, -1 = infinite) +-- yoyo (bool): if true, swap from/to each loop +-- onStart/onUpdate/onComplete (fn): callbacks +function Animator:to(subject, goal, duration, opts) + assert(type(subject) == "table", "anim:to subject must be a table") + assert(type(goal) == "table", "anim:to goal must be a table") + assert(type(duration) == "number" and duration >= 0, "anim:to duration must be a non-negative number") + opts = opts or {} + + local tw = setmetatable({ + subject = subject, + goal = goal, + from = snapshotFrom(subject, goal), + duration = duration, + ease = opts.ease or anim.ease.linear, + delay = opts.delay or 0, + loop = opts.loop or 0, + yoyo = opts.yoyo or false, + onStart = opts.onStart, + onUpdate = opts.onUpdate, + onComplete = opts.onComplete, + _elapsed = 0, + _started = false, + _done = false, + _cancelled = false, + _paused = false, + }, Tween) + + table.insert(self._tweens, tw) + return tw +end + +-- Schedule a callback after `delay` seconds. +function Animator:after(delay, fn) + assert(type(delay) == "number" and delay >= 0, "anim:after delay must be a non-negative number") + assert(type(fn) == "function", "anim:after fn must be a function") + local t = { t = delay, fn = fn, cancelled = false } + table.insert(self._timers, t) + return { + cancel = function() t.cancelled = true end + } +end + +function Animator:clear() + self._tweens = {} + self._timers = {} +end + +function Animator:isActive() + return (#self._tweens > 0) or (#self._timers > 0) +end + +-- Advances animations by dt seconds. +-- Returns true if anything advanced (properties changed or callbacks executed). +function Animator:update(dt) + if type(dt) ~= "number" or dt <= 0 then + return false + end + + local changed = false + + -- Timers + for i = #self._timers, 1, -1 do + local t = self._timers[i] + if t.cancelled then + table.remove(self._timers, i) + else + t.t = t.t - dt + if t.t <= 0 then + -- run once + t.cancelled = true + table.remove(self._timers, i) + t.fn() + changed = true + end + end + end + + -- Tweens + for i = #self._tweens, 1, -1 do + local tw = self._tweens[i] + + if tw._cancelled then + table.remove(self._tweens, i) + elseif tw._paused then + -- no-op + else + if tw.delay > 0 then + tw.delay = tw.delay - dt + if tw.delay <= 0 then + -- start this frame (carry leftover dt) + local carry = -tw.delay + tw.delay = 0 + if not tw._started then + tw._started = true + if tw.onStart then tw.onStart(tw.subject) end + changed = true + end + if carry > 0 then + tw._elapsed = tw._elapsed + carry + end + end + else + if not tw._started then + tw._started = true + if tw.onStart then tw.onStart(tw.subject) end + changed = true + end + tw._elapsed = tw._elapsed + dt + end + + if tw._started then + local t = (tw.duration == 0) and 1 or (tw._elapsed / tw.duration) + local p = clamp01(t) + local e = tw.ease(p) + + for k, toV in pairs(tw.goal) do + local fromV = tw.from[k] + tw.subject[k] = lerp(fromV, toV, e) + end + + if tw.onUpdate then tw.onUpdate(tw.subject, p) end + changed = true + + if t >= 1 then + -- finalize exact goal + for k, toV in pairs(tw.goal) do + tw.subject[k] = toV + end + + if tw.loop == 0 then + tw._done = true + table.remove(self._tweens, i) + if tw.onComplete then tw.onComplete(tw.subject) end + changed = true + else + if tw.loop > 0 then tw.loop = tw.loop - 1 end + + -- Reset for next loop + tw._elapsed = 0 + tw._started = false + + if tw.yoyo then + -- swap from/goal + local newGoal = {} + for k, _ in pairs(tw.goal) do + newGoal[k] = tw.from[k] + end + tw.from = snapshotFrom(tw.subject, newGoal) + tw.goal = newGoal + else + tw.from = snapshotFrom(tw.subject, tw.goal) + end + end + end + end + end + end + + return changed +end + +return anim + + diff --git a/syslib/doublebuf.lua b/syslib/doublebuf.lua new file mode 100644 index 0000000..f4fcff5 --- /dev/null +++ b/syslib/doublebuf.lua @@ -0,0 +1,61 @@ +-- syslib/doublebuf.lua +-- +-- Optional sprite/backbuffer wrapper. +-- +-- Why: +-- On SPI LCDs (like the M5Cardputer's ST7789), direct drawing often flickers because +-- the display is showing the framebuffer while you "clear then draw" each frame. +-- The fix is to render into an off-screen buffer (a sprite/canvas), then push the +-- finished frame to the display in one shot. + +local M = {} + +local function hasSpriteAPI(gfx) + return type(gfx) == "table" and type(gfx.newSprite) == "function" +end + +function M.new(gfx, w, h) + if hasSpriteAPI(gfx) then + local sprite = gfx.newSprite(w, h) + + local g = { + clear = function(c) return sprite:clear(c) end, + setTextColor = function(fg, bg) return sprite:setTextColor(fg, bg) end, + setTextSize = function(sz) return sprite:setTextSize(sz) end, + drawString = function(text, x, y) return sprite:drawString(text, x, y) end, + drawCenterString = function(text, x, y) return sprite:drawCenterString(text, x, y) end, + } + + return { + g = g, + isBuffered = true, + present = function() + -- Push the completed frame. If your binding supports DMA, this should use it. + return sprite:push(0, 0) + end, + free = function() + if sprite and type(sprite.free) == "function" then sprite:free() end + end, + } + end + + -- Unbuffered fallback: proxy directly to gfx's module-level functions. + local g = { + clear = function(c) return gfx.clear(c) end, + setTextColor = function(fg, bg) return gfx.setTextColor(fg, bg) end, + setTextSize = function(sz) return gfx.setTextSize(sz) end, + drawString = function(text, x, y) return gfx.drawString(text, x, y) end, + drawCenterString = function(text, x, y) return gfx.drawCenterString(text, x, y) end, + } + + return { + g = g, + isBuffered = false, + present = function() end, + free = function() end, + } +end + +return M + + diff --git a/syslib/gfxplus.lua b/syslib/gfxplus.lua new file mode 100644 index 0000000..e2104be --- /dev/null +++ b/syslib/gfxplus.lua @@ -0,0 +1,80 @@ +-- Convert a normal 24-bit RGB hex color (#RRGGBB or RRGGBB) to RGB565 (0..65535) +local function hexConvert(hex) + assert(type(hex) == "string", "hexConvert expects a string like '#RRGGBB'") + + hex = hex:gsub("^#", "") + assert(#hex == 6, "hex color must be 6 hex digits (RRGGBB)") + + local r = tonumber(hex:sub(1, 2), 16) + local g = tonumber(hex:sub(3, 4), 16) + local b = tonumber(hex:sub(5, 6), 16) + assert(r and g and b, "invalid hex color") + + -- RGB888 -> RGB565 (truncate, common in embedded code) + local rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) + return rgb565 +end + +local function clampInt(x, lo, hi) + if x < lo then return lo end + if x > hi then return hi end + return x +end + +-- Split RGB565 into channels: +-- r: 0..31, g: 0..63, b: 0..31 +local function unpack565(c) + c = c & 0xFFFF + local r = (c >> 11) & 0x1F + local g = (c >> 5) & 0x3F + local b = c & 0x1F + return r, g, b +end + +local function pack565(r, g, b) + r = r & 0x1F + g = g & 0x3F + b = b & 0x1F + return (r << 11) | (g << 5) | b +end + +-- Blend a foreground RGB565 color over a background RGB565 color, returning an RGB565 result. +-- opacity: +-- - 0..1 (float) OR 0..255 (int) +-- - 0 means fully background, 1/255 means fully foreground +local function withOpacity(fg565, bg565, opacity) + assert(type(fg565) == "number" and type(bg565) == "number", "withOpacity expects (number fg565, number bg565, number opacity)") + assert(type(opacity) == "number", "withOpacity opacity must be a number") + + local a + if opacity <= 1 then + a = math.floor(opacity * 255 + 0.5) + else + a = math.floor(opacity + 0.5) + end + a = clampInt(a, 0, 255) + + if a == 0 then return bg565 & 0xFFFF end + if a == 255 then return fg565 & 0xFFFF end + + local fr, fg, fb = unpack565(fg565) + local br, bg, bb = unpack565(bg565) + + -- out = bg + (fg - bg) * a + -- done per-channel, with rounding. + local r = math.floor((br * (255 - a) + fr * a + 127) / 255) + local g = math.floor((bg * (255 - a) + fg * a + 127) / 255) + local b = math.floor((bb * (255 - a) + fb * a + 127) / 255) + + return pack565( + clampInt(r, 0, 31), + clampInt(g, 0, 63), + clampInt(b, 0, 31) + ) +end + + +return { + hexConvert = hexConvert, + withOpacity = withOpacity, +} \ No newline at end of file