Soluna 快速入门

11 Oct 2025

虽然我最近给 soluna 贡献了不少提交,但我实际上还不懂得如何使用它。

所以我简单写了一个快速入门指南,总结我自己基于 deepfuture 项目学习到的用法。

注意: soluna 目前还处于快速迭代阶段, APIs 随时可能发生重大变化,本文内容可能很快就会过时。

本文截止 commit a673013

等真正熟悉后,我会考虑同步到 soluna 的官方文档中,并维护更新。

快速入门

local soluna = require("soluna")
local matquad = require("soluna.material.quad")
local mattext = require("soluna.material.text")
local font = require("soluna.font")
local sysfont = require("soluna.font.system")
local math = math

-- soluna 引擎传入的参数, 这是一个 table
-- 包括 width, height, 和 batch 字段
-- 以及其他外部传给 soluna 的字段
args = ...

local FONT_FAMILY = "Wenquanyi Micro Hei" -- 请替换为本机存在的字体
local PANEL_W, PANEL_H = 320, 160

-- 设置窗口标题
soluna.set_window_title("Soluna Quick Start")

-- 获取批渲染 API
local batch = args.batch

-- 创建字体
font.import(assert(sysfont.ttfdata(FONT_FAMILY)))
local font_id = font.name("")
local font_ctx = font.cobj()
-- 创建文字材质
local text_block = mattext.block(font_ctx, font_id, 28, 0xff202020, "CT")
-- 创建提示文字材质
local tip_block = mattext.block(font_ctx, font_id, 20, 0xff405060, "CT")
-- 创建文字对象
local hello_label = text_block("你好,Soluna!", PANEL_W, 64)
-- 创建提示对象
local hint_label = tip_block("把鼠标移到面板上试试", PANEL_W, 32)
-- 创建悬停提示对象
local hover_label = tip_block("鼠标在这里!", PANEL_W, 32)

-- 创建面板和旋转小块材质
local panel_bg = matquad.quad(PANEL_W, PANEL_H, 0xfff2f6ff)
local panel_hover = matquad.quad(PANEL_W, PANEL_H, 0x8033ff66)
local spinner_quad = matquad.quad(48, 48, 0xff3366ff)

-- 状态
local state = { hover = false, t = 0 }
-- 计算面板位置
local panel_x = (args.width - PANEL_W) * 0.5
local panel_y = (args.height - PANEL_H) * 0.5
-- 计算面板中心位置
local panel_cx = panel_x + PANEL_W * 0.5
local panel_cy = panel_y + PANEL_H * 0.5

-- 回调函数, 由引擎调用
local callback = {}

-- 引擎每帧调用
function callback.frame(count)
	state.t = state.t + 1
	-- pulse 表示缩放系数
	-- math.sin 的参数单位是弧度, 0.05 约等于 2π/125
	-- 0.05 的周期约为 125 帧, 约 2 秒
	-- pulse 在 [0.75, 0.95] 之间变化
	local pulse = 0.85 + math.sin(state.t * 0.05) * 0.1

	-- 将面板平移并添加到屏幕中央
	batch:add(panel_bg, panel_x, panel_y)
	-- 如果悬停则叠加悬停背景
	if state.hover then
		batch:add(panel_hover, panel_x, panel_y)
	end
	-- 添加文字和提示到面板上, 位置相对于面板左上角
	batch:add(hello_label, panel_x, panel_y + 32)
	-- 根据悬停状态选择提示文字
	batch:add(state.hover and hover_label or hint_label, panel_x, panel_y + 96)

	-- 下面的代码展示 layer 的嵌套用法
	-- 先平移到面板中心
	batch:layer(panel_cx, panel_cy) -- translate
	do -- do-end 只是为了在视觉上区分层次, 没有实际作用
		batch:layer(pulse, state.t * 0.03, 0, 0) -- scale + rotate
		do
			-- 最后缩放并添加旋转小块
			-- 因为 spinner_quad 的尺寸是 48x48, 所以这里平移 -24, -24 让它中心对齐
			batch:add(spinner_quad, -24, -24)
		end
		-- 弹出旋转层
		batch:layer()
	end
	-- 弹出平移层
	batch:layer()
end

-- 鼠标移动时调用
function callback.mouse_move(mx, my)
	-- 计算鼠标在面板内的局部坐标
	batch:layer(panel_x, panel_y)
	do
		-- point 会把屏幕坐标逆变换成局部坐标
		-- 这里的 mx, my 是屏幕坐标
		-- 返回的 lx, ly 是面板内的局部坐标
		local lx, ly = batch:point(mx, my)
		-- 判断鼠标是否在面板内
		state.hover = lx >= 0 and lx <= PANEL_W and ly >= 0 and ly <= PANEL_H
	end
	-- 弹出平移层
	batch:layer()
end

callback.window_resize = function() end
callback.mouse_button = function() end
callback.mouse_scroll = function() end
callback.key = function() end

return callback

材质模块说明

• 目前 soluna 可直接使用的材质模块只有四种:

文字材质 API 速查

mattext.block(font_mgr, font_id [, font_size [, color [, align_string]]]) -> draw_fn, cursor_fn

返回值:

  1. draw_fn(text, width, height) -> userdata 将字符串排版到给定宽高区域,生成可直接 batch:add 的材质对象。
  2. cursor_fn(text, cursor_index, width, height) -> x, y, w, h, next_index, descent 计算插入光标位置(用于文本编辑),同时返回下一个合法的字符索引和当前行的 descent;若无需光标,可忽略此函数。

BATCH API 速查

交互说明

经典 TODO 开局

soluna 还提供了一个基于 yoga 的 layout 模块,这意味着我们可以用来排版。

local soluna = require("soluna")
local layout = require("soluna.layout")
local datalist = require("soluna.datalist")
local matquad = require("soluna.material.quad")
local mattext = require("soluna.material.text")
local font = require("soluna.font")
local sysfont = require("soluna.font.system")
local app = require("soluna.app")

local utf8 = utf8
local table = table

local args = ...
local batch = args.batch

soluna.set_window_title("Soluna Layout Todo Demo")

font.import(assert(sysfont.ttfdata("Wenquanyi Micro Hei")))
local font_id = font.name("")
local font_ctx = font.cobj()

local text_cache = {}
local function text_factory(size, color, align)
	size = size or 16
	color = color or 0xff000000
	align = align or "LT"
	local key = table.concat({ size, color, align }, ":")
	local fn = text_cache[key]
	if not fn then
		fn = mattext.block(font_ctx, font_id, size, color, align)
		text_cache[key] = fn
	end
	return fn
end

local todos = {
	{ text = "了解 Yoga 布局 API", done = false },
	{ text = "搭建 Todo List UI", done = true },
	{ text = "整合 batch 渲染", done = false },
	{ text = "准备虚拟滚动列表", done = false },
}

local layout_def = [[
id : app
direction : column
padding : 24
gap : 16
background : 0xfff5f7fb
header :
    id : header
    height : 64
    direction : row
    alignItems : center
    gap : 12
    badge :
        id : header_badge
        width : 36
        height : 36
        background : 0xff4c8bf5
    title :
        id : header_title
        text : "待办清单"
        size : 28
        color : 0xff263238
    spacer :
        flex : 1
    counter :
        id : header_counter
        text : "共 0 项 · 已完成 0"
        size : 16
        color : 0xff607d8b
list :
    id : list_panel
    flex : 1
    direction : column
    gap : 10
    children : todo_slots
footer :
    id : footer
    height : 28
    text : "空格:新增待办 · Enter:确认 · Esc:取消 · Backspace:删除字符"
    size : 14
    color : 0xff90a4ae
]]

local function flatten(tbl)
	local list = {}
	local n = 1
	for k, v in pairs(tbl) do
		list[n] = k
		list[n + 1] = v
		n = n + 2
	end
	return list
end

local SLOT_COUNT <const> = 10
local editing_index
local editing_text = ""

local function build_children(tag)
	if tag ~= "todo_slots" then
		return {}
	end
	local nodes = {}
	for i = 1, SLOT_COUNT do
		nodes[#nodes + 1] = ("slot_%d"):format(i)
		nodes[#nodes + 1] = flatten({
			id = "todo_item_" .. i,
			direction = "row",
			alignItems = "center",
			padding = "12 16",
			gap = 12,
			display = "none",
			background = 0xffffffff,
			checkbox = flatten({
				id = "todo_check_" .. i,
				width = 20,
				height = 20,
				background = 0xffcfd8dc,
			}),
			label = flatten({
				id = "todo_label_" .. i,
				flex = 1,
				text = "",
				size = 18,
				color = 0xff37474f,
			}),
			status = flatten({
				id = "todo_status_" .. i,
				text = "",
				size = 14,
				color = 0xffef6c00,
			}),
		})
	end
	return nodes
end

local doc = layout.load(datalist.parse_list(layout_def), build_children)
local root = doc.app

local function apply_todo_styles()
	local done = 0
	for i = 1, SLOT_COUNT do
		local todo = todos[i]
		local elem = doc["todo_item_" .. i]
		local item = elem:attribs()
		local check = doc["todo_check_" .. i]:attribs()
		local label = doc["todo_label_" .. i]:attribs()
		local status = doc["todo_status_" .. i]:attribs()

		if todo then
			if i > SLOT_COUNT then
				break
			end
			local is_editing = (editing_index == i)
			local is_done = todo.done
			local text_value = is_editing and editing_text or todo.text

			elem:update({ display = "flex" })
			item.display = "flex"
			item.background = is_editing and 0xffe3f2fd or (is_done and 0xffe8f5e9 or 0xffffffff)
			check.background = is_done and 0xff4caf50 or 0xffcfd8dc
			label.text = text_value ~= "" and text_value or (is_editing and "(请输入内容)" or "")
			label.color = is_editing and 0xff1a73e8 or (is_done and 0xff78909c or 0xff37474f)
			if is_editing then
				status.text = "输入中"
				status.color = 0xff1a73e8
			else
				status.text = is_done and "完成" or "待办"
				status.color = is_done and 0xff66bb6a or 0xffef6c00
			end
			if is_done then
				done = done + 1
			end
		else
			elem:update({ display = "none" })
			item.display = "none"
			item.background = 0xffffffff
			check.background = 0xffcfd8dc
			label.text = ""
			label.color = 0xff37474f
			status.text = ""
			status.color = 0xffef6c00
		end
	end

	local header_counter = doc.header_counter:attribs()
	header_counter.text = string.format("共 %d 项 · 已完成 %d", #todos, done)
end

local draw_commands = {}

local function rebuild_layout()
	apply_todo_styles()
	root.width = args.width
	root.height = args.height

	local nodes = layout.calc(doc)
	if not editing_index then
		app.set_ime_rect(nil)
	end
	draw_commands = {}
	for _, obj in ipairs(nodes) do
		if obj.display == "none" then
			goto continue
		end
		if obj.background then
			draw_commands[#draw_commands + 1] = {
				data = matquad.quad(obj.w, obj.h, obj.background),
				x = obj.x,
				y = obj.y,
			}
		end
		if obj.text and obj.text ~= "" then
			local factory = text_factory(obj.size, obj.color, obj.text_align)
			draw_commands[#draw_commands + 1] = {
				data = factory(obj.text, obj.w, obj.h),
				x = obj.x,
				y = obj.y,
			}
		end
		::continue::
	end
	if editing_index then
		local label = doc["todo_label_" .. editing_index] and doc["todo_label_" .. editing_index]:attribs()
		if label and label.x and label.y and label.w and label.h then
			app.set_ime_rect(label.x, label.y, label.w, label.h)
		else
			app.set_ime_rect(nil)
		end
	end
end

local function start_edit()
	if editing_index or #todos >= SLOT_COUNT then
		return
	end
	local new_index = #todos + 1
	todos[new_index] = { text = "", done = false }
	editing_index = new_index
	editing_text = ""
end

local function finish_edit()
	if not editing_index then
		return
	end
	local idx = editing_index
	if editing_text == "" then
		table.remove(todos, idx)
	else
		todos[idx].text = editing_text
	end
	editing_index = nil
	editing_text = ""
end

local function cancel_edit()
	if not editing_index then
		return
	end
	local idx = editing_index
	table.remove(todos, idx)
	editing_index = nil
	editing_text = ""
end

local function backspace_edit()
	if not editing_index then
		return
	end
	local len = #editing_text
	if len == 0 then
		return
	end
	local offset = utf8.offset(editing_text, -1)
	if offset then
		editing_text = editing_text:sub(1, offset - 1)
	else
		editing_text = ""
	end
end

local function append_char(codepoint)
	if not editing_index or codepoint == 0 then
		return
	end
	if codepoint < 32 then
		return
	end
	local ch = utf8.char(codepoint)
	if not ch then
		return
	end
	editing_text = editing_text .. ch
end

rebuild_layout()

local callback = {}

function callback.frame()
	rebuild_layout()
	for _, cmd in ipairs(draw_commands) do
		batch:add(cmd.data, cmd.x, cmd.y)
	end
end

function callback.window_resize(w, h)
	args.width = w
	args.height = h
end

function callback.key(code, state)
	if state ~= 0 then
		return
	end
	if code == 32 then -- Space
		start_edit()
	elseif code == 257 or code == 335 then -- Enter / keypad Enter
		finish_edit()
	elseif code == 256 then -- Esc
		cancel_edit()
	elseif code == 259 then -- Backspace
		backspace_edit()
	end
end

function callback.char(codepoint)
	append_char(codepoint)
end

function callback.quit()
	batch:release()
end

callback.mouse_move = function() end
callback.mouse_button = function() end
callback.mouse_scroll = function() end

return callback

Back to home