initial commit

This commit is contained in:
wisplite
2025-12-25 20:05:11 -06:00
commit 3a77d95273
5 changed files with 539 additions and 0 deletions
+20
View File
@@ -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 Cardstocks 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.
+118
View File
@@ -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
+260
View File
@@ -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
+61
View File
@@ -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
+80
View File
@@ -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,
}