4005 lines
132 KiB
Lua
4005 lines
132 KiB
Lua
--[[
|
||
exEasyUI v1.7.1
|
||
作者:曾帅、江访
|
||
日期:2025-12-26
|
||
================================
|
||
结构说明:
|
||
1. 常量定义 - UI颜色常量和调试配置
|
||
2. 硬件依赖 - LCD/TP 初始化、字体后端装配
|
||
3. 核心部分
|
||
3.1 渲染子系统
|
||
3.2 运行时与事件系统
|
||
3.3 调试模块
|
||
3.4 Widget 基类
|
||
4. 工具函数 - 绘图工具、字体工具、文本处理工具
|
||
5. 组件部分 - 组件按名称拼音排序,组件列表:
|
||
button、
|
||
check_box、
|
||
combo_box、
|
||
input、
|
||
keyboard、
|
||
label、
|
||
message_box、
|
||
picture、
|
||
progress_bar、
|
||
window、
|
||
6. 对外接口导出
|
||
]]
|
||
|
||
local ui = {
|
||
version = "1.7.1",
|
||
hw = {},
|
||
runtime = {},
|
||
render = {},
|
||
widget = {},
|
||
debug = {}
|
||
}
|
||
|
||
-- 依赖模块(由 LuatOS 侧提供)
|
||
local exlcd = require "exlcd"
|
||
local extp = require "extp"
|
||
local lcd = lcd
|
||
local spi = spi
|
||
local gtfont = gtfont
|
||
local hzfont = hzfont or rawget(_G, "hzfont")
|
||
|
||
-- 前置声明(便于分段组织)
|
||
local clamp
|
||
local now_ms
|
||
local fill_rect
|
||
local stroke_rect
|
||
local font_line_height
|
||
local font_measure
|
||
local font_draw
|
||
local Canvas
|
||
local canvas
|
||
local handle_debug_stats
|
||
|
||
local render_state
|
||
|
||
-- 运行时表提前声明,便于硬件模块引用
|
||
local runtime = {
|
||
roots = {},
|
||
pointer_capture = nil,
|
||
input_bound = false,
|
||
last_pointer = { x = 0, y = 0 },
|
||
touch_anchor_x = 0,
|
||
touch_anchor_y = 0
|
||
}
|
||
ui.runtime = runtime
|
||
|
||
-- 调试状态表提前声明
|
||
local debug_state = {
|
||
enabled = false,
|
||
last_stats = nil,
|
||
last_log_ms = 0,
|
||
accum_frame_ms = 0,
|
||
accum_start_ms = 0,
|
||
timer_id = nil
|
||
}
|
||
|
||
-- ================================
|
||
-- 1. 常量定义
|
||
-- ================================
|
||
|
||
local COLOR_WHITE = 0xFFFF
|
||
local COLOR_BLACK = 0x0000
|
||
local COLOR_GRAY = 0x8410
|
||
local COLOR_BLUE = 0x001F
|
||
local COLOR_RED = 0xF800
|
||
local COLOR_GREEN = 0x07E0
|
||
local COLOR_YELLOW = 0xFFE0
|
||
local COLOR_CYAN = 0x07FF
|
||
local COLOR_MAGENTA = 0xF81F
|
||
local COLOR_ORANGE = 0xFC00
|
||
local COLOR_PINK = 0xF81F
|
||
local COLOR_SKY_BLUE = 0x65BE
|
||
local COLOR_WIN11_LIGHT_DIALOG_BG = 0xF79E
|
||
local COLOR_WIN11_LIGHT_BUTTON_BG = 0xFFDF
|
||
local COLOR_WIN11_LIGHT_BUTTON_BORDER = 0xE73C
|
||
local COLOR_WIN11_DARK_DIALOG_BG = 0x2104
|
||
local COLOR_WIN11_DARK_BUTTON_BG = 0x3186
|
||
local COLOR_WIN11_DARK_BUTTON_BORDER = 0x4A69
|
||
|
||
ui.COLOR_WHITE = COLOR_WHITE
|
||
ui.COLOR_BLACK = COLOR_BLACK
|
||
ui.COLOR_GRAY = COLOR_GRAY
|
||
ui.COLOR_BLUE = COLOR_BLUE
|
||
ui.COLOR_RED = COLOR_RED
|
||
ui.COLOR_GREEN = COLOR_GREEN
|
||
ui.COLOR_YELLOW = COLOR_YELLOW
|
||
ui.COLOR_CYAN = COLOR_CYAN
|
||
ui.COLOR_MAGENTA = COLOR_MAGENTA
|
||
ui.COLOR_ORANGE = COLOR_ORANGE
|
||
ui.COLOR_PINK = COLOR_PINK
|
||
ui.COLOR_SKY_BLUE = COLOR_SKY_BLUE
|
||
ui.COLOR_WIN11_LIGHT_DIALOG_BG = COLOR_WIN11_LIGHT_DIALOG_BG
|
||
ui.COLOR_WIN11_LIGHT_BUTTON_BG = COLOR_WIN11_LIGHT_BUTTON_BG
|
||
ui.COLOR_WIN11_LIGHT_BUTTON_BORDER = COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
ui.COLOR_WIN11_DARK_DIALOG_BG = COLOR_WIN11_DARK_DIALOG_BG
|
||
ui.COLOR_WIN11_DARK_BUTTON_BG = COLOR_WIN11_DARK_BUTTON_BG
|
||
ui.COLOR_WIN11_DARK_BUTTON_BORDER = COLOR_WIN11_DARK_BUTTON_BORDER
|
||
|
||
local DEBUG_LOG_INTERVAL_MS = 1000
|
||
local current_theme = "light"
|
||
gtfont_dev = gtfont_dev or nil
|
||
|
||
local FontAdapter = {
|
||
_backend = "default",
|
||
_size = 12,
|
||
_gray = false,
|
||
_name = nil,
|
||
_hz_antialias = -1
|
||
}
|
||
|
||
-- ================================
|
||
-- 2. 硬件依赖
|
||
-- ================================
|
||
|
||
local function configure_font_backend(opts)
|
||
opts = opts or {}
|
||
local function fallback_default()
|
||
FontAdapter._backend = "default"
|
||
FontAdapter._size = 12
|
||
FontAdapter._gray = false
|
||
FontAdapter._name = opts.name
|
||
FontAdapter._hz_antialias = -1
|
||
if lcd and lcd.setFont and lcd.font_opposansm12_chinese then
|
||
lcd.setFont(lcd.font_opposansm12_chinese)
|
||
log.info("exEasyUI", "使用默认的font_opposansm12_chinese字体")
|
||
else
|
||
log.warn("exEasyUI", "该固件不支持默认的font_opposansm12_chinese字体,将没有中文支持,请更换支持该字体的固件 ")
|
||
end
|
||
end
|
||
if opts.type == "gtfont" and gtfont and spi then
|
||
local spi_id = (opts.spi and opts.spi.id) or 0
|
||
local spi_cs = (opts.spi and opts.spi.cs) or 8
|
||
local spi_clk = 20 * 1000 * 1000
|
||
gtfont_dev = spi.deviceSetup(spi_id, spi_cs, 0, 0, 8, spi_clk, spi.MSB, 1, 0)
|
||
local ok = gtfont_dev and gtfont.init(gtfont_dev)
|
||
if ok then
|
||
FontAdapter._backend = "gtfont"
|
||
FontAdapter._size = tonumber(opts.size or 16)
|
||
FontAdapter._gray = false
|
||
log.info("exEasyUI", "gtfont enabled", spi_id, spi_cs, FontAdapter._size)
|
||
return
|
||
else
|
||
log.warn("exEasyUI", "gtfont init failed, fallback")
|
||
end
|
||
elseif opts.type == "hzfont" and hzfont then
|
||
local cache_size = tonumber(opts.cache_size) or 256
|
||
cache_size = (cache_size == 128 or cache_size == 256 or cache_size == 512 or cache_size == 1024 or cache_size == 2048) and
|
||
cache_size or 256
|
||
local ok = hzfont.init(opts.path, cache_size)
|
||
if ok then
|
||
FontAdapter._backend = "hzfont"
|
||
FontAdapter._size = tonumber(opts.size or 16)
|
||
local aa = tonumber(opts.antialias or -1) or -1
|
||
if not (aa == -1 or aa == 1 or aa == 2 or aa == 4) then
|
||
aa = -1
|
||
end
|
||
FontAdapter._hz_antialias = aa
|
||
log.info("exEasyUI", "hzfont enabled", opts.path or "builtin", FontAdapter._size)
|
||
return
|
||
else
|
||
log.warn("exEasyUI", "hzfont init failed, fallback")
|
||
end
|
||
end
|
||
fallback_default()
|
||
end
|
||
|
||
function ui.hw_init(opts)
|
||
if not opts then
|
||
log.error("ui.hw_init", "opts is nil")
|
||
return false
|
||
end
|
||
local lcd_ok = exlcd.init(opts.lcd_config)
|
||
if not lcd_ok then
|
||
log.error("exEasyUI", "LCD init failed")
|
||
return false
|
||
end
|
||
if opts.enable_buffer ~= false and lcd and lcd.setupBuff then
|
||
lcd.setupBuff(nil, true)
|
||
log.info("exEasyUI", "framebuffer enabled")
|
||
end
|
||
if lcd and lcd.autoFlush then
|
||
lcd.autoFlush(false)
|
||
end
|
||
if lcd and lcd.setAcchw and lcd.ACC_HW_JPEG then
|
||
lcd.setAcchw(lcd.ACC_HW_JPEG, opts.enable_hardware_decode and true or false)
|
||
end
|
||
|
||
-- 初始化触摸IC
|
||
-- 使用配置表中的参数初始化触摸
|
||
local tp_config = opts.tp_config
|
||
extp.init(tp_config)
|
||
|
||
-- 设置消息发布状态
|
||
if tp_config and tp_config.message_enabled then
|
||
if type(tp_config.message_enabled) == "table" then
|
||
for msg_type, enabled in pairs(tp_config.message_enabled) do
|
||
if type(msg_type) == "string" and type(enabled) == "boolean" then
|
||
local success = extp.set_publish_enabled(msg_type, enabled)
|
||
if not success then
|
||
log.warn("exEasyUI", "设置消息发布状态失败:", msg_type, enabled)
|
||
end
|
||
end
|
||
end
|
||
elseif type(tp_config.message_enabled) == "string" then
|
||
local success = extp.set_publish_enabled(tp_config.message_enabled, true)
|
||
if not success then
|
||
log.warn("exEasyUI", "设置消息发布状态失败:", tp_config.message_enabled)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 设置滑动阈值
|
||
if tp_config and tp_config.swipe_threshold then
|
||
if type(tp_config.swipe_threshold) == "number" and tp_config.swipe_threshold > 0 then
|
||
extp.set_swipe_threshold(tp_config.swipe_threshold)
|
||
end
|
||
end
|
||
|
||
-- 设置长按阈值
|
||
if tp_config and tp_config.long_press_threshold then
|
||
if type(tp_config.long_press_threshold) == "number" and tp_config.long_press_threshold > 0 then
|
||
extp.set_long_press_threshold(tp_config.long_press_threshold)
|
||
end
|
||
end
|
||
|
||
runtime.bindInput()
|
||
|
||
local width, height
|
||
|
||
if opts.lcd_config and opts.lcd_config.w then
|
||
width = (opts.lcd_config and opts.lcd_config.w) or (render_state and render_state.viewport_w)
|
||
height = (opts.lcd_config and opts.lcd_config.h) or (render_state and render_state.viewport_h)
|
||
else
|
||
width, height = lcd.getSize()
|
||
end
|
||
|
||
-- 设定字体依赖
|
||
configure_font_backend(opts.font_config or {})
|
||
ui.render.set_viewport(width, height)
|
||
return true
|
||
end
|
||
|
||
-- ================================
|
||
-- 3. 核心部分
|
||
-- ================================
|
||
|
||
-- 3.1 渲染子系统
|
||
|
||
--[[ 刷新机制说明:
|
||
exEasyUI 当前的画面刷新机制采用了“脏区收集 + 延迟批量渲染”策略:
|
||
|
||
1. 脏区收集:当 UI 组件需要刷新时(即 invalidate),会将脏区域 push 到 render_state.dirty_regions,或者标记全屏需刷新,而不会立刻调用渲染。
|
||
2. 延迟批量定时:每次有新的脏区加入时,如果刷新定时器未启动,则会启动一个 30ms 的延时定时器(render_state.batch_timer),多次 invalidate 会聚合在一起,定时器回调时统一刷新。
|
||
3. 批量渲染:定时器触发后,统一执行一次渲染(如 render_dirty_regions_once 或 request_render),根据脏区列表/全屏标志渲染这些区域,并调用 lcd.flush()。渲染后清空脏区和定时器标记,准备下一轮。
|
||
4. 优势:这样能有效合并多组件的刷屏操作(如一次事件引发多个区域变化),大幅减少无谓的重复渲染和屏幕刷新调用,提高性能并减少闪烁。
|
||
|
||
总之,easyui 刷新机制通过“脏区收集 + 延迟批量+合并”实现了响应灵活且高效的 UI 更新,有利于复杂交互场景下的性能优化和体验提升。
|
||
]]
|
||
|
||
render_state = {
|
||
dirty_regions = {}, -- 当前帧需要刷新的区域列表(数组)
|
||
full_refresh = true, -- 是否需要全屏刷新
|
||
need_present = false, -- 是否需要LCD重新显示
|
||
viewport_w = 320, -- 渲染视口宽度,默认320
|
||
viewport_h = 240, -- 渲染视口高度,默认240
|
||
clear_color = COLOR_BLACK, -- 清屏颜色
|
||
render_in_progress = false, -- 是否正在渲染
|
||
render_pending = false, -- 是否有待渲染请求
|
||
batch_timer_id = nil, -- 批量刷新定时器ID
|
||
batch_delay_ms = 30 -- 批量刷新延迟(单位ms)
|
||
}
|
||
|
||
-- 计算当前脏区的纵向范围,返回 min_y/max_y(用于局部刷新优化)
|
||
local function accumulate_dirty_y_range()
|
||
if render_state.full_refresh then
|
||
return 0, render_state.viewport_h - 1
|
||
end
|
||
if #render_state.dirty_regions == 0 then
|
||
return nil, nil
|
||
end
|
||
local min_y = render_state.viewport_h
|
||
local max_y = 0
|
||
for i = 1, #render_state.dirty_regions do
|
||
local r = render_state.dirty_regions[i]
|
||
local rmin = clamp(r.y, 0, render_state.viewport_h - 1)
|
||
local rmax = clamp(r.y + r.h - 1, 0, render_state.viewport_h - 1)
|
||
if rmin < min_y then min_y = rmin end
|
||
if rmax > max_y then max_y = rmax end
|
||
end
|
||
if min_y > max_y then
|
||
return nil, nil
|
||
end
|
||
return min_y, max_y
|
||
end
|
||
|
||
-- 重置脏区状态,使下一帧从空白状态开始
|
||
local function reset_dirty_state()
|
||
render_state.dirty_regions = {}
|
||
render_state.full_refresh = false
|
||
render_state.need_present = false
|
||
end
|
||
|
||
-- 递归绘制整个 widget 树,传入脏区范围用于局部渲染
|
||
local function draw_widget_tree(widget, dirty, stats)
|
||
if not widget.visible then return end
|
||
if stats then stats.widgets = stats.widgets + 1 end
|
||
if widget.draw then
|
||
widget:draw(canvas, dirty)
|
||
end
|
||
if widget.children then
|
||
for i = 1, #widget.children do
|
||
draw_widget_tree(widget.children[i], dirty, stats)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 执行一次脏区渲染:只在确实有脏区时才调用,绘制后立即重置脏区
|
||
-- 渲染器核心:根据当前积累的 dirty_regions 渲染整个 widget 树
|
||
-- 如果没脏区直接返回,避免无谓的绘制
|
||
local function render_dirty_regions_once()
|
||
if not render_state.need_present then
|
||
if lcd and lcd.flush then
|
||
lcd.flush()
|
||
end
|
||
return false
|
||
end
|
||
local start_ms = now_ms()
|
||
local y_min, y_max = accumulate_dirty_y_range()
|
||
if not y_min then
|
||
reset_dirty_state()
|
||
return false
|
||
end
|
||
if render_state.full_refresh and canvas and canvas.clear then
|
||
canvas:clear(render_state.clear_color)
|
||
end
|
||
local stats = {
|
||
widgets = 0,
|
||
dirty_span = (y_max and y_min) and (y_max - y_min + 1) or 0,
|
||
full_refresh = render_state.full_refresh
|
||
}
|
||
local dirty_span = { y_min = y_min, y_max = y_max }
|
||
for i = 1, #runtime.roots do
|
||
draw_widget_tree(runtime.roots[i], dirty_span, stats)
|
||
end
|
||
stats.frame_ms = now_ms() - start_ms
|
||
handle_debug_stats(stats)
|
||
if lcd and lcd.flush then
|
||
lcd.flush()
|
||
end
|
||
reset_dirty_state()
|
||
return true
|
||
end
|
||
|
||
-- 请求一次渲染:设置 need_present 并串行调用 render_frame,确保当前渲染完成前不会再次启动
|
||
-- 用于 `invalidate`/`background` 等接口
|
||
-- 请求一次脏区渲染:只有在当前没有正在渲染的情况下才执行,否则设置 pending 让当前帧结束后继续渲染
|
||
local function cancel_batch_timer()
|
||
if render_state.batch_timer_id and sys and sys.timerStop then
|
||
sys.timerStop(render_state.batch_timer_id)
|
||
end
|
||
render_state.batch_timer_id = nil
|
||
end
|
||
|
||
local function request_render()
|
||
cancel_batch_timer()
|
||
render_state.need_present = true
|
||
if render_state.render_in_progress then
|
||
render_state.render_pending = true
|
||
return false
|
||
end
|
||
local result = false
|
||
repeat
|
||
-- 每轮先清除 pending 标记再执行
|
||
render_state.render_pending = false
|
||
render_state.render_in_progress = true
|
||
result = render_dirty_regions_once()
|
||
render_state.render_in_progress = false
|
||
-- 如果在 render_dirty_regions_once 中又产生新的 invalidate,就继续渲染
|
||
until not render_state.render_pending
|
||
return result
|
||
end
|
||
|
||
-- 批量渲染调度函数:合并短时间内多次渲染请求,只调度一次定时渲染
|
||
local function schedule_batched_render()
|
||
render_state.need_present = true -- 标记需要渲染
|
||
-- 如果不支持 sys.timerStart 或未设置批量延迟,或批量延迟为0,则直接渲染
|
||
if not sys or not sys.timerStart or (render_state.batch_delay_ms or 0) <= 0 then
|
||
return request_render()
|
||
end
|
||
-- 已有定时器任务在排队,不重复调度
|
||
if render_state.batch_timer_id then
|
||
return
|
||
end
|
||
-- 启动一次定时器,到期后执行渲染并清除计时器ID
|
||
render_state.batch_timer_id = sys.timerStart(function()
|
||
render_state.batch_timer_id = nil
|
||
request_render()
|
||
end, render_state.batch_delay_ms)
|
||
end
|
||
|
||
-- 设置逻辑分辨率(主要由硬件初始化时调用)
|
||
ui.render.set_viewport = function(w, h)
|
||
if w then render_state.viewport_w = w end
|
||
if h then render_state.viewport_h = h end
|
||
end
|
||
|
||
-- 直接填充背景色并强制标记全屏脏区
|
||
ui.render.background = function(color)
|
||
render_state.clear_color = color or COLOR_BLACK
|
||
render_state.full_refresh = true
|
||
schedule_batched_render()
|
||
end
|
||
|
||
-- 标记一个脏区并触发渲染;传入 nil 意味着全屏刷新
|
||
ui.render.invalidate = function(rect)
|
||
if not rect then
|
||
render_state.full_refresh = true
|
||
else
|
||
render_state.dirty_regions[#render_state.dirty_regions + 1] = rect
|
||
end
|
||
schedule_batched_render()
|
||
end
|
||
|
||
-- 设置批量渲染延迟(单位:毫秒),用于合并多次刷新请求,减少刷新次数
|
||
ui.render.set_batch_delay = function(ms)
|
||
local delay = tonumber(ms)
|
||
if delay and delay >= 0 then
|
||
render_state.batch_delay_ms = delay
|
||
else
|
||
render_state.batch_delay_ms = 0
|
||
end
|
||
end
|
||
|
||
ui.render.present = request_render
|
||
|
||
-- 3.1.1 图片缓存管理器
|
||
local image_cache = {
|
||
_zbuff_cache = {}, -- 路径 -> zbuff 映射
|
||
_loading = {}, -- 正在加载的路径集合(防止重复加载)
|
||
_failed = {} -- 加载失败的路径集合(避免重复尝试)
|
||
}
|
||
|
||
-- 获取图片 zbuff(按需加载)
|
||
function image_cache.get_zbuff(path)
|
||
if not path or type(path) ~= "string" or path == "" then
|
||
return nil
|
||
end
|
||
|
||
-- 检查是否已缓存
|
||
if image_cache._zbuff_cache[path] then
|
||
return image_cache._zbuff_cache[path]
|
||
end
|
||
|
||
-- 检查是否正在加载(防止重复加载)
|
||
if image_cache._loading[path] then
|
||
return nil
|
||
end
|
||
|
||
-- 检查是否已失败
|
||
if image_cache._failed[path] then
|
||
return nil
|
||
end
|
||
|
||
-- 检查文件是否存在
|
||
if io and io.exists then
|
||
if not io.exists(path) then
|
||
image_cache._failed[path] = true
|
||
log.warn("image_cache", "图片文件不存在", path)
|
||
return nil
|
||
end
|
||
end
|
||
|
||
-- 检查 lcd.image2raw 是否可用
|
||
if not lcd or not lcd.image2raw then
|
||
return nil
|
||
end
|
||
|
||
-- 标记为正在加载
|
||
image_cache._loading[path] = true
|
||
|
||
-- 解码图片
|
||
local ok, zbuff = pcall(lcd.image2raw, path)
|
||
image_cache._loading[path] = false
|
||
|
||
if ok and zbuff then
|
||
-- 缓存成功
|
||
image_cache._zbuff_cache[path] = zbuff
|
||
return zbuff
|
||
else
|
||
-- 解码失败
|
||
image_cache._failed[path] = true
|
||
log.warn("image_cache", "图片解码失败", path)
|
||
return nil
|
||
end
|
||
end
|
||
|
||
-- 预加载图片
|
||
function image_cache.preload(path)
|
||
if not path or type(path) ~= "string" or path == "" then
|
||
return false
|
||
end
|
||
|
||
-- 如果已缓存,直接返回
|
||
if image_cache._zbuff_cache[path] then
|
||
return true
|
||
end
|
||
|
||
-- 尝试加载
|
||
local zbuff = image_cache.get_zbuff(path)
|
||
return zbuff ~= nil
|
||
end
|
||
|
||
-- 清除缓存
|
||
function image_cache.clear(path)
|
||
if path then
|
||
-- 清除指定路径
|
||
image_cache._zbuff_cache[path] = nil
|
||
image_cache._loading[path] = nil
|
||
image_cache._failed[path] = nil
|
||
else
|
||
-- 清除所有缓存
|
||
image_cache._zbuff_cache = {}
|
||
image_cache._loading = {}
|
||
image_cache._failed = {}
|
||
end
|
||
end
|
||
|
||
-- 检查缓存状态
|
||
function image_cache.is_cached(path)
|
||
if not path then return false end
|
||
return image_cache._zbuff_cache[path] ~= nil
|
||
end
|
||
|
||
ui.image_cache = image_cache
|
||
|
||
-- 3.2 运行时与事件系统
|
||
local function dispatch_pointer(evt, a, b)
|
||
for i = #runtime.roots, 1, -1 do
|
||
local root = runtime.roots[i]
|
||
if root:dispatch_pointer(evt, a, b) then
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function debug_touch_log(evt, rawA, rawB, cursorX, cursorY)
|
||
if not debug_state.enabled then return end
|
||
local function sval(v)
|
||
if v == nil then return "nil" end
|
||
return tostring(v)
|
||
end
|
||
-- log.info("exEasyUI.debug.tp", string.format("evt=%s raw=(%s,%s) cursor=(%s,%s)",
|
||
-- tostring(evt or ""), sval(rawA), sval(rawB), sval(cursorX), sval(cursorY)))
|
||
end
|
||
|
||
local function handle_touch_event(evt, a, b)
|
||
local rawA = a
|
||
local rawB = b
|
||
if evt == "TOUCH_DOWN" or evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
|
||
runtime.last_pointer.x = tonumber(a) or 0
|
||
runtime.last_pointer.y = tonumber(b) or 0
|
||
runtime.touch_anchor_x = runtime.last_pointer.x
|
||
runtime.touch_anchor_y = runtime.last_pointer.y
|
||
debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
end
|
||
if evt == "MOVE_X" then
|
||
local delta = tonumber(a) or 0
|
||
if runtime.touch_anchor_x == nil then
|
||
runtime.touch_anchor_x = runtime.last_pointer.x
|
||
end
|
||
runtime.last_pointer.x = (runtime.touch_anchor_x or 0) + delta
|
||
debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
elseif evt == "MOVE_Y" then
|
||
local delta = tonumber(b) or 0
|
||
if runtime.touch_anchor_y == nil then
|
||
runtime.touch_anchor_y = runtime.last_pointer.y
|
||
end
|
||
runtime.last_pointer.y = (runtime.touch_anchor_y or 0) + delta
|
||
debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
|
||
else
|
||
local ax = tonumber(a)
|
||
local ay = tonumber(b)
|
||
debug_touch_log(evt, rawA, rawB, ax, ay)
|
||
return dispatch_pointer(evt, ax, ay)
|
||
end
|
||
end
|
||
|
||
function runtime.bindInput()
|
||
if runtime.input_bound then return end
|
||
sys.subscribe("BASE_TOUCH_EVENT", handle_touch_event)
|
||
runtime.input_bound = true
|
||
end
|
||
|
||
function runtime.add(widget)
|
||
runtime.roots[#runtime.roots + 1] = widget
|
||
widget.root = true
|
||
if widget.on_mount then widget:on_mount() end
|
||
widget:invalidate()
|
||
return widget
|
||
end
|
||
|
||
function runtime.remove(widget)
|
||
for i = #runtime.roots, 1, -1 do
|
||
if runtime.roots[i] == widget then
|
||
table.remove(runtime.roots, i)
|
||
if widget.on_unmount then widget:on_unmount() end
|
||
render_state.full_refresh = true
|
||
request_render()
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
local function debug_emit_summary()
|
||
local total = debug_state.accum_frame_ms or 0
|
||
local usage = (total / DEBUG_LOG_INTERVAL_MS) * 100
|
||
log.info("exEasyUI.debug", string.format("最近1s绘制耗时=%.1fms 耗时占比=%.1f%%", total, usage))
|
||
debug_state.accum_frame_ms = 0
|
||
debug_state.accum_start_ms = now_ms()
|
||
end
|
||
|
||
local function debug_timer_tick()
|
||
if not debug_state.enabled then return end
|
||
debug_emit_summary()
|
||
end
|
||
|
||
handle_debug_stats = function(stats)
|
||
debug_state.last_stats = stats
|
||
if not debug_state.enabled then return end
|
||
if stats and stats.frame_ms then
|
||
log.info("exEasyUI.debug", string.format("单次绘制耗时=%.1fms 绘制组件=%d 脏区高度=%dpx 绘制方式=%s",
|
||
stats.frame_ms or 0,
|
||
stats.widgets or 0,
|
||
stats.dirty_span or 0,
|
||
stats.full_refresh and "全屏" or "局部"))
|
||
debug_state.accum_frame_ms = (debug_state.accum_frame_ms or 0) + (stats.frame_ms or 0)
|
||
end
|
||
if not debug_state.timer_id then
|
||
local now = now_ms()
|
||
if debug_state.accum_start_ms == 0 then
|
||
debug_state.accum_start_ms = now
|
||
end
|
||
local window_ms = DEBUG_LOG_INTERVAL_MS
|
||
if now - debug_state.accum_start_ms >= window_ms then
|
||
debug_emit_summary()
|
||
end
|
||
end
|
||
end
|
||
|
||
function ui.debug.enable(enabled)
|
||
enabled = not not enabled
|
||
if enabled and not debug_state.enabled then
|
||
debug_state.enabled = true
|
||
debug_state.accum_frame_ms = 0
|
||
debug_state.accum_start_ms = now_ms()
|
||
if sys and sys.timerLoopStart then
|
||
debug_state.timer_id = sys.timerLoopStart(debug_timer_tick, DEBUG_LOG_INTERVAL_MS)
|
||
end
|
||
elseif (not enabled) and debug_state.enabled then
|
||
debug_state.enabled = false
|
||
if debug_state.timer_id and sys and sys.timerStop then
|
||
sys.timerStop(debug_state.timer_id)
|
||
end
|
||
debug_state.timer_id = nil
|
||
debug_state.accum_frame_ms = 0
|
||
debug_state.accum_start_ms = 0
|
||
end
|
||
end
|
||
|
||
function ui.debug.set_level(level)
|
||
if level == "off" then
|
||
ui.debug.enable(false)
|
||
else
|
||
ui.debug.enable(true)
|
||
end
|
||
end
|
||
|
||
function ui.debug.get_stats()
|
||
return debug_state.last_stats
|
||
end
|
||
|
||
setmetatable(ui.debug, {
|
||
__call = function(_, enabled)
|
||
ui.debug.enable(enabled)
|
||
end
|
||
})
|
||
|
||
-- 3.4 Widget 基类
|
||
local BaseWidget = {}
|
||
BaseWidget.__index = BaseWidget
|
||
|
||
function BaseWidget:new(opts)
|
||
opts = opts or {}
|
||
local o = setmetatable({}, self)
|
||
o.x = opts.x or 0
|
||
o.y = opts.y or 0
|
||
o.w = opts.w or 0
|
||
o.h = opts.h or 0
|
||
o.visible = opts.visible ~= false
|
||
o.enabled = opts.enabled ~= false
|
||
o.children = {}
|
||
o.theme = opts.theme
|
||
return o
|
||
end
|
||
|
||
function BaseWidget:get_absolute_position()
|
||
local x = self.x or 0
|
||
local y = self.y or 0
|
||
local parent = self.parent
|
||
while parent do
|
||
x = x + (parent.x or 0)
|
||
y = y + (parent.y or 0)
|
||
if parent._scroll then
|
||
x = x + (parent._scroll.offset_x or 0)
|
||
y = y + (parent._scroll.offset_y or 0)
|
||
end
|
||
parent = parent.parent
|
||
end
|
||
return x, y
|
||
end
|
||
|
||
function BaseWidget:add(child)
|
||
self.children[#self.children + 1] = child
|
||
child.parent = self
|
||
child:invalidate()
|
||
return child
|
||
end
|
||
|
||
function BaseWidget:get_bounds()
|
||
local ax, ay = self:get_absolute_position()
|
||
return { x = ax, y = ay, w = self.w, h = self.h }
|
||
end
|
||
|
||
function BaseWidget:invalidate(rect)
|
||
local bounds = rect or self:get_bounds()
|
||
ui.render.invalidate(bounds)
|
||
end
|
||
|
||
function BaseWidget:contains_point(px, py)
|
||
local bounds = self:get_bounds()
|
||
return px >= bounds.x and py >= bounds.y and
|
||
px <= (bounds.x + bounds.w) and py <= (bounds.y + bounds.h)
|
||
end
|
||
|
||
function BaseWidget:handle_event()
|
||
return false
|
||
end
|
||
|
||
function BaseWidget:dispatch_pointer(evt, x, y)
|
||
if not self.visible or not self.enabled then
|
||
return false
|
||
end
|
||
if self.children then
|
||
for i = #self.children, 1, -1 do
|
||
if self.children[i]:dispatch_pointer(evt, x, y) then
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
if self.handle_event ~= BaseWidget.handle_event then
|
||
return self:handle_event(evt, x, y)
|
||
end
|
||
return false
|
||
end
|
||
|
||
ui.widget.Base = BaseWidget
|
||
|
||
-- ================================
|
||
-- 4. 工具函数
|
||
-- ================================
|
||
|
||
clamp = function(v, minv, maxv)
|
||
if v < minv then return minv end
|
||
if v > maxv then return maxv end
|
||
return v
|
||
end
|
||
|
||
now_ms = function()
|
||
if mcu and mcu.ticks then
|
||
return mcu.ticks()
|
||
end
|
||
if sys and sys.tick then
|
||
return sys.tick()
|
||
end
|
||
return (os.time() or 0) * 1000
|
||
end
|
||
|
||
fill_rect = function(x1, y1, x2, y2, color)
|
||
if not lcd or not lcd.fill then return end
|
||
lcd.fill(x1, y1, x2, y2 + 1, color)
|
||
end
|
||
|
||
stroke_rect = function(x1, y1, x2, y2, color)
|
||
if not lcd then return end
|
||
if lcd.drawLine then
|
||
lcd.drawLine(x1, y1, x2, y1, color)
|
||
lcd.drawLine(x2, y1, x2, y2, color)
|
||
lcd.drawLine(x2, y2, x1, y2, color)
|
||
lcd.drawLine(x1, y2, x1, y1, color)
|
||
end
|
||
end
|
||
|
||
font_line_height = function(style)
|
||
if FontAdapter._backend == "gtfont" or FontAdapter._backend == "hzfont" then
|
||
return tonumber(style and style.size or FontAdapter._size or 16)
|
||
end
|
||
if lcd and style and style.size then
|
||
local guess = "font_opposansm" .. tostring(style.size) .. "_chinese"
|
||
if lcd[guess] then
|
||
return tonumber(style.size)
|
||
end
|
||
end
|
||
return FontAdapter._size or 12
|
||
end
|
||
|
||
font_measure = function(text, style)
|
||
if not text or text == "" then return 0 end
|
||
style = style or {}
|
||
if FontAdapter._backend == "gtfont" and lcd and lcd.getGtfontStrWidth then
|
||
return lcd.getGtfontStrWidth(text, tonumber(style.size or FontAdapter._size or 16))
|
||
end
|
||
if FontAdapter._backend == "hzfont" and lcd and lcd.getHzFontStrWidth then
|
||
return lcd.getHzFontStrWidth(text, tonumber(style.size or FontAdapter._size or 16))
|
||
end
|
||
if lcd and lcd.getStrWidth then
|
||
return lcd.getStrWidth(text)
|
||
end
|
||
local width = 0
|
||
local i = 1
|
||
local size = tonumber(style.size) or FontAdapter._size or 12
|
||
while i <= #text do
|
||
local byte = string.byte(text, i)
|
||
if byte < 128 then
|
||
width = width + math.ceil(size / 2)
|
||
i = i + 1
|
||
else
|
||
width = width + size
|
||
i = i + 3
|
||
end
|
||
end
|
||
return width
|
||
end
|
||
|
||
font_draw = function(text, x, y, color, style)
|
||
if not lcd then return end
|
||
style = style or {}
|
||
color = color or COLOR_WHITE
|
||
if FontAdapter._backend == "gtfont" and lcd.drawGtfontUtf8 then
|
||
local sz = tonumber(style.size or FontAdapter._size or 16)
|
||
if FontAdapter._gray and lcd.drawGtfontUtf8Gray then
|
||
lcd.drawGtfontUtf8Gray(text, sz, 4, x, y, color)
|
||
return
|
||
end
|
||
lcd.drawGtfontUtf8(text, sz, x, y, color)
|
||
return
|
||
end
|
||
if FontAdapter._backend == "hzfont" and lcd.drawHzfontUtf8 then
|
||
local sz = tonumber(style.size or FontAdapter._size or 16)
|
||
local lh = font_line_height(style)
|
||
lcd.drawHzfontUtf8(x, y + lh, text, sz, color, FontAdapter._hz_antialias or -1)
|
||
return
|
||
end
|
||
if lcd.setFont then
|
||
if FontAdapter._name and lcd["font_" .. FontAdapter._name] then
|
||
lcd.setFont(lcd["font_" .. FontAdapter._name])
|
||
elseif style.size then
|
||
local guess = "font_opposansm" .. tostring(style.size) .. "_chinese"
|
||
if lcd[guess] then
|
||
lcd.setFont(lcd[guess])
|
||
elseif lcd.font_opposansm12_chinese then
|
||
lcd.setFont(lcd.font_opposansm12_chinese)
|
||
end
|
||
elseif lcd.font_opposansm12_chinese then
|
||
lcd.setFont(lcd.font_opposansm12_chinese)
|
||
end
|
||
end
|
||
local lh = font_line_height(style)
|
||
if lcd.drawStr then
|
||
lcd.drawStr(x, y + lh, text, color)
|
||
end
|
||
end
|
||
|
||
Canvas = {}
|
||
Canvas.__index = Canvas
|
||
|
||
function Canvas:new()
|
||
return setmetatable({}, Canvas)
|
||
end
|
||
|
||
function Canvas:clear(color)
|
||
if lcd and lcd.clear then
|
||
lcd.clear(color or COLOR_BLACK)
|
||
end
|
||
end
|
||
|
||
function Canvas:fill_rect(x, y, w, h, color)
|
||
if w <= 0 or h <= 0 then return end
|
||
fill_rect(x, y, x + w - 1, y + h - 1, color)
|
||
end
|
||
|
||
function Canvas:stroke_rect(x, y, w, h, color)
|
||
if w <= 0 or h <= 0 then return end
|
||
stroke_rect(x, y, x + w - 1, y + h - 1, color)
|
||
end
|
||
|
||
function Canvas:draw_text(text, x, y, color, style)
|
||
font_draw(text, x, y, color, style)
|
||
end
|
||
|
||
function Canvas:text_width(text, style)
|
||
return font_measure(text, style)
|
||
end
|
||
|
||
function Canvas:line_height(style)
|
||
return font_line_height(style)
|
||
end
|
||
|
||
function Canvas:draw_text_in_rect_centered(x, y, w, h, text, opts)
|
||
opts = opts or {}
|
||
local padding = opts.padding or 0
|
||
local style = opts.style or {}
|
||
local color = opts.color or COLOR_WHITE
|
||
local tw = self:text_width(text or "", style)
|
||
local lh = self:line_height(style)
|
||
local inner_w = math.max(0, w - padding * 2)
|
||
local inner_h = math.max(0, h - padding * 2)
|
||
local tx = x + padding + (inner_w - tw) // 2
|
||
local ty = y + padding + (inner_h - lh) // 2
|
||
self:draw_text(text or "", tx, ty, color, style)
|
||
end
|
||
|
||
canvas = Canvas:new()
|
||
|
||
local function get_utf8_char(text, i)
|
||
if not text or i > #text then return "", 0 end
|
||
local byte = string.byte(text, i)
|
||
if not byte then return "", 0 end
|
||
if byte < 128 then
|
||
return string.sub(text, i, i), 1
|
||
elseif byte >= 224 and byte < 240 then
|
||
if i + 2 <= #text then
|
||
return string.sub(text, i, i + 2), 3
|
||
else
|
||
return string.sub(text, i, i), 1
|
||
end
|
||
elseif byte >= 192 and byte < 224 then
|
||
if i + 1 <= #text then
|
||
return string.sub(text, i, i + 1), 2
|
||
else
|
||
return string.sub(text, i, i), 1
|
||
end
|
||
elseif byte >= 240 then
|
||
if i + 3 <= #text then
|
||
return string.sub(text, i, i + 3), 4
|
||
else
|
||
return string.sub(text, i, i), 1
|
||
end
|
||
end
|
||
return string.sub(text, i, i), 1
|
||
end
|
||
|
||
local function wrap_text_lines(text, maxWidth, style)
|
||
if not text or text == "" then return { "" } end
|
||
if not maxWidth or maxWidth <= 0 then return { text } end
|
||
local lines = {}
|
||
local currentLine = ""
|
||
local currentWidth = 0
|
||
local wordBuffer = ""
|
||
local wordWidth = 0
|
||
local i = 1
|
||
while i <= #text do
|
||
local char, charLen = get_utf8_char(text, i)
|
||
local charWidth = font_measure(char, style)
|
||
local byte = string.byte(text, i)
|
||
local isAlphaNum = (byte and ((byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122)))
|
||
if isAlphaNum then
|
||
wordBuffer = wordBuffer .. char
|
||
wordWidth = wordWidth + charWidth
|
||
i = i + charLen
|
||
else
|
||
if wordBuffer ~= "" then
|
||
if currentWidth + wordWidth > maxWidth then
|
||
if currentLine ~= "" then
|
||
table.insert(lines, currentLine)
|
||
currentLine = wordBuffer
|
||
currentWidth = wordWidth
|
||
else
|
||
currentLine = wordBuffer
|
||
currentWidth = wordWidth
|
||
end
|
||
else
|
||
currentLine = currentLine .. wordBuffer
|
||
currentWidth = currentWidth + wordWidth
|
||
end
|
||
wordBuffer = ""
|
||
wordWidth = 0
|
||
end
|
||
if char == " " then
|
||
if currentWidth + charWidth <= maxWidth then
|
||
currentLine = currentLine .. char
|
||
currentWidth = currentWidth + charWidth
|
||
else
|
||
if currentLine ~= "" then
|
||
table.insert(lines, currentLine)
|
||
end
|
||
currentLine = ""
|
||
currentWidth = 0
|
||
end
|
||
else
|
||
if currentWidth + charWidth > maxWidth then
|
||
if currentLine ~= "" then
|
||
table.insert(lines, currentLine)
|
||
end
|
||
currentLine = char
|
||
currentWidth = charWidth
|
||
else
|
||
currentLine = currentLine .. char
|
||
currentWidth = currentWidth + charWidth
|
||
end
|
||
end
|
||
i = i + charLen
|
||
end
|
||
end
|
||
if wordBuffer ~= "" then
|
||
if currentWidth + wordWidth > maxWidth and currentLine ~= "" then
|
||
table.insert(lines, currentLine)
|
||
currentLine = wordBuffer
|
||
else
|
||
currentLine = currentLine .. wordBuffer
|
||
end
|
||
end
|
||
if currentLine ~= "" then
|
||
table.insert(lines, currentLine)
|
||
end
|
||
if #lines == 0 then
|
||
lines = { "" }
|
||
end
|
||
return lines
|
||
end
|
||
|
||
local function fit_text_to_width(text, maxWidth, style, opts)
|
||
opts = opts or {}
|
||
if not text then return "" end
|
||
if not maxWidth or maxWidth <= 0 then return text end
|
||
if font_measure(text, style) <= maxWidth then
|
||
return text
|
||
end
|
||
local ellipsis = opts.ellipsis and "..." or ""
|
||
local reserve = opts.ellipsis and font_measure("...", style) or 0
|
||
local limit = maxWidth - reserve
|
||
if limit <= 0 then
|
||
return opts.ellipsis and "..." or ""
|
||
end
|
||
local truncated = ""
|
||
local used = 0
|
||
local i = 1
|
||
while i <= #text do
|
||
local char, len = get_utf8_char(text, i)
|
||
local cw = font_measure(char, style)
|
||
if used + cw > limit then
|
||
break
|
||
end
|
||
truncated = truncated .. char
|
||
used = used + cw
|
||
i = i + len
|
||
end
|
||
if opts.ellipsis then
|
||
return truncated .. "..."
|
||
end
|
||
return truncated
|
||
end
|
||
|
||
local function draw_text_direct(x, y, text, opts)
|
||
opts = opts or {}
|
||
font_draw(text or "", x, y, opts.color or COLOR_WHITE, opts.style or {})
|
||
end
|
||
|
||
local function draw_text_in_rect_centered(x, y, w, h, text, opts)
|
||
opts = opts or {}
|
||
local padding = opts.padding or 0
|
||
local style = opts.style or {}
|
||
local color = opts.color or COLOR_WHITE
|
||
local tw = font_measure(text or "", style)
|
||
local lh = font_line_height(style)
|
||
local inner_w = math.max(0, w - padding * 2)
|
||
local inner_h = math.max(0, h - padding * 2)
|
||
local tx = x + padding + (inner_w - tw) // 2
|
||
local ty = y + padding + (inner_h - lh) // 2
|
||
font_draw(text or "", tx, ty, color, style)
|
||
end
|
||
|
||
local function draw_image_placeholder(x, y, w, h, bg_color, border_color)
|
||
bg_color = bg_color or COLOR_GRAY
|
||
border_color = border_color or COLOR_WHITE
|
||
fill_rect(x, y, x + w - 1, y + h - 1, bg_color)
|
||
stroke_rect(x, y, x + w - 1, y + h - 1, border_color)
|
||
if lcd and lcd.drawLine then
|
||
lcd.drawLine(x, y, x + w - 1, y + h - 1, border_color)
|
||
lcd.drawLine(x + w - 1, y, x, y + h - 1, border_color)
|
||
if w >= 20 and h >= 20 then
|
||
local margin = math.min(w, h) // 8
|
||
lcd.drawLine(x + margin, y + margin, x + w - 1 - margin, y + h - 1 - margin, border_color)
|
||
lcd.drawLine(x + w - 1 - margin, y + margin, x + margin, y + h - 1 - margin, border_color)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 箭头绘制工具(在给定矩形内绘制上下左右箭头)
|
||
local function draw_arrow_icon(x, y, w, h, direction, color)
|
||
local cx = x + w // 2
|
||
local cy = y + h // 2
|
||
-- 控制箭头尺寸(增大内边距,整体缩短约 1/3)
|
||
local padX = math.max(1, w // 3)
|
||
local padY = math.max(1, h // 3)
|
||
local leftX = x + padX
|
||
local rightX = x + w - padX
|
||
local topY = y + padY
|
||
local bottomY = y + h - padY
|
||
|
||
if direction == "up" then
|
||
lcd.drawLine(leftX, bottomY, cx, topY, color)
|
||
lcd.drawLine(rightX, bottomY, cx, topY, color)
|
||
elseif direction == "down" then
|
||
lcd.drawLine(leftX, topY, cx, bottomY, color)
|
||
lcd.drawLine(rightX, topY, cx, bottomY, color)
|
||
elseif direction == "left" then
|
||
-- 左侧中点 -> 右上/右下(<)
|
||
lcd.drawLine(leftX, cy, rightX, topY, color)
|
||
lcd.drawLine(leftX, cy, rightX, bottomY, color)
|
||
elseif direction == "right" then
|
||
-- 右侧中点 -> 左上/左下(>)
|
||
lcd.drawLine(rightX, cy, leftX, topY, color)
|
||
lcd.drawLine(rightX, cy, leftX, bottomY, color)
|
||
end
|
||
end
|
||
-- ================================
|
||
-- 5. 组件部分(按拼音排序)
|
||
-- 组件列表:button、check_box、combo_box、input、keyboard、label、message_box、picture、progress_bar、window
|
||
-- ================================
|
||
|
||
-- 5.1 button
|
||
local button = setmetatable({}, { __index = BaseWidget })
|
||
button.__index = button
|
||
|
||
function button:new(opts)
|
||
opts = opts or {}
|
||
opts.w = opts.w or opts.width or 100
|
||
opts.h = opts.h or opts.height or 36
|
||
local o = BaseWidget.new(self, opts)
|
||
local dark = (current_theme == "dark")
|
||
o.text = opts.text or "Button"
|
||
o.text_style = { size = opts.text_size or 12 }
|
||
o.colors = {
|
||
bg = opts.bg_color or (dark and COLOR_GRAY or COLOR_WHITE),
|
||
pressed = opts.pressed_color or COLOR_SKY_BLUE,
|
||
border = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK),
|
||
text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
}
|
||
o.src = opts.src
|
||
o.src_pressed = opts.src_pressed
|
||
o.src_toggled = opts.src_toggled
|
||
o.toggle = opts.toggle or false
|
||
o.toggled = opts.toggled or false
|
||
o.on_toggle = opts.on_toggle
|
||
o.on_click = opts.on_click
|
||
o.pressed = false
|
||
o._imageCache = {}
|
||
return o
|
||
end
|
||
|
||
local function button_resolve_image(self)
|
||
if not self.src then return nil end
|
||
if self.toggle and self.toggled then
|
||
return self.src_toggled or self.src
|
||
elseif self.pressed then
|
||
return self.src_pressed or self.src
|
||
end
|
||
return self.src
|
||
end
|
||
|
||
function button:draw(ctx)
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
local img = button_resolve_image(self)
|
||
|
||
-- 优先使用图片缓存(lcd.image2raw + lcd.draw)
|
||
if img and lcd and lcd.image2raw and lcd.draw then
|
||
local zbuff = ui.image_cache.get_zbuff(img)
|
||
if zbuff then
|
||
-- 使用 zbuff 绘制,lcd.draw 会自动使用 zbuff 内部的 width 和 height
|
||
lcd.draw(ax, ay, nil, nil, zbuff)
|
||
return
|
||
end
|
||
end
|
||
|
||
-- 绘制按钮背景和文本
|
||
local bg = self.pressed and self.colors.pressed or self.colors.bg
|
||
ctx:fill_rect(ax, ay, self.w, self.h, bg)
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, self.colors.border)
|
||
local text_w = ctx:text_width(self.text or "", self.text_style)
|
||
local text_h = ctx:line_height(self.text_style)
|
||
local tx = ax + math.max(0, (self.w - text_w) // 2)
|
||
local ty = ay + math.max(0, (self.h - text_h) // 2)
|
||
ctx:draw_text(self.text or "", tx, ty, self.colors.text, self.text_style)
|
||
end
|
||
|
||
function button:set_text(new_text)
|
||
self.text = tostring(new_text or "")
|
||
self:invalidate()
|
||
end
|
||
|
||
function button:handle_event(evt, x, y)
|
||
if not self.enabled then return false end
|
||
local inside = self:contains_point(x or 0, y or 0)
|
||
if evt == "TOUCH_DOWN" and inside then
|
||
self.pressed = true
|
||
self._capture = true
|
||
self:invalidate()
|
||
return true
|
||
elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
|
||
local new_pressed = inside
|
||
if new_pressed ~= self.pressed then
|
||
self.pressed = new_pressed
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
local was_pressed = self.pressed
|
||
self.pressed = false
|
||
self._capture = false
|
||
if was_pressed and inside then
|
||
if self.toggle then
|
||
self.toggled = not self.toggled
|
||
if self.on_toggle then
|
||
pcall(self.on_toggle, self.toggled, self)
|
||
end
|
||
end
|
||
if self.on_click then
|
||
pcall(self.on_click, self)
|
||
end
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
self:invalidate()
|
||
return was_pressed
|
||
elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
|
||
if self._capture then
|
||
self.pressed = false
|
||
self._capture = false
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
ui.button = function(opts)
|
||
return button:new(opts)
|
||
end
|
||
|
||
-- 5.2 CheckBox
|
||
local check_box = setmetatable({}, { __index = BaseWidget })
|
||
check_box.__index = check_box
|
||
|
||
function check_box:new(opts)
|
||
opts = opts or {}
|
||
opts.box_size = opts.box_size or 20
|
||
local text_style = { size = opts.font_size or 12 }
|
||
local text_width = opts.text and font_measure(opts.text, text_style) or 0
|
||
opts.w = math.max(opts.w or 0, opts.box_size + (opts.text and (10 + text_width) or 0))
|
||
opts.h = math.max(opts.h or 0, opts.box_size, font_line_height(text_style))
|
||
local o = BaseWidget.new(self, opts)
|
||
o.text = opts.text or ""
|
||
o.text_style = text_style
|
||
o.box_size = opts.box_size
|
||
o.checked = opts.checked or false
|
||
o.on_change = opts.on_change
|
||
local dark = (current_theme == "dark")
|
||
o.colors = {
|
||
border = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK),
|
||
bg = opts.bg_color or (dark and COLOR_BLACK or COLOR_WHITE),
|
||
tick = opts.tick_color or COLOR_SKY_BLUE,
|
||
text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
}
|
||
return o
|
||
end
|
||
|
||
function check_box:set_checked(v)
|
||
local nv = not not v
|
||
if nv == self.checked then return end
|
||
self.checked = nv
|
||
self:invalidate()
|
||
if self.on_change then
|
||
pcall(self.on_change, self.checked, self)
|
||
end
|
||
end
|
||
|
||
function check_box:toggle()
|
||
self:set_checked(not self.checked)
|
||
end
|
||
|
||
function check_box:draw(ctx)
|
||
local ax, ay = self:get_absolute_position()
|
||
local size = self.box_size
|
||
ctx:stroke_rect(ax, ay, size, size, self.colors.border)
|
||
ctx:fill_rect(ax + 2, ay + 2, size - 4, size - 4, self.colors.bg)
|
||
if self.checked then
|
||
ctx:fill_rect(ax + 4, ay + 4, size - 8, size - 8, self.colors.tick)
|
||
end
|
||
if self.text and self.text ~= "" then
|
||
local lh = ctx:line_height(self.text_style)
|
||
local ty = ay + (self.h - lh) // 2
|
||
ctx:draw_text(self.text, ax + size + 10, ty, self.colors.text, self.text_style)
|
||
end
|
||
end
|
||
|
||
function check_box:handle_event(evt, x, y)
|
||
if evt == "SINGLE_TAP" then
|
||
if x and y and self:contains_point(x, y) then
|
||
self:toggle()
|
||
return true
|
||
end
|
||
elseif evt == "TOUCH_DOWN" then
|
||
return self:contains_point(x or 0, y or 0)
|
||
end
|
||
return false
|
||
end
|
||
|
||
ui.check_box = function(opts)
|
||
return check_box:new(opts)
|
||
end
|
||
|
||
-- 5.3 ComboBox
|
||
local dropdown_panel = setmetatable({}, { __index = BaseWidget })
|
||
dropdown_panel.__index = dropdown_panel
|
||
|
||
function dropdown_panel:new(owner)
|
||
local o = BaseWidget.new(self, { x = 0, y = 0, w = 0, h = 0 })
|
||
o.owner = owner
|
||
o.visible = false
|
||
o.item_height = (owner and owner.dropdown_item_height) or 32
|
||
o.padding = 4
|
||
o.scroll_offset = 0
|
||
o.max_scroll_offset = 0
|
||
o.hovered_index = -1
|
||
o.pressed_index = -1
|
||
o.scroll_threshold = 10
|
||
o.drag_start_y = 0
|
||
o.scroll_base_offset = 0
|
||
o.is_dragging = false
|
||
o._host_is_window = false
|
||
return o
|
||
end
|
||
|
||
function dropdown_panel:update_layout()
|
||
local owner = self.owner
|
||
if not owner then return end
|
||
self.w = owner.w
|
||
local itemCount = #(owner.options or {})
|
||
local maxVisible = math.max(1, math.min(itemCount, owner.max_visible_items or 5))
|
||
self.visible_count = maxVisible
|
||
self.h = maxVisible * self.item_height + self.padding * 2
|
||
self.max_scroll_offset = math.max(0, itemCount - maxVisible)
|
||
self.scroll_offset = clamp(self.scroll_offset, 0, self.max_scroll_offset)
|
||
if self._host_is_window and owner._parentWindow then
|
||
self.x = owner.x
|
||
self.y = owner.y + owner.h
|
||
else
|
||
local ax, ay = owner:get_absolute_position()
|
||
self.x = ax
|
||
self.y = ay + owner.h
|
||
end
|
||
end
|
||
|
||
function dropdown_panel:draw(ctx)
|
||
if not self.visible then return end
|
||
local owner = self.owner
|
||
if not owner then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
ctx:fill_rect(ax, ay, self.w, self.h, bg_color )
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
|
||
local startIdx = self.scroll_offset + 1
|
||
local endIdx = math.min(#owner.options, startIdx + (self.visible_count or owner.max_visible_items or 5) - 1)
|
||
local textStyle = owner.text_style
|
||
local lh = ctx:line_height(textStyle)
|
||
for i = startIdx, endIdx do
|
||
local itemY = ay + self.padding + (i - startIdx) * self.item_height
|
||
local isHovered = (i == self.hovered_index)
|
||
local isPressed = (i == self.pressed_index)
|
||
local isSelected = (i == owner.selected_index)
|
||
if isPressed then
|
||
ctx:fill_rect(ax + self.padding, itemY, self.w - self.padding * 2, self.item_height, COLOR_GRAY)
|
||
elseif isHovered then
|
||
ctx:fill_rect(ax + self.padding, itemY, self.w - self.padding * 2, self.item_height, COLOR_SKY_BLUE)
|
||
end
|
||
local labelColor = owner.colors.text
|
||
if isHovered then
|
||
labelColor = COLOR_WHITE
|
||
end
|
||
local text = owner:_normalize_option_text(owner.options[i])
|
||
local textX = ax + self.padding + 6
|
||
local textY = itemY + (self.item_height - lh) // 2
|
||
if isSelected then
|
||
ctx:draw_text("*", textX, textY, labelColor, textStyle)
|
||
textX = textX + ctx:text_width("*", textStyle) + 4
|
||
end
|
||
ctx:draw_text(text, textX, textY, labelColor, textStyle)
|
||
end
|
||
|
||
-- 绘制滚动指示器(如果需要滚动)
|
||
if self.max_scroll_offset > 0 then
|
||
local scrollBarWidth = 4
|
||
local scrollBarX = ax + self.w - scrollBarWidth - 2
|
||
local scrollBarHeight = self.h - 4
|
||
local scrollBarY = ay + 2
|
||
|
||
-- 滚动条背景
|
||
ctx:fill_rect(scrollBarX, scrollBarY, scrollBarWidth, scrollBarHeight, COLOR_WHITE)
|
||
|
||
-- 滚动条滑块(基于滚动偏移计算)
|
||
local maxVisibleItems = self.visible_count or owner.max_visible_items or 5
|
||
local totalItems = #owner.options
|
||
local thumbHeight = math.max(10, math.floor(scrollBarHeight * maxVisibleItems / totalItems))
|
||
|
||
-- 计算滑块位置:基于当前滚动偏移量
|
||
local thumbY
|
||
if self.max_scroll_offset > 0 then
|
||
thumbY = scrollBarY +
|
||
math.floor((self.scroll_offset / self.max_scroll_offset) * (scrollBarHeight - thumbHeight))
|
||
else
|
||
thumbY = scrollBarY
|
||
end
|
||
|
||
ctx:fill_rect(scrollBarX, thumbY, scrollBarWidth, thumbHeight, border_color)
|
||
end
|
||
end
|
||
|
||
function dropdown_panel:handle_event(evt, x, y)
|
||
if not (self.visible and self.owner and self.owner.enabled) then return false end
|
||
local inside = self:contains_point(x, y)
|
||
if not inside then
|
||
if evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
|
||
self:hide()
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
local owner = self.owner
|
||
local ax, ay = self:get_absolute_position()
|
||
if evt == "TOUCH_DOWN" then
|
||
self.drag_start_y = y
|
||
self.scroll_base_offset = self.scroll_offset
|
||
self.is_dragging = false
|
||
local relativeY = y - ay - self.padding
|
||
local pressedIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
|
||
if pressedIndex >= 1 and pressedIndex <= #owner.options then
|
||
self.pressed_index = pressedIndex
|
||
else
|
||
self.pressed_index = -1
|
||
end
|
||
self._capture = true
|
||
self:invalidate()
|
||
return true
|
||
elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
|
||
local dy = y - self.drag_start_y
|
||
if not self.is_dragging and math.abs(dy) >= self.scroll_threshold then
|
||
self.is_dragging = true
|
||
self.pressed_index = -1
|
||
self.hovered_index = -1
|
||
self:invalidate()
|
||
end
|
||
if self.is_dragging then
|
||
local newOffset = self.scroll_base_offset + math.floor(-dy / self.item_height)
|
||
newOffset = clamp(newOffset, 0, self.max_scroll_offset)
|
||
if newOffset ~= self.scroll_offset then
|
||
self.scroll_offset = newOffset
|
||
self:invalidate()
|
||
end
|
||
else
|
||
local relativeY = y - ay - self.padding
|
||
local hoverIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
|
||
if hoverIndex >= 1 and hoverIndex <= #owner.options then
|
||
if hoverIndex ~= self.hovered_index then
|
||
self.hovered_index = hoverIndex
|
||
self:invalidate()
|
||
end
|
||
else
|
||
if self.hovered_index ~= -1 then
|
||
self.hovered_index = -1
|
||
self:invalidate()
|
||
end
|
||
end
|
||
end
|
||
return true
|
||
elseif evt == "SINGLE_TAP" and self._capture then
|
||
self._capture = false
|
||
local relativeY = y - ay - self.padding
|
||
local clickedIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
|
||
self.pressed_index = -1
|
||
if self.hovered_index ~= -1 then
|
||
self.hovered_index = -1
|
||
self:invalidate()
|
||
end
|
||
if not self.is_dragging and clickedIndex >= 1 and clickedIndex <= #owner.options then
|
||
owner:set_selected(clickedIndex)
|
||
self:hide()
|
||
return true
|
||
end
|
||
self.is_dragging = false
|
||
return true
|
||
elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
|
||
self._capture = false
|
||
if self.pressed_index ~= -1 or self.hovered_index ~= -1 then
|
||
self.pressed_index = -1
|
||
self.hovered_index = -1
|
||
self:invalidate()
|
||
end
|
||
self.is_dragging = false
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
function dropdown_panel:show()
|
||
local owner = self.owner
|
||
if not owner then return end
|
||
if self.visible then return end
|
||
self._host_is_window = owner._parentWindow ~= nil
|
||
if self._host_is_window and owner._parentWindow then
|
||
owner._parentWindow:add(self)
|
||
else
|
||
runtime.add(self)
|
||
end
|
||
self:update_layout()
|
||
self.visible = true
|
||
self.hovered_index = owner.selected_index or -1
|
||
self.pressed_index = -1
|
||
self.is_dragging = false
|
||
self:invalidate()
|
||
end
|
||
|
||
function dropdown_panel:hide()
|
||
if not self.visible then return end
|
||
self.visible = false
|
||
self.hovered_index = -1
|
||
self.pressed_index = -1
|
||
self.is_dragging = false
|
||
self._capture = false
|
||
if self._host_is_window and self.parent then
|
||
self.parent:remove(self)
|
||
else
|
||
runtime.remove(self)
|
||
end
|
||
self._host_is_window = false
|
||
end
|
||
|
||
local combo_box = setmetatable({}, { __index = BaseWidget })
|
||
combo_box.__index = combo_box
|
||
|
||
function combo_box:new(opts)
|
||
opts = opts or {}
|
||
opts.w = opts.width or opts.w or 200
|
||
opts.h = opts.height or opts.h or 36
|
||
local o = BaseWidget.new(self, opts)
|
||
o.options = opts.options or {}
|
||
o.selected_index = clamp(opts.selected or 1, 1, math.max(1, #o.options))
|
||
o.placeholder = opts.placeholder or "请选择"
|
||
o.max_visible_items = opts.max_visible_items or 5
|
||
o.dropdown_item_height = opts.item_height or 32
|
||
o.text_style = { size = opts.text_size or opts.size or 12 }
|
||
local dark = (current_theme == "dark")
|
||
o.colors = {
|
||
bg = opts.bg_color or (dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG),
|
||
border = opts.border_color or (dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER),
|
||
text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK),
|
||
arrow = opts.arrow_color or COLOR_SKY_BLUE
|
||
}
|
||
o.on_select = opts.on_select
|
||
o.pressed = false
|
||
o._dropdown = dropdown_panel:new(o)
|
||
return o
|
||
end
|
||
|
||
function combo_box:_normalize_option_text(item)
|
||
if type(item) == "table" then
|
||
return tostring(item.text or item.value or "")
|
||
end
|
||
return tostring(item or "")
|
||
end
|
||
|
||
function combo_box:set_options(options)
|
||
self.options = options or {}
|
||
if self.selected_index > #self.options then
|
||
self.selected_index = #self.options > 0 and #self.options or 1
|
||
end
|
||
self:invalidate()
|
||
end
|
||
|
||
function combo_box:set_selected(index)
|
||
if index < 1 or index > #self.options then return end
|
||
self.selected_index = index
|
||
self:invalidate()
|
||
if self.on_select then
|
||
local ok, err = pcall(self.on_select, self:get_selected_value(), index, self:get_selected_text())
|
||
if not ok then
|
||
log.warn("ComboBox", "on_select error", err)
|
||
end
|
||
end
|
||
end
|
||
|
||
function combo_box:get_selected_index()
|
||
return self.selected_index or 0
|
||
end
|
||
|
||
function combo_box:get_selected_text()
|
||
if not self.options or #self.options == 0 then
|
||
return self.placeholder
|
||
end
|
||
return self:_normalize_option_text(self.options[self.selected_index])
|
||
end
|
||
|
||
function combo_box:get_selected_value()
|
||
if not self.options or #self.options == 0 then
|
||
return nil
|
||
end
|
||
local item = self.options[self.selected_index]
|
||
if type(item) == "table" then
|
||
return item.value
|
||
end
|
||
return item
|
||
end
|
||
|
||
function combo_box:draw(ctx)
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
local bg_color = self.pressed and COLOR_GRAY or self.colors.bg
|
||
ctx:fill_rect(ax, ay, self.w, self.h, bg_color )
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, self.colors.border)
|
||
local textPadding = 8
|
||
local arrowSpace = 20
|
||
local style = self.text_style
|
||
local maxTextWidth = math.max(0, self.w - textPadding * 2 - arrowSpace)
|
||
local text = self:get_selected_text()
|
||
text = fit_text_to_width(text, maxTextWidth, style, { ellipsis = true })
|
||
local textY = ay + (self.h - ctx:line_height(style)) // 2
|
||
ctx:draw_text(text, ax + textPadding, textY, self.colors.text, style)
|
||
if lcd and lcd.drawLine then
|
||
local arrowX = ax + self.w - arrowSpace // 2 - 4
|
||
local arrowY = ay + self.h // 2
|
||
if self._dropdown.visible then
|
||
lcd.drawLine(arrowX - 5, arrowY + 2, arrowX, arrowY - 2, self.colors.arrow)
|
||
lcd.drawLine(arrowX, arrowY - 2, arrowX + 5, arrowY + 2, self.colors.arrow)
|
||
else
|
||
lcd.drawLine(arrowX - 5, arrowY - 2, arrowX, arrowY + 2, self.colors.arrow)
|
||
lcd.drawLine(arrowX, arrowY + 2, arrowX + 5, arrowY - 2, self.colors.arrow)
|
||
end
|
||
end
|
||
end
|
||
|
||
function combo_box:handle_event(evt, x, y)
|
||
if not self.enabled then return false end
|
||
local inside = self:contains_point(x or 0, y or 0)
|
||
if evt == "TOUCH_DOWN" and inside then
|
||
self.pressed = true
|
||
return true
|
||
elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self.pressed then
|
||
self.pressed = inside
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
local was_pressed = self.pressed
|
||
self.pressed = false
|
||
if was_pressed and inside then
|
||
if self._dropdown.visible then
|
||
self._dropdown:hide()
|
||
else
|
||
self._dropdown:show()
|
||
end
|
||
return true
|
||
end
|
||
elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
|
||
self.pressed = false
|
||
end
|
||
return false
|
||
end
|
||
|
||
function combo_box:on_unmount()
|
||
if self._dropdown and self._dropdown.visible then
|
||
self._dropdown:hide()
|
||
end
|
||
end
|
||
|
||
ui.combo_box = function(opts)
|
||
return combo_box:new(opts)
|
||
end
|
||
|
||
-- 5.4 Label
|
||
local label = setmetatable({}, { __index = BaseWidget })
|
||
label.__index = label
|
||
|
||
function label:new(opts)
|
||
opts = opts or {}
|
||
local o = BaseWidget.new(self, opts)
|
||
o.text = tostring(opts.text or "")
|
||
o.text_style = { size = opts.size or opts.text_size }
|
||
local dark = (current_theme == "dark")
|
||
o.color = opts.color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
o.word_wrap = not not opts.word_wrap
|
||
o._autoWidth = not opts.w
|
||
o:_reflow()
|
||
return o
|
||
end
|
||
|
||
function label:_reflow()
|
||
local style = self.text_style
|
||
if self.word_wrap and not self._autoWidth and self.w and self.w > 0 then
|
||
self._lines = wrap_text_lines(self.text, self.w, style)
|
||
local lh = font_line_height(style)
|
||
self.h = math.max(self.h or 0, #self._lines * lh)
|
||
else
|
||
self._lines = nil
|
||
if self._autoWidth then
|
||
self.w = font_measure(self.text, style)
|
||
end
|
||
self.h = font_line_height(style)
|
||
end
|
||
end
|
||
|
||
function label:set_text(text)
|
||
self.text = tostring(text or "")
|
||
self:_reflow()
|
||
self:invalidate()
|
||
end
|
||
|
||
function label:set_size(sz)
|
||
self.text_style.size = tonumber(sz) or self.text_style.size
|
||
self:_reflow()
|
||
self:invalidate()
|
||
end
|
||
|
||
function label:draw(ctx)
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
local style = self.text_style
|
||
if self.word_wrap and self._lines then
|
||
local lh = ctx:line_height(style)
|
||
for i = 1, #self._lines do
|
||
ctx:draw_text(self._lines[i], ax, ay + (i - 1) * lh, self.color, style)
|
||
end
|
||
else
|
||
local text = self.text
|
||
if not self._autoWidth and self.w and self.w > 0 then
|
||
text = fit_text_to_width(text, self.w, style, { ellipsis = false })
|
||
end
|
||
ctx:draw_text(text, ax, ay, self.color, style)
|
||
end
|
||
end
|
||
|
||
function label:handle_event()
|
||
return false
|
||
end
|
||
|
||
ui.label = function(opts)
|
||
return label:new(opts)
|
||
end
|
||
|
||
-- 5.5 Input
|
||
local input = setmetatable({}, { __index = BaseWidget })
|
||
input.__index = input
|
||
|
||
function input:new(opts)
|
||
opts = opts or {}
|
||
opts.w = opts.w or opts.width or 200
|
||
opts.h = opts.h or opts.height or 36
|
||
local o = BaseWidget.new(self, opts)
|
||
|
||
-- 文本属性
|
||
o.text = opts.text or ""
|
||
o.placeholder = opts.placeholder or "请输入文本"
|
||
o.max_length = opts.max_length
|
||
|
||
-- 输入类型
|
||
o.input_type = opts.input_type or "text" -- text/number/password/email
|
||
|
||
-- 外观属性
|
||
local dark = (current_theme == "dark")
|
||
o.bg_color = opts.bg_color or (dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG)
|
||
o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
o.placeholder_color = opts.placeholder_color or COLOR_GRAY
|
||
o.border_color = opts.border_color or (dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER)
|
||
o.focused_border_color = opts.focused_border_color or COLOR_SKY_BLUE
|
||
o.text_style = { size = opts.text_size or opts.size or 12 }
|
||
|
||
-- 状态属性
|
||
o.focused = false
|
||
o.keyboard = nil -- 关联的键盘实例
|
||
|
||
-- 回调函数
|
||
o.on_text_changed = opts.on_text_changed
|
||
o.on_focus_changed = opts.on_focus_changed
|
||
o.on_confirm = opts.on_confirm
|
||
|
||
-- 内部状态
|
||
o._textOffset = 0 -- 文本滚动偏移(用于长文本显示)
|
||
o._pressed = false -- TOUCH_DOWN 时的视觉反馈
|
||
|
||
-- 键盘配置
|
||
o.keyboard_click_effect = opts.keyboard_click_effect ~= false
|
||
|
||
return o
|
||
end
|
||
|
||
-- 文本操作方法
|
||
function input:set_text(text)
|
||
local newText = tostring(text or "")
|
||
if self.max_length and #newText > self.max_length then
|
||
newText = string.sub(newText, 1, self.max_length)
|
||
end
|
||
|
||
if self.text ~= newText then
|
||
self.text = newText
|
||
self:invalidate()
|
||
if self.on_text_changed then
|
||
pcall(self.on_text_changed, self.text, self)
|
||
end
|
||
end
|
||
end
|
||
|
||
function input:get_text()
|
||
return self.text
|
||
end
|
||
|
||
-- 别名方法(兼容驼峰命名)
|
||
function input:getText()
|
||
return self:get_text()
|
||
end
|
||
|
||
function input:setText(text)
|
||
return self:set_text(text)
|
||
end
|
||
|
||
function input:set_placeholder(text)
|
||
self.placeholder = tostring(text or "")
|
||
self:invalidate()
|
||
end
|
||
|
||
function input:insert_text(text)
|
||
if not text or text == "" then return end
|
||
local newText = self.text .. text
|
||
self:set_text(newText)
|
||
end
|
||
|
||
function input:delete_text(start_pos, length)
|
||
if not self.text or self.text == "" then return end
|
||
|
||
length = length or 1
|
||
start_pos = math.max(1, math.min(start_pos, #self.text + 1))
|
||
local end_pos = math.min(start_pos + length - 1, #self.text)
|
||
|
||
if start_pos > end_pos then return end
|
||
|
||
local beforeText = string.sub(self.text, 1, start_pos - 1)
|
||
local afterText = string.sub(self.text, end_pos + 1)
|
||
self:set_text(beforeText .. afterText)
|
||
end
|
||
|
||
-- 焦点管理
|
||
function input:focus()
|
||
if self.focused then return end
|
||
|
||
-- 隐藏其他 Input 的键盘(确保同时只有一个 Input 有焦点)
|
||
for i = 1, #runtime.roots do
|
||
local root = runtime.roots[i]
|
||
if root and root._isKeyboard and root ~= self.keyboard then
|
||
root:hide()
|
||
end
|
||
end
|
||
|
||
self.focused = true
|
||
|
||
-- 创建键盘实例(每个 Input 拥有自己的 keyboard 实例)
|
||
if not self.keyboard then
|
||
-- 通过 ui.keyboard 访问(keyboard 组件在后面定义)
|
||
if ui.keyboard then
|
||
self.keyboard = ui.keyboard({
|
||
input = self,
|
||
enable_click_effect = self.keyboard_click_effect
|
||
})
|
||
self.keyboard._isKeyboard = true -- 标记为键盘组件
|
||
end
|
||
end
|
||
|
||
-- 显示键盘
|
||
if self.keyboard then
|
||
self.keyboard:show() -- 键盘位置在 show() 内部计算(屏幕中下对齐底边)
|
||
self.keyboard:set_input_type(self.input_type)
|
||
end
|
||
|
||
-- 触发焦点变化回调
|
||
if self.on_focus_changed then
|
||
pcall(self.on_focus_changed, true, self)
|
||
end
|
||
end
|
||
|
||
function input:blur()
|
||
if not self.focused then return end
|
||
|
||
self.focused = false
|
||
|
||
-- 隐藏键盘
|
||
if self.keyboard and self.keyboard:is_visible() then
|
||
self.keyboard:hide()
|
||
end
|
||
|
||
-- 触发焦点变化回调
|
||
if self.on_focus_changed then
|
||
pcall(self.on_focus_changed, false, self)
|
||
end
|
||
|
||
self:invalidate()
|
||
end
|
||
|
||
function input:is_focused()
|
||
return self.focused
|
||
end
|
||
|
||
-- 绘制方法
|
||
function input:draw(ctx)
|
||
if not self.visible then return end
|
||
|
||
local ax, ay = self:get_absolute_position()
|
||
|
||
-- 绘制背景
|
||
ctx:fill_rect(ax, ay, self.w, self.h, self.bg_color)
|
||
|
||
-- 绘制边框
|
||
local border_color = (self.focused or self._pressed) and self.focused_border_color or self.border_color
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
|
||
|
||
-- 文本绘制区域
|
||
local textPadding = 8
|
||
local textX = ax + textPadding
|
||
local textY = ay + (self.h - ctx:line_height(self.text_style)) // 2
|
||
local maxTextWidth = self.w - textPadding * 2
|
||
|
||
-- 确定要显示的文本
|
||
local displayText = self.text
|
||
local text_color = self.text_color
|
||
|
||
if not displayText or displayText == "" then
|
||
displayText = self.placeholder
|
||
text_color = self.placeholder_color
|
||
elseif self.input_type == "password" then
|
||
displayText = string.rep("*", #self.text)
|
||
end
|
||
|
||
-- 处理长文本滚动显示
|
||
local textWidth = ctx:text_width(displayText, self.text_style)
|
||
if textWidth > maxTextWidth then
|
||
-- 使用 fit_text_to_width 工具函数截断文本
|
||
displayText = fit_text_to_width(displayText, maxTextWidth, self.text_style, { ellipsis = false })
|
||
end
|
||
|
||
-- 绘制文本
|
||
if displayText and displayText ~= "" then
|
||
ctx:draw_text(displayText, textX, textY, text_color, self.text_style)
|
||
end
|
||
end
|
||
|
||
-- 事件处理
|
||
function input:handle_event(evt, x, y)
|
||
if not self.enabled then return false end
|
||
|
||
local inside = self:contains_point(x or 0, y or 0)
|
||
|
||
if evt == "TOUCH_DOWN" then
|
||
if inside then
|
||
self._pressed = true
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
elseif evt == "SINGLE_TAP" then
|
||
if inside then
|
||
self._pressed = false
|
||
self:focus()
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
ui.input = function(opts)
|
||
return input:new(opts)
|
||
end
|
||
|
||
-- 5.6 Keyboard
|
||
local keyboard = setmetatable({}, { __index = BaseWidget })
|
||
keyboard.__index = keyboard
|
||
|
||
function keyboard:new(opts)
|
||
opts = opts or {}
|
||
local o = BaseWidget.new(self, {
|
||
x = 0,
|
||
y = 0,
|
||
w = opts.width or 300,
|
||
h = opts.height or 450,
|
||
visible = false
|
||
})
|
||
|
||
-- 关联的 Input 组件
|
||
o.input = opts.input
|
||
|
||
-- 是否启用点击变色效果
|
||
o.enable_click_effect = opts.enable_click_effect ~= false
|
||
|
||
-- 键盘布局参数
|
||
o.keySize = 90
|
||
o.keyGap = 0
|
||
|
||
-- 输入模式
|
||
o.isNumberMode = false
|
||
o.isPinyin9KeyMode = false
|
||
|
||
-- 字母键盘按键映射
|
||
o.letterMappings = {
|
||
{ text = "ABC", chars = { "a", "b", "c", "A", "B", "C" }, type = "letters", keyId = 1 },
|
||
{ text = "DEF", chars = { "d", "e", "f", "D", "E", "F" }, type = "letters", keyId = 2 },
|
||
{ text = "GHI", chars = { "g", "h", "i", "G", "H", "I" }, type = "letters", keyId = 3 },
|
||
{ text = "JKL", chars = { "j", "k", "l", "J", "K", "L" }, type = "letters", keyId = 4 },
|
||
{ text = "MNO", chars = { "m", "n", "o", "M", "N", "O" }, type = "letters", keyId = 5 },
|
||
{ text = "PQRS", chars = { "p", "q", "r", "s", "P", "Q", "R", "S" }, type = "letters", keyId = 6 },
|
||
{ text = "TUV", chars = { "t", "u", "v", "T", "U", "V" }, type = "letters", keyId = 7 },
|
||
{ text = "WXYZ", chars = { "w", "x", "y", "z", "W", "X", "Y", "Z" }, type = "letters", keyId = 8 },
|
||
{ text = "空格", chars = { " " }, type = "space" },
|
||
{ text = "delete", chars = {}, type = "delete" },
|
||
{ text = "NUM", chars = {}, type = "num" },
|
||
{ text = "中/EN", chars = {}, type = "lang" }
|
||
}
|
||
|
||
-- 数字键盘按键映射
|
||
o.numberMappings = {
|
||
{ text = "1", chars = { "1" }, type = "number" },
|
||
{ text = "2", chars = { "2" }, type = "number" },
|
||
{ text = "3", chars = { "3" }, type = "number" },
|
||
{ text = "4", chars = { "4" }, type = "number" },
|
||
{ text = "5", chars = { "5" }, type = "number" },
|
||
{ text = "6", chars = { "6" }, type = "number" },
|
||
{ text = "7", chars = { "7" }, type = "number" },
|
||
{ text = "8", chars = { "8" }, type = "number" },
|
||
{ text = "9", chars = { "9" }, type = "number" },
|
||
{ text = "delete", chars = {}, type = "delete" },
|
||
{ text = "0", chars = { "0" }, type = "number" },
|
||
{ text = "EN", chars = {}, type = "letter" }
|
||
}
|
||
|
||
-- 根据模式设置按键映射
|
||
o.keyMappings = o.letterMappings
|
||
|
||
-- 按键布局
|
||
o.keyLayout = {}
|
||
o:build_key_layout()
|
||
|
||
-- 候选字符相关状态
|
||
o.selectedKey = nil
|
||
o.currentCandidates = {}
|
||
o._pressedCandidateIndex = nil
|
||
|
||
-- 9键拼音输入模式相关属性
|
||
o.keySequence = {} -- 当前按键序列(存储按键ID:1-8)
|
||
o.syllableCandidates = {} -- 音节候选列表
|
||
o.selectedSyllableIndex = 1 -- 当前选中的音节索引
|
||
o.currentSyllable = "" -- 当前选中的音节(已确认)
|
||
o.pinyinCandidates = {} -- 候选字列表(UTF-8字符串数组)
|
||
o.selectedCandidateIndex = 1 -- 当前选中的候选字索引
|
||
o.syllablePageIndex = 1 -- 音节列表当前页索引
|
||
o.candidatePageIndex = 1 -- 候选字列表当前页索引(每页8个候选字)
|
||
o.pinyinModule = nil -- pinyin模块缓存
|
||
|
||
-- 候选区显示状态
|
||
o.displayStartIndex = 1 -- 预览框显示的起始字符位置
|
||
o._pressedSyllableIndex = nil -- 当前按下的音节索引
|
||
o._backButtonPressed = false -- 返回按钮按下状态
|
||
|
||
return o
|
||
end
|
||
|
||
function keyboard:build_key_layout()
|
||
local start_x = self.x + 30 -- 左侧预留30px(用于未来音节选择区)
|
||
local start_y = self.y + 95 -- 顶部控制栏50px + 候选区50px
|
||
local keySize = self.keySize
|
||
local keyGap = self.keyGap
|
||
|
||
-- 构建3×4按键布局
|
||
self.keyLayout = {}
|
||
local keyIndex = 1
|
||
|
||
for row = 0, 3 do
|
||
for col = 0, 2 do
|
||
if keyIndex <= #self.keyMappings then
|
||
local key = {
|
||
x = start_x + col * (keySize + keyGap),
|
||
y = start_y + row * (keySize + keyGap),
|
||
w = keySize,
|
||
h = keySize,
|
||
text = self.keyMappings[keyIndex].text,
|
||
chars = self.keyMappings[keyIndex].chars,
|
||
type = self.keyMappings[keyIndex].type,
|
||
keyId = self.keyMappings[keyIndex].keyId, -- 用于拼音输入
|
||
pressed = false
|
||
}
|
||
table.insert(self.keyLayout, key)
|
||
keyIndex = keyIndex + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
function keyboard:show()
|
||
-- 计算键盘位置(屏幕中下对齐底边)
|
||
local sw = render_state.viewport_w
|
||
local sh = render_state.viewport_h
|
||
self.x = (sw - self.w) // 2 -- 水平居中
|
||
self.y = sh - self.h -- 底部对齐
|
||
|
||
-- 重新构建按键布局
|
||
self:build_key_layout()
|
||
|
||
self.visible = true
|
||
self.enabled = true
|
||
|
||
-- 重置状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self.displayStartIndex = 1
|
||
|
||
-- 重置拼音输入状态
|
||
self.keySequence = {}
|
||
self.syllableCandidates = {}
|
||
self.selectedSyllableIndex = 1
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.syllablePageIndex = 1
|
||
self.candidatePageIndex = 1
|
||
self._pressedSyllableIndex = nil
|
||
self._pressedCandidateIndex = nil
|
||
|
||
-- 添加到运行时根组件列表(顶层显示)
|
||
runtime.add(self)
|
||
end
|
||
|
||
function keyboard:hide()
|
||
self.visible = false
|
||
self.enabled = false
|
||
|
||
-- 从运行时根组件列表移除
|
||
runtime.remove(self)
|
||
|
||
-- 通知 Input 组件失去焦点
|
||
if self.input and self.input.focused then
|
||
self.input:blur()
|
||
end
|
||
end
|
||
|
||
function keyboard:is_visible()
|
||
return self.visible
|
||
end
|
||
|
||
function keyboard:set_input_type(inputType)
|
||
-- 根据输入类型切换键盘模式
|
||
if inputType == "number" then
|
||
self:switch_to_number_mode()
|
||
else
|
||
self:switch_to_letter_mode()
|
||
end
|
||
end
|
||
|
||
function keyboard:switch_to_number_mode()
|
||
if not self.isNumberMode then
|
||
self.isNumberMode = true
|
||
self.isPinyin9KeyMode = false -- 切换到数字模式时关闭拼音模式
|
||
self.keyMappings = self.numberMappings
|
||
self:build_key_layout()
|
||
-- 清除候选字符状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self._pressedCandidateIndex = nil
|
||
-- 清除拼音输入状态
|
||
self.keySequence = {}
|
||
self.syllableCandidates = {}
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self:invalidate()
|
||
end
|
||
end
|
||
|
||
function keyboard:switch_to_letter_mode()
|
||
if self.isNumberMode then
|
||
self.isNumberMode = false
|
||
-- 切换到字母模式时不清除拼音模式(保持当前状态)
|
||
self.keyMappings = self.letterMappings
|
||
self:build_key_layout()
|
||
-- 清除候选字符状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self._pressedCandidateIndex = nil
|
||
self:invalidate()
|
||
end
|
||
end
|
||
|
||
-- 切换到9键拼音模式
|
||
function keyboard:switch_to_pinyin_9key_mode()
|
||
self.isPinyin9KeyMode = true
|
||
self.keySequence = {}
|
||
self.syllableCandidates = {}
|
||
self.selectedSyllableIndex = 1
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.syllablePageIndex = 1
|
||
self.candidatePageIndex = 1
|
||
self._pressedSyllableIndex = nil
|
||
|
||
-- 加载pinyin模块(模组自带的核心库,不需要require)
|
||
if not self.pinyinModule then
|
||
self.pinyinModule = pinyin
|
||
if not self.pinyinModule then
|
||
log.warn("Keyboard", "pinyin模块不可用")
|
||
self.isPinyin9KeyMode = false
|
||
return false
|
||
end
|
||
end
|
||
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
|
||
-- 处理9键输入
|
||
function keyboard:on_pinyin_9key_input(keyId)
|
||
-- keyId: 1-8 对应 ABC-WXYZ
|
||
if not self.isPinyin9KeyMode then
|
||
return
|
||
end
|
||
|
||
-- 限制按键序列最大长度为5(中文最多5个音节拼音)
|
||
if #self.keySequence >= 5 then
|
||
log.warn("Keyboard", "按键序列已达最大长度5")
|
||
return
|
||
end
|
||
|
||
-- 如果已经有选中的音节,先清除候选字状态
|
||
if self.currentSyllable ~= "" then
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.candidatePageIndex = 1
|
||
end
|
||
|
||
-- 追加到按键序列
|
||
table.insert(self.keySequence, keyId)
|
||
|
||
-- 查询可能的音节(输入第一个按键后即开始显示)
|
||
if self.pinyinModule and self.pinyinModule.querySyllables then
|
||
local syllables = self.pinyinModule.querySyllables(self.keySequence)
|
||
self.syllableCandidates = syllables or {}
|
||
self.selectedSyllableIndex = 1
|
||
self.syllablePageIndex = 1
|
||
log.info("Keyboard", "按键序列:", table.concat(self.keySequence, ","),
|
||
"音节数:", #self.syllableCandidates)
|
||
else
|
||
self.syllableCandidates = {}
|
||
end
|
||
|
||
self:invalidate()
|
||
end
|
||
|
||
-- 选择音节
|
||
function keyboard:select_syllable(syllable)
|
||
if not self.isPinyin9KeyMode or not syllable then
|
||
return false
|
||
end
|
||
|
||
-- 确认选中的音节
|
||
self.currentSyllable = syllable
|
||
|
||
-- 查询该音节对应的候选字(使用queryUtf8直接返回UTF-8字符串数组)
|
||
if self.pinyinModule and self.pinyinModule.queryUtf8 then
|
||
local chars = self.pinyinModule.queryUtf8(syllable)
|
||
self.pinyinCandidates = chars or {}
|
||
self.selectedCandidateIndex = 1
|
||
self.candidatePageIndex = 1
|
||
log.info("Keyboard", "选中音节:", syllable, "候选字数:", #self.pinyinCandidates)
|
||
else
|
||
self.pinyinCandidates = {}
|
||
end
|
||
|
||
-- 清空按键序列(准备输入下一个字)
|
||
self.keySequence = {}
|
||
self.syllableCandidates = {}
|
||
self.selectedSyllableIndex = 1
|
||
self.syllablePageIndex = 1
|
||
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
|
||
-- 选择候选字
|
||
function keyboard:select_candidate(index)
|
||
if not self.isPinyin9KeyMode then
|
||
return false
|
||
end
|
||
|
||
-- index 是相对索引(1-8),需要计算实际索引(考虑分页)
|
||
local actualIndex = (self.candidatePageIndex - 1) * 8 + index
|
||
|
||
if actualIndex >= 1 and actualIndex <= #self.pinyinCandidates then
|
||
local char = self.pinyinCandidates[actualIndex] -- 直接使用UTF-8字符串
|
||
|
||
-- 插入到输入框
|
||
if self.input then
|
||
self.input:insert_text(char)
|
||
end
|
||
|
||
-- 重置状态,准备输入下一个字
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.candidatePageIndex = 1
|
||
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- 删除键处理(9键模式)
|
||
function keyboard:on_pinyin_9key_delete()
|
||
if not self.isPinyin9KeyMode then
|
||
return
|
||
end
|
||
|
||
-- 如果正在选择音节(有按键序列未确认)
|
||
if #self.keySequence > 0 then
|
||
-- 删除最后一个按键,并根据剩余按键序列重新查询音节
|
||
table.remove(self.keySequence, #self.keySequence)
|
||
if #self.keySequence > 0 then
|
||
if self.pinyinModule and self.pinyinModule.querySyllables then
|
||
local syllables = self.pinyinModule.querySyllables(self.keySequence)
|
||
self.syllableCandidates = syllables or {}
|
||
self.selectedSyllableIndex = 1
|
||
self.syllablePageIndex = 1
|
||
else
|
||
self.syllableCandidates = {}
|
||
self.selectedSyllableIndex = 1
|
||
self.syllablePageIndex = 1
|
||
end
|
||
else
|
||
-- 没有按键了,清空音节候选
|
||
self.syllableCandidates = {}
|
||
self.selectedSyllableIndex = 1
|
||
self.syllablePageIndex = 1
|
||
end
|
||
log.info("Keyboard", "删除一位按键,当前序列:", table.concat(self.keySequence, ","))
|
||
self:invalidate()
|
||
return
|
||
end
|
||
|
||
-- 如果已选中音节并在选择候选汉字阶段,删除应清空音节并回到按键输入
|
||
if self.currentSyllable ~= "" then
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.candidatePageIndex = 1
|
||
log.info("Keyboard", "清空已选音节,返回按键输入阶段")
|
||
self:invalidate()
|
||
return
|
||
else
|
||
-- 没有选择音节,删除输入框中的最后一个字符
|
||
if self.input then
|
||
local currentText = self.input:get_text()
|
||
if currentText and #currentText > 0 then
|
||
-- 按 UTF-8 字符删除最后一个字符,避免残留半个字节导致 "<22>"
|
||
local lastStart = 1
|
||
local i = 1
|
||
while i <= #currentText do
|
||
local _, charLen = get_utf8_char(currentText, i)
|
||
lastStart = i
|
||
i = i + math.max(charLen, 1)
|
||
end
|
||
local deleteLen = #currentText - lastStart + 1
|
||
self.input:delete_text(lastStart, deleteLen)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 绘制方法
|
||
function keyboard:draw(ctx)
|
||
if not self.visible then return end
|
||
|
||
local ax, ay = self:get_absolute_position()
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
|
||
-- 绘制键盘背景
|
||
ctx:fill_rect(ax, ay, self.w, self.h, bg_color )
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
|
||
|
||
-- 绘制顶部控制栏(返回按钮和预览区)
|
||
self:draw_top_bar(ctx, ax, ay)
|
||
|
||
-- 绘制候选区(音节或候选字)
|
||
if self.isPinyin9KeyMode then
|
||
-- 显示候选字选择区(始终显示)
|
||
self:draw_pinyin_candidates(ctx, ax, ay)
|
||
else
|
||
-- 显示预览区(英文模式)
|
||
self:draw_preview_area(ctx, ax, ay)
|
||
-- 绘制候选字符区(英文模式)
|
||
self:draw_candidate_area(ctx, ax, ay)
|
||
end
|
||
|
||
-- 绘制左侧音节选择区(9键拼音模式)
|
||
if self.isPinyin9KeyMode then
|
||
self:draw_left_syllable_panel(ctx, ax, ay)
|
||
end
|
||
|
||
-- 绘制按键
|
||
for i = 1, #self.keyLayout do
|
||
self:draw_key(ctx, self.keyLayout[i])
|
||
end
|
||
end
|
||
|
||
function keyboard:draw_top_bar(ctx, ax, ay)
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
local button_bg_color = bg_color
|
||
|
||
-- 返回按钮
|
||
local backBtnX = ax + 10
|
||
local backBtnY = ay + 5
|
||
local backBtnW = 60
|
||
local backBtnH = 35
|
||
-- 检查返回按钮是否被按下
|
||
local backBtnbg_color = (self.enable_click_effect and self._backButtonPressed) and COLOR_GRAY or button_bg_color
|
||
ctx:fill_rect(backBtnX, backBtnY, backBtnW, backBtnH, backBtnbg_color )
|
||
ctx:stroke_rect(backBtnX, backBtnY, backBtnW, backBtnH, border_color)
|
||
local back_text = "返回"
|
||
local back_style = { size = 12 }
|
||
ctx:draw_text_in_rect_centered(backBtnX, backBtnY, backBtnW, backBtnH, back_text, {
|
||
color = text_color,
|
||
style = back_style
|
||
})
|
||
|
||
-- 输入预览区
|
||
if self.input then
|
||
local previewX = backBtnX + backBtnW + 10 -- 预览框起始位置
|
||
local previewW = self.w - 90 -- 预览框宽度:300px - 90px = 210px
|
||
local previewText = self.input:get_text()
|
||
|
||
-- 处理预览文本显示(上方仅预览已输入的汉字/文本,不再显示音节)
|
||
local displayText = previewText
|
||
if displayText == "" then
|
||
displayText = "输入预览"
|
||
else
|
||
local style = { size = 12 }
|
||
local textWidth = ctx:text_width(displayText, style)
|
||
local maxTextWidth = previewW - 20 -- 左右各留10像素
|
||
if textWidth > maxTextWidth then
|
||
displayText = fit_text_to_width(displayText, maxTextWidth, style, { ellipsis = false })
|
||
end
|
||
end
|
||
|
||
-- 输入预览区:有边框,高35px
|
||
local previewAreaY = backBtnY
|
||
local previewAreaH = backBtnH
|
||
ctx:fill_rect(previewX, previewAreaY, previewW, previewAreaH, button_bg_color )
|
||
ctx:stroke_rect(previewX, previewAreaY, previewW, previewAreaH, border_color)
|
||
-- 左对齐绘制,左边距10px
|
||
local previewtext_color = (previewText == "") and COLOR_GRAY or text_color
|
||
ctx:draw_text(displayText, previewX + 10, previewAreaY + (previewAreaH - ctx:line_height({ size = 12 })) // 2,
|
||
previewtext_color, { size = 12 })
|
||
|
||
-- 新增:音节预览区(位于预览区下方5px,高20px,无边框)
|
||
if self.isPinyin9KeyMode then
|
||
local syllablePreviewY = previewAreaY + previewAreaH
|
||
local syllableText = ""
|
||
if #self.keySequence > 0 then
|
||
-- 显示按键序列(如:abc+mno)
|
||
local keyToLetters = {
|
||
[1] = "abc",
|
||
[2] = "def",
|
||
[3] = "ghi",
|
||
[4] = "jkl",
|
||
[5] = "mno",
|
||
[6] = "pqrs",
|
||
[7] = "tuv",
|
||
[8] = "wxyz"
|
||
}
|
||
local keyPreview = {}
|
||
for _, keyId in ipairs(self.keySequence) do
|
||
table.insert(keyPreview, keyToLetters[keyId] or "")
|
||
end
|
||
syllableText = table.concat(keyPreview, "+")
|
||
elseif self.currentSyllable ~= "" then
|
||
-- 显示已选中的音节
|
||
syllableText = self.currentSyllable
|
||
end
|
||
if syllableText ~= "" then
|
||
ctx:draw_text(syllableText, previewX + 10,
|
||
syllablePreviewY + (20 - ctx:line_height({ size = 12 })) // 2,
|
||
text_color, { size = 12 })
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
function keyboard:draw_preview_area(ctx, ax, ay)
|
||
if not self.input then return end
|
||
|
||
local previewY = ay + 5 -- 和返回按键平行
|
||
local previewHeight = 35 -- 和返回按键高度一致
|
||
local previewX = ax + 80 -- 预览框起始位置(返回键后)
|
||
local previewW = self.w - 90 -- 预览框宽度
|
||
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
|
||
-- 绘制预览区背景
|
||
ctx:fill_rect(previewX, previewY, previewW, previewHeight, bg_color )
|
||
ctx:stroke_rect(previewX, previewY, previewW, previewHeight, border_color)
|
||
|
||
-- 绘制预览文本
|
||
local previewText = self.input:get_text() or ""
|
||
if previewText == "" then
|
||
previewText = "输入预览"
|
||
text_color = COLOR_GRAY
|
||
end
|
||
|
||
-- 处理长文本
|
||
local previewStyle = { size = 12 }
|
||
local textWidth = ctx:text_width(previewText, previewStyle)
|
||
local maxTextWidth = previewW - 20 -- 左右各留10像素
|
||
if textWidth > maxTextWidth then
|
||
previewText = fit_text_to_width(previewText, maxTextWidth, previewStyle, { ellipsis = false })
|
||
end
|
||
|
||
local textHeight = ctx:line_height(previewStyle)
|
||
local textX = previewX + 10
|
||
local textY = previewY + (previewHeight - textHeight) // 2
|
||
ctx:draw_text(previewText, textX, textY, text_color, previewStyle)
|
||
end
|
||
|
||
function keyboard:draw_key(ctx, key)
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
local presse_dbg_color = COLOR_GRAY
|
||
|
||
local btnbg_color = (self.enable_click_effect and key.pressed) and presse_dbg_color or bg_color
|
||
|
||
ctx:fill_rect(key.x, key.y, key.w, key.h, btnbg_color )
|
||
ctx:stroke_rect(key.x, key.y, key.w, key.h, border_color)
|
||
|
||
-- -- 绘制按键文本
|
||
local displayText = key.text
|
||
|
||
local textStyle = { size = 12 }
|
||
local textWidth = ctx:text_width(displayText, textStyle)
|
||
local textHeight = ctx:line_height(textStyle)
|
||
local textX = key.x + (key.w - textWidth) // 2
|
||
local textY = key.y + (key.h - textHeight) // 2
|
||
ctx:draw_text(displayText, textX, textY, text_color, textStyle)
|
||
end
|
||
|
||
-- 事件处理
|
||
function keyboard:handle_event(evt, x, y)
|
||
if not self.visible or not self.enabled then
|
||
return false
|
||
end
|
||
|
||
local inside = self:contains_point(x or 0, y or 0)
|
||
|
||
-- 检查是否点击了返回按钮
|
||
local backBtnX = self.x + 10
|
||
local backBtnY = self.y + 5
|
||
local backBtnW = 60
|
||
local backBtnH = 40
|
||
if x >= backBtnX and x < backBtnX + backBtnW and
|
||
y >= backBtnY and y < backBtnY + backBtnH then
|
||
if evt == "TOUCH_DOWN" then
|
||
self._backButtonPressed = true
|
||
self:invalidate()
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
self._backButtonPressed = false
|
||
self:hide()
|
||
return true
|
||
elseif evt == "MOVE_X" or evt == "MOVE_Y" then
|
||
self._backButtonPressed = false
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 处理候选字左右翻页按键(9键拼音模式)
|
||
if self.isPinyin9KeyMode and #self.pinyinCandidates > 0 then
|
||
if self:handle_candidate_arrow_touch(evt, x, y) then
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 处理候选字选择区触摸(9键拼音模式)
|
||
if self.isPinyin9KeyMode and #self.pinyinCandidates > 0 then
|
||
if self:handle_candidate_panel_touch(evt, x, y) then
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 处理音节选择区触摸(9键拼音模式)
|
||
if self.isPinyin9KeyMode and #self.syllableCandidates > 0 and self.currentSyllable == "" then
|
||
if self:handle_syllable_panel_touch(evt, x, y) then
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 处理候选字符选择(英文模式)
|
||
if not self.isPinyin9KeyMode and #self.currentCandidates > 0 then
|
||
local candidateY = self.y + 50
|
||
local candidateHeight = 50
|
||
local candidateBtnSize = 30
|
||
|
||
for i = 1, 10 do
|
||
local btnX = self.x + (i - 1) * candidateBtnSize
|
||
local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
|
||
if i <= #self.currentCandidates and
|
||
x >= btnX and x < btnX + candidateBtnSize and
|
||
y >= btnY and y < btnY + candidateBtnSize then
|
||
if evt == "TOUCH_DOWN" then
|
||
self._pressedCandidateIndex = i
|
||
self:invalidate()
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
local char = self.currentCandidates[i]
|
||
self:on_candidate_selected(char)
|
||
return true
|
||
elseif evt == "MOVE_X" or evt == "MOVE_Y" then
|
||
if self._pressedCandidateIndex ~= i then
|
||
self._pressedCandidateIndex = nil
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 处理按键点击
|
||
if evt == "TOUCH_DOWN" and inside then
|
||
-- 检查是否点击了按键
|
||
for i = 1, #self.keyLayout do
|
||
local key = self.keyLayout[i]
|
||
if x >= key.x and x < key.x + key.w and
|
||
y >= key.y and y < key.y + key.h then
|
||
key.pressed = true
|
||
self._capture = true
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
end
|
||
return true
|
||
elseif evt == "SINGLE_TAP" and self._capture then
|
||
-- 处理按键释放
|
||
for i = 1, #self.keyLayout do
|
||
local key = self.keyLayout[i]
|
||
if key.pressed then
|
||
key.pressed = false
|
||
self:on_key_pressed(key)
|
||
self:invalidate()
|
||
break
|
||
end
|
||
end
|
||
|
||
self._capture = false
|
||
return true
|
||
elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
|
||
-- 更新按键按下状态
|
||
for i = 1, #self.keyLayout do
|
||
local key = self.keyLayout[i]
|
||
local wasPressed = key.pressed
|
||
key.pressed = (x >= key.x and x < key.x + key.w and
|
||
y >= key.y and y < key.y + key.h)
|
||
if wasPressed ~= key.pressed then
|
||
self:invalidate()
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- 处理按键按下
|
||
function keyboard:on_key_pressed(key)
|
||
if not key then return end
|
||
|
||
-- 删除键处理
|
||
if key.type == "delete" then
|
||
-- 9键拼音模式下的删除键处理
|
||
if self.isPinyin9KeyMode then
|
||
self:on_pinyin_9key_delete()
|
||
return
|
||
end
|
||
|
||
-- 清除候选字符状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self._pressedCandidateIndex = nil
|
||
|
||
if self.input then
|
||
local currentText = self.input:get_text()
|
||
if currentText and #currentText > 0 then
|
||
-- 按 UTF-8 字符删除最后一个字符
|
||
local lastStart = 1
|
||
local i = 1
|
||
while i <= #currentText do
|
||
local _, charLen = get_utf8_char(currentText, i)
|
||
lastStart = i
|
||
i = i + math.max(charLen, 1)
|
||
end
|
||
local deleteLen = #currentText - lastStart + 1
|
||
self.input:delete_text(lastStart, deleteLen)
|
||
end
|
||
end
|
||
self:invalidate()
|
||
return
|
||
end
|
||
|
||
-- 数字/字母模式切换
|
||
if key.type == "num" then
|
||
self:switch_to_number_mode()
|
||
return
|
||
elseif key.type == "letter" then
|
||
self:switch_to_letter_mode()
|
||
return
|
||
end
|
||
|
||
-- 9键拼音模式下的字母键处理
|
||
if self.isPinyin9KeyMode and key.type == "letters" and key.keyId then
|
||
self:on_pinyin_9key_input(key.keyId)
|
||
return
|
||
end
|
||
|
||
|
||
-- 9键拼音模式下的空格键处理
|
||
if self.isPinyin9KeyMode and key.type == "space" then
|
||
if self.input then
|
||
self.input:insert_text(" ")
|
||
end
|
||
return
|
||
end
|
||
|
||
-- 语言切换键(中/EN)
|
||
if key.type == "lang" then
|
||
if self.isPinyin9KeyMode then
|
||
-- 关闭拼音模式,切换到英文模式
|
||
self.isPinyin9KeyMode = false
|
||
-- 清除拼音输入状态
|
||
self.keySequence = {}
|
||
self.syllableCandidates = {}
|
||
self.currentSyllable = ""
|
||
self.pinyinCandidates = {}
|
||
self.selectedCandidateIndex = 1
|
||
self.syllablePageIndex = 1
|
||
self.candidatePageIndex = 1
|
||
self._pressedSyllableIndex = nil
|
||
-- 如果当前是数字模式,需要先切换到字母模式
|
||
if self.isNumberMode then
|
||
self:switch_to_letter_mode()
|
||
end
|
||
else
|
||
-- 切换到拼音模式
|
||
-- 如果当前是数字模式,需要先切换到字母模式
|
||
if self.isNumberMode then
|
||
self:switch_to_letter_mode()
|
||
end
|
||
-- 尝试切换到拼音模式
|
||
local success = self:switch_to_pinyin_9key_mode()
|
||
if not success then
|
||
log.warn("Keyboard", "切换到拼音模式失败,pinyin模块不可用")
|
||
end
|
||
end
|
||
self:invalidate()
|
||
return
|
||
end
|
||
|
||
-- 数字/字母模式切换
|
||
if key.type == "num" then
|
||
self:switch_to_number_mode()
|
||
return
|
||
elseif key.type == "letter" then
|
||
self:switch_to_letter_mode()
|
||
return
|
||
end
|
||
|
||
-- 普通按键处理(字母/数字)
|
||
if key.chars and #key.chars > 0 then
|
||
-- 清除之前的候选字符状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self._pressedCandidateIndex = nil
|
||
|
||
-- 处理数字直接输入
|
||
if key.type == "number" then
|
||
local char = key.chars[1]
|
||
if self.input then
|
||
self.input:insert_text(char)
|
||
end
|
||
-- 处理空格键
|
||
elseif key.type == "space" then
|
||
if self.input then
|
||
self.input:insert_text(" ")
|
||
end
|
||
else
|
||
-- 字母键:显示候选字符,让用户选择
|
||
self.selectedKey = key
|
||
self.currentCandidates = key.chars
|
||
self:invalidate()
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 处理候选字符选择
|
||
function keyboard:on_candidate_selected(char)
|
||
if char and char ~= "" then
|
||
if self.input then
|
||
self.input:insert_text(char)
|
||
end
|
||
end
|
||
-- 清除候选状态和按键状态
|
||
self.selectedKey = nil
|
||
self.currentCandidates = {}
|
||
self._pressedCandidateIndex = nil
|
||
|
||
-- 清除所有按键状态
|
||
for i = 1, #self.keyLayout do
|
||
self.keyLayout[i].pressed = false
|
||
end
|
||
|
||
self:invalidate()
|
||
end
|
||
|
||
-- 绘制候选字符区
|
||
function keyboard:draw_candidate_area(ctx, ax, ay)
|
||
local candidateY = ay + 50 -- 候选区Y坐标(预览区下方10px)
|
||
local candidateHeight = 50
|
||
local candidateBtnSize = 30
|
||
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
local presse_dbg_color = COLOR_GRAY
|
||
|
||
-- 候选按键固定10个,从左到右排列
|
||
for i = 1, 10 do
|
||
local btnX = ax + (i - 1) * candidateBtnSize
|
||
local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
|
||
-- 根据是否有候选字符决定显示内容
|
||
if i <= #self.currentCandidates then
|
||
local char = self.currentCandidates[i]
|
||
-- 检查候选按键是否被按下
|
||
local isPressed = (self._pressedCandidateIndex == i)
|
||
local btnbg_color = (self.enable_click_effect and isPressed) and presse_dbg_color or bg_color
|
||
|
||
ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, btnbg_color )
|
||
ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
|
||
|
||
-- 绘制候选字符文本
|
||
local textStyle = { size = 12 }
|
||
local textWidth = ctx:text_width(char, textStyle)
|
||
local textHeight = ctx:line_height(textStyle)
|
||
local textX = btnX + (candidateBtnSize - textWidth) // 2
|
||
local textY = btnY + (candidateBtnSize - textHeight) // 2
|
||
ctx:draw_text(char, textX, textY, text_color, textStyle)
|
||
else
|
||
-- 没有候选字符时显示空按钮
|
||
ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, bg_color )
|
||
ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 处理候选字左右翻页按键
|
||
function keyboard:handle_candidate_arrow_touch(evt, x, y)
|
||
local candidateY = self.y + 50
|
||
local candidateHeight = 50
|
||
local candidateBtnSize = 30
|
||
local arrowW = candidateBtnSize
|
||
|
||
-- 左侧翻页按键(←)
|
||
local leftArrowX = self.x
|
||
local leftArrowY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
if x >= leftArrowX and x < leftArrowX + arrowW and
|
||
y >= leftArrowY and y < leftArrowY + candidateBtnSize then
|
||
if evt == "SINGLE_TAP" then
|
||
if self.candidatePageIndex > 1 then
|
||
self.candidatePageIndex = self.candidatePageIndex - 1
|
||
self.selectedCandidateIndex = (self.candidatePageIndex - 1) * 8 + 1
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 右侧翻页按键(→)
|
||
local rightArrowX = self.x + self.w - arrowW
|
||
local rightArrowY = leftArrowY
|
||
if x >= rightArrowX and x < rightArrowX + arrowW and
|
||
y >= rightArrowY and y < rightArrowY + candidateBtnSize then
|
||
if evt == "SINGLE_TAP" then
|
||
local maxPage = math.ceil(#self.pinyinCandidates / 8)
|
||
if self.candidatePageIndex < maxPage then
|
||
self.candidatePageIndex = self.candidatePageIndex + 1
|
||
self.selectedCandidateIndex = (self.candidatePageIndex - 1) * 8 + 1
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- 处理候选字选择区触摸
|
||
function keyboard:handle_candidate_panel_touch(evt, x, y)
|
||
local candidateY = self.y + 50
|
||
local candidateHeight = 50
|
||
local candidateBtnSize = 30
|
||
local arrowW = candidateBtnSize
|
||
local candidatestart_x = self.x + arrowW
|
||
|
||
for i = 1, 8 do
|
||
local idx = (self.candidatePageIndex - 1) * 8 + i
|
||
local btnX = candidatestart_x + (i - 1) * candidateBtnSize
|
||
local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
|
||
if idx <= #self.pinyinCandidates and
|
||
x >= btnX and x < btnX + candidateBtnSize and
|
||
y >= btnY and y < btnY + candidateBtnSize then
|
||
if evt == "TOUCH_DOWN" then
|
||
self._pressedCandidateIndex = idx
|
||
self:invalidate()
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
self:select_candidate(i) -- 传入相对索引(1-8)
|
||
self._pressedCandidateIndex = nil
|
||
return true
|
||
elseif evt == "MOVE_X" or evt == "MOVE_Y" then
|
||
if self._pressedCandidateIndex ~= idx then
|
||
self._pressedCandidateIndex = nil
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- 处理音节选择区触摸
|
||
function keyboard:handle_syllable_panel_touch(evt, x, y)
|
||
local syllableBtnSize = 30
|
||
local syllableAreaX = self.x
|
||
local syllableAreaY = self.y + 95
|
||
local start_y = syllableAreaY
|
||
|
||
-- 上一页按钮(第一个小格子)
|
||
local topBtnY = start_y
|
||
if x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
|
||
y >= topBtnY and y < topBtnY + syllableBtnSize then
|
||
if evt == "SINGLE_TAP" then
|
||
if self.syllablePageIndex > 1 then
|
||
self.syllablePageIndex = self.syllablePageIndex - 1
|
||
self.selectedSyllableIndex = (self.syllablePageIndex - 1) * 10 + 1
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
|
||
-- 中间10个音节按钮(从第二个小格子开始)
|
||
local syllablestart_y = start_y + syllableBtnSize
|
||
for i = 1, 10 do
|
||
local idx = (self.syllablePageIndex - 1) * 10 + i
|
||
local btnY = syllablestart_y + (i - 1) * syllableBtnSize
|
||
|
||
if idx <= #self.syllableCandidates and
|
||
x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
|
||
y >= btnY and y < btnY + syllableBtnSize then
|
||
if evt == "TOUCH_DOWN" then
|
||
self._pressedSyllableIndex = idx
|
||
self:invalidate()
|
||
return true
|
||
elseif evt == "SINGLE_TAP" then
|
||
local syllable = self.syllableCandidates[idx]
|
||
self.selectedSyllableIndex = idx
|
||
self:select_syllable(syllable)
|
||
self._pressedSyllableIndex = nil
|
||
return true
|
||
elseif evt == "MOVE_X" or evt == "MOVE_Y" then
|
||
if self._pressedSyllableIndex ~= idx then
|
||
self._pressedSyllableIndex = nil
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 下一页按钮(第12个小格子)
|
||
local bottomBtnY = start_y + 11 * syllableBtnSize
|
||
if x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
|
||
y >= bottomBtnY and y < bottomBtnY + syllableBtnSize then
|
||
if evt == "SINGLE_TAP" then
|
||
local maxPage = math.ceil(#self.syllableCandidates / 10)
|
||
if self.syllablePageIndex < maxPage then
|
||
self.syllablePageIndex = self.syllablePageIndex + 1
|
||
self.selectedSyllableIndex = (self.syllablePageIndex - 1) * 10 + 1
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- 绘制左侧音节选择区
|
||
function keyboard:draw_left_syllable_panel(ctx, ax, ay)
|
||
local syllableBtnSize = 30 -- 每个音节按钮大小(30x30)
|
||
local syllableAreaX = ax -- 左侧预留区域X坐标
|
||
local syllableAreaY = ay + 95 -- 从按键区域上方开始(与大格子对齐)
|
||
|
||
-- 大格子高度是90px,4个大格子总高度360px
|
||
-- 12个小格子,每个30px,总共360px,正好对齐
|
||
-- 每3个小格子对齐一个大格子(90px = 3 * 30px)
|
||
local keySize = 90 -- 大格子高度
|
||
local totalHeight = 4 * keySize -- 4个大格子的总高度 = 360px
|
||
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
local selecte_dbg_color = COLOR_SKY_BLUE
|
||
local selected_text_color = COLOR_WHITE
|
||
local presse_dbg_color = COLOR_GRAY
|
||
|
||
-- 12个小格子,每个30px,总共360px,正好等于4个大格子的高度
|
||
local start_y = syllableAreaY
|
||
|
||
-- 1. 最上面的上一页切换按键(↑)- 第一个大格子的第一个小格子位置
|
||
local topBtnY = start_y
|
||
ctx:fill_rect(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, bg_color )
|
||
ctx:stroke_rect(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, border_color)
|
||
-- 使用 draw_arrow_icon 绘制箭头图标
|
||
draw_arrow_icon(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, "up", text_color)
|
||
|
||
-- 2. 中间10个音节选择按键
|
||
-- 从第二个小格子开始,每3个小格子对应一个大格子
|
||
-- 索引1是上一页,索引2-11是10个音节,索引12是下一页
|
||
local syllablestart_y = start_y + syllableBtnSize -- 从第二个小格子开始
|
||
for i = 1, 10 do
|
||
local idx = (self.syllablePageIndex - 1) * 10 + i
|
||
local btnY = syllablestart_y + (i - 1) * syllableBtnSize
|
||
|
||
if idx <= #self.syllableCandidates then
|
||
local syllable = self.syllableCandidates[idx]
|
||
local isSelected = (idx == self.selectedSyllableIndex)
|
||
local isPressed = (self._pressedSyllableIndex == idx)
|
||
local btnbg_color
|
||
if self.enable_click_effect and isPressed then
|
||
btnbg_color = presse_dbg_color
|
||
elseif isSelected then
|
||
btnbg_color = selecte_dbg_color
|
||
else
|
||
btnbg_color = bg_color
|
||
end
|
||
local btntext_color = (isSelected or (self.enable_click_effect and isPressed)) and selected_text_color or
|
||
text_color
|
||
|
||
ctx:fill_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, btnbg_color )
|
||
ctx:stroke_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, border_color)
|
||
ctx:draw_text_in_rect_centered(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, syllable, {
|
||
color = btntext_color,
|
||
style = { size = 10 }
|
||
})
|
||
else
|
||
-- 空按钮
|
||
ctx:fill_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, bg_color )
|
||
ctx:stroke_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, border_color)
|
||
end
|
||
end
|
||
|
||
-- 3. 最下面的下一页切换按键(↓)- 第4个大格子的第3个小格子位置(最后一个)
|
||
local bottomBtnY = start_y + 11 * syllableBtnSize -- 第12个小格子(索引12)
|
||
ctx:fill_rect(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, bg_color )
|
||
ctx:stroke_rect(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, border_color)
|
||
draw_arrow_icon(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, "down", text_color)
|
||
end
|
||
|
||
-- 绘制候选字选择区
|
||
function keyboard:draw_pinyin_candidates(ctx, ax, ay)
|
||
local candidateY = ay + 50 -- 候选区Y坐标
|
||
local candidateHeight = 50
|
||
-- 中文候选带左右翻页:左右各占1格(30px),中间8格候选
|
||
local candidateBtnSize = 30 -- 每个候选按钮大小(30x30)
|
||
|
||
local dark = (current_theme == "dark")
|
||
local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
|
||
local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
|
||
local text_color = dark and COLOR_WHITE or COLOR_BLACK
|
||
local selecte_dbg_color = COLOR_SKY_BLUE
|
||
local selected_text_color = COLOR_WHITE
|
||
local presse_dbg_color = COLOR_GRAY
|
||
|
||
-- 左侧分页按键(←)
|
||
local arrowW = candidateBtnSize
|
||
local leftArrowX = ax
|
||
local leftArrowY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
ctx:fill_rect(leftArrowX, leftArrowY, arrowW, candidateBtnSize, bg_color )
|
||
ctx:stroke_rect(leftArrowX, leftArrowY, arrowW, candidateBtnSize, border_color)
|
||
draw_arrow_icon(leftArrowX, leftArrowY, arrowW, candidateBtnSize, "left", text_color)
|
||
|
||
-- 右侧分页按键(→)
|
||
local rightArrowX = ax + self.w - arrowW
|
||
local rightArrowY = leftArrowY
|
||
ctx:fill_rect(rightArrowX, rightArrowY, arrowW, candidateBtnSize, bg_color )
|
||
ctx:stroke_rect(rightArrowX, rightArrowY, arrowW, candidateBtnSize, border_color)
|
||
draw_arrow_icon(rightArrowX, rightArrowY, arrowW, candidateBtnSize, "right", text_color)
|
||
|
||
-- 候选按键固定8个(居中区域,从 ax + arrowW 开始)
|
||
local candidatestart_x = ax + arrowW
|
||
for i = 1, 8 do
|
||
local idx = (self.candidatePageIndex - 1) * 8 + i
|
||
local btnX = candidatestart_x + (i - 1) * candidateBtnSize
|
||
local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
|
||
|
||
if idx <= #self.pinyinCandidates then
|
||
local char = self.pinyinCandidates[idx] -- 直接使用UTF-8字符串
|
||
local isSelected = (idx == self.selectedCandidateIndex)
|
||
local isPressed = (self._pressedCandidateIndex == idx)
|
||
local btnbg_color
|
||
if self.enable_click_effect and isPressed then
|
||
btnbg_color = presse_dbg_color
|
||
elseif isSelected then
|
||
btnbg_color = selecte_dbg_color
|
||
else
|
||
btnbg_color = bg_color
|
||
end
|
||
local btntext_color = (isSelected or (self.enable_click_effect and isPressed)) and selected_text_color or
|
||
text_color
|
||
|
||
ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, btnbg_color )
|
||
ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
|
||
|
||
-- 使用字体渲染候选字(优先使用hzfont,如果不可用则降级到其他字体后端)
|
||
-- 通过 ctx:draw_text 统一接口,字体后端在 ui.init() 中配置
|
||
local textStyle = { size = 12 }
|
||
ctx:draw_text_in_rect_centered(btnX, btnY, candidateBtnSize, candidateBtnSize, char, {
|
||
color = btntext_color,
|
||
style = textStyle
|
||
})
|
||
else
|
||
-- 空按钮
|
||
ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, bg_color )
|
||
ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 绘制音节候选区(显示在候选字选择区的位置,但内容不同)
|
||
function keyboard:draw_syllable_candidates(ctx, ax, ay)
|
||
-- 音节候选区暂时不单独绘制,由左侧音节选择区处理
|
||
-- 这里可以预留,如果需要显示音节预览可以在这里实现
|
||
end
|
||
|
||
ui.keyboard = function(opts)
|
||
return keyboard:new(opts)
|
||
end
|
||
|
||
-- 5.7 MessageBox
|
||
local message_box = setmetatable({}, { __index = BaseWidget })
|
||
message_box.__index = message_box
|
||
|
||
function message_box:new(opts)
|
||
opts = opts or {}
|
||
opts.w = opts.width or opts.w or 280
|
||
opts.h = opts.height or opts.h or 160
|
||
opts.x = opts.x or 20
|
||
opts.y = opts.y or 40
|
||
local o = BaseWidget.new(self, opts)
|
||
o.title = opts.title or "Info"
|
||
o.message = opts.message or ""
|
||
o.word_wrap = opts.word_wrap ~= false
|
||
local dark = (current_theme == "dark")
|
||
o.border_color = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
o.bg_color = opts.bg_color or (dark and COLOR_WIN11_DARK_DIALOG_BG or COLOR_WIN11_LIGHT_DIALOG_BG)
|
||
o.buttons = opts.buttons or { "OK" }
|
||
o.on_result = opts.on_result
|
||
o.text_style = { size = opts.text_size or opts.size or 12 }
|
||
o._buttons = {}
|
||
o:_layout_buttons()
|
||
o:_layout_message()
|
||
return o
|
||
end
|
||
|
||
function message_box:_layout_buttons()
|
||
self._buttons = {}
|
||
local count = #self.buttons
|
||
if count == 0 then return end
|
||
local btnW = 80
|
||
local gap = 12
|
||
local total = count * btnW + (count - 1) * gap
|
||
local start_x = (self.w - total) // 2
|
||
local btnY = self.h - 12 - 36
|
||
for i = 1, count do
|
||
local label = tostring(self.buttons[i])
|
||
local btn = button:new({ x = start_x, y = btnY, w = btnW, h = 36, text = label })
|
||
btn.on_click = function()
|
||
if self.on_result then
|
||
local ok, err = pcall(self.on_result, label, self)
|
||
if not ok then
|
||
log.warn("MessageBox", "on_result error", err)
|
||
end
|
||
end
|
||
self.visible = false
|
||
end
|
||
self:add(btn)
|
||
self._buttons[#self._buttons + 1] = btn
|
||
start_x = start_x + btnW + gap
|
||
end
|
||
end
|
||
|
||
function message_box:_layout_message()
|
||
self._msgPadding = 10
|
||
self._msgstart_y = 36
|
||
local reserved = (#self.buttons > 0) and (12 + 36) or 10
|
||
self._msgHeight = self.h - reserved - self._msgstart_y
|
||
self._msgWidth = self.w - self._msgPadding * 2
|
||
if self.word_wrap then
|
||
self._messageLines = wrap_text_lines(self.message, self._msgWidth, self.text_style)
|
||
local lh = font_line_height(self.text_style)
|
||
self._maxLines = math.max(1, math.floor(self._msgHeight / lh))
|
||
else
|
||
self._messageLines = nil
|
||
end
|
||
end
|
||
|
||
function message_box:set_message(message)
|
||
self.message = tostring(message or "")
|
||
self:_layout_message()
|
||
self:invalidate()
|
||
end
|
||
|
||
function message_box:set_title(title)
|
||
self.title = tostring(title or "")
|
||
self:invalidate()
|
||
end
|
||
|
||
function message_box:show()
|
||
self.visible = true
|
||
self.enabled = true
|
||
self:invalidate()
|
||
end
|
||
|
||
function message_box:hide()
|
||
self.visible = false
|
||
self:invalidate()
|
||
end
|
||
|
||
function message_box:draw(ctx)
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
ctx:fill_rect(ax, ay, self.w, self.h, self.bg_color)
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, self.border_color)
|
||
ctx:draw_text(self.title, ax + 10, ay + 8, self.text_color, self.text_style)
|
||
local style = self.text_style
|
||
local lh = ctx:line_height(style)
|
||
local start_y = ay + self._msgstart_y
|
||
if self.word_wrap then
|
||
local lines = self._messageLines or wrap_text_lines(self.message, self._msgWidth, style)
|
||
local limit = math.min(#lines, self._maxLines or #lines)
|
||
for i = 1, limit do
|
||
ctx:draw_text(lines[i], ax + self._msgPadding, start_y + (i - 1) * lh, self.text_color, style)
|
||
end
|
||
else
|
||
local text = fit_text_to_width(self.message, self._msgWidth, style, { ellipsis = true })
|
||
ctx:draw_text(text, ax + self._msgPadding, start_y, self.text_color, style)
|
||
end
|
||
end
|
||
|
||
function message_box:handle_event()
|
||
if not (self.visible and self.enabled) then return false end
|
||
return true
|
||
end
|
||
|
||
ui.message_box = function(opts)
|
||
return message_box:new(opts)
|
||
end
|
||
|
||
-- 5.6 Picture
|
||
local picture = setmetatable({}, { __index = BaseWidget })
|
||
picture.__index = picture
|
||
|
||
function picture:new(opts)
|
||
opts = opts or {}
|
||
local o = BaseWidget.new(self, opts)
|
||
o.src = opts.src
|
||
o.sources = opts.sources
|
||
o.index = opts.index or 1
|
||
o.autoplay = not not opts.autoplay
|
||
o.interval = opts.interval or 1000
|
||
o._last_switch = now_ms()
|
||
o._imageCache = {}
|
||
o._timer_id = nil
|
||
if o.w == 0 then o.w = 80 end
|
||
if o.h == 0 then o.h = 80 end
|
||
-- 如果启用自动播放,启动定时器
|
||
if o.autoplay and o.sources and #o.sources > 1 then
|
||
o:_start_autoplay_timer()
|
||
end
|
||
return o
|
||
end
|
||
|
||
function picture:set_sources(list)
|
||
self.sources = list
|
||
self.index = 1
|
||
-- 如果启用自动播放且有多个图片,重启定时器
|
||
if self.autoplay and list and #list > 1 then
|
||
self:_stop_autoplay_timer()
|
||
self:_start_autoplay_timer()
|
||
elseif not list or #list <= 1 then
|
||
self:_stop_autoplay_timer()
|
||
end
|
||
end
|
||
|
||
function picture:next()
|
||
if not self.sources or #self.sources == 0 then return end
|
||
self.index = (self.index % #self.sources) + 1
|
||
end
|
||
|
||
function picture:prev()
|
||
if not self.sources or #self.sources == 0 then return end
|
||
self.index = (self.index - 2) % #self.sources + 1
|
||
end
|
||
|
||
function picture:_start_autoplay_timer()
|
||
if self._timer_id then return end
|
||
if not (self.sources and #self.sources > 1) then return end
|
||
|
||
-- 使用定时器定期触发切换
|
||
local function autoplay_tick()
|
||
if not self.autoplay or not self.visible then
|
||
self:_stop_autoplay_timer()
|
||
return
|
||
end
|
||
if not self.sources or #self.sources <= 1 then
|
||
self:_stop_autoplay_timer()
|
||
return
|
||
end
|
||
|
||
local t = now_ms()
|
||
if (t - self._last_switch) >= self.interval then
|
||
self:next()
|
||
self._last_switch = t
|
||
self:invalidate()
|
||
end
|
||
end
|
||
|
||
-- 尝试使用 sys.timerLoopStart(如果可用)
|
||
if sys and sys.timerLoopStart then
|
||
-- 使用较短的检查间隔(100ms),确保及时响应
|
||
self._timer_id = sys.timerLoopStart(autoplay_tick, math.min(100, self.interval))
|
||
else
|
||
-- 如果没有定时器 API,回退到原来的方式(在 draw 中检查)
|
||
-- 这种情况下需要确保 ui.render() 被定期调用
|
||
self._timer_id = true -- 标记为已启用,但使用 draw() 中的逻辑
|
||
end
|
||
end
|
||
|
||
function picture:_stop_autoplay_timer()
|
||
if self._timer_id and sys and sys.timerStop then
|
||
sys.timerStop(self._timer_id)
|
||
end
|
||
self._timer_id = nil
|
||
end
|
||
|
||
function picture:play()
|
||
self.autoplay = true
|
||
if not self._timer_id then
|
||
self:_start_autoplay_timer()
|
||
end
|
||
end
|
||
|
||
function picture:pause()
|
||
self.autoplay = false
|
||
self:_stop_autoplay_timer()
|
||
end
|
||
|
||
function picture:draw()
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
local path = self.src
|
||
if self.sources and #self.sources > 0 then
|
||
path = self.sources[self.index]
|
||
end
|
||
|
||
if type(path) == "string" and path ~= "" then
|
||
-- 优先使用图片缓存(lcd.image2raw + lcd.draw)
|
||
if lcd and lcd.image2raw and lcd.draw then
|
||
local zbuff = ui.image_cache.get_zbuff(path)
|
||
if zbuff then
|
||
-- 使用 zbuff 绘制,lcd.draw 会自动使用 zbuff 内部的 width 和 height
|
||
lcd.draw(ax, ay, nil, nil, zbuff)
|
||
return
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 绘制占位符
|
||
draw_image_placeholder(ax, ay, self.w, self.h, COLOR_GRAY, COLOR_WHITE)
|
||
end
|
||
|
||
function picture:handle_event()
|
||
return false
|
||
end
|
||
|
||
ui.picture = function(opts)
|
||
return picture:new(opts)
|
||
end
|
||
|
||
-- 5.7 ProgressBar
|
||
local progress_bar = setmetatable({}, { __index = BaseWidget })
|
||
progress_bar.__index = progress_bar
|
||
|
||
function progress_bar:new(opts)
|
||
opts = opts or {}
|
||
opts.w = opts.width or opts.w or 200
|
||
opts.h = opts.height or opts.h or 24
|
||
local o = BaseWidget.new(self, opts)
|
||
o.progress = math.max(0, math.min(100, opts.progress or 0))
|
||
o.show_percentage = opts.show_percentage ~= false
|
||
o.text = opts.text
|
||
o.text_style = { size = opts.text_size or opts.size or 12 }
|
||
local dark = (current_theme == "dark")
|
||
o.background_color = opts.background_color or (dark and COLOR_GRAY or 0xC618)
|
||
o.progress_color = opts.progress_color or (dark and COLOR_BLUE or COLOR_SKY_BLUE)
|
||
o.border_color = opts.border_color or (dark and COLOR_WHITE or 0x8410)
|
||
o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
|
||
return o
|
||
end
|
||
|
||
function progress_bar:get_progress()
|
||
return self.progress
|
||
end
|
||
|
||
function progress_bar:set_progress(value)
|
||
self.progress = math.max(0, math.min(100, value))
|
||
self:invalidate()
|
||
end
|
||
|
||
function progress_bar:set_text(text)
|
||
self.text = tostring(text or "")
|
||
self:invalidate()
|
||
end
|
||
|
||
function progress_bar:draw(ctx)
|
||
if not self.visible then return end
|
||
local ax, ay = self:get_absolute_position()
|
||
ctx:fill_rect(ax + 1, ay + 1, self.w - 2, self.h - 2, self.background_color)
|
||
ctx:stroke_rect(ax, ay, self.w, self.h, self.border_color)
|
||
local innerWidth = math.max(0, self.w - 2)
|
||
local fillWidth = math.floor(innerWidth * (self.progress / 100))
|
||
if fillWidth > 0 then
|
||
ctx:fill_rect(ax + 1, ay + 1, fillWidth, self.h - 2, self.progress_color)
|
||
end
|
||
if self.show_percentage or self.text then
|
||
local label = self.text or (tostring(self.progress) .. "%")
|
||
draw_text_in_rect_centered(ax, ay, self.w, self.h, label, {
|
||
color = self.text_color,
|
||
style = self.text_style,
|
||
padding = 2
|
||
})
|
||
end
|
||
end
|
||
|
||
function progress_bar:handle_event()
|
||
return false
|
||
end
|
||
|
||
ui.progress_bar = function(opts)
|
||
return progress_bar:new(opts)
|
||
end
|
||
|
||
-- 5.8 Window
|
||
local function window_theme_color()
|
||
return (current_theme == "dark") and COLOR_BLACK or COLOR_WHITE
|
||
end
|
||
|
||
local function window_snap_axis(self, axis, mode)
|
||
local sc = self._scroll
|
||
if not sc then return false end
|
||
local pageSize, contentSize, offsetField
|
||
if axis == "x" then
|
||
pageSize = sc.page_width or self.w
|
||
contentSize = sc.content_width or self.w
|
||
offsetField = "offset_x"
|
||
if not (sc.direction == "horizontal" or sc.direction == "both") then
|
||
return false
|
||
end
|
||
else
|
||
pageSize = sc.page_height or self.h
|
||
contentSize = sc.content_height or self.h
|
||
offsetField = "offset_y"
|
||
if not (sc.direction == "vertical" or sc.direction == "both") then
|
||
return false
|
||
end
|
||
end
|
||
if pageSize <= 0 then return false end
|
||
local pages = math.max(1, math.floor((contentSize + pageSize - 1) / pageSize))
|
||
local current = sc[offsetField] or 0
|
||
local cur = math.floor((-(current) + pageSize / 2) / pageSize)
|
||
if mode == "increment" then
|
||
cur = cur + 1
|
||
elseif mode == "decrement" then
|
||
cur = cur - 1
|
||
elseif type(mode) == "number" then
|
||
cur = mode
|
||
end
|
||
if cur < 0 then cur = 0 end
|
||
if cur > pages - 1 then cur = pages - 1 end
|
||
local target = -cur * pageSize
|
||
if target ~= current then
|
||
sc[offsetField] = target
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
local window = setmetatable({}, { __index = BaseWidget })
|
||
window.__index = window
|
||
|
||
function window:new(opts)
|
||
opts = opts or {}
|
||
opts.x = opts.x or 0
|
||
opts.y = opts.y or 0
|
||
opts.w = opts.w or render_state.viewport_w
|
||
opts.h = opts.h or render_state.viewport_h
|
||
local o = BaseWidget.new(self, opts)
|
||
o.background_color = opts.background_color or window_theme_color()
|
||
o.background_image = opts.background_image
|
||
o._scroll = nil
|
||
if opts.scroll then
|
||
o:enable_scroll(opts.scroll)
|
||
end
|
||
return o
|
||
end
|
||
|
||
function window:add(child)
|
||
child = BaseWidget.add(self, child)
|
||
child._parentWindow = self
|
||
return child
|
||
end
|
||
|
||
function window:remove(child)
|
||
for i = #self.children, 1, -1 do
|
||
if self.children[i] == child then
|
||
table.remove(self.children, i)
|
||
if child then
|
||
if child.on_unmount then
|
||
pcall(child.on_unmount, child)
|
||
end
|
||
child.parent = nil
|
||
child._parentWindow = nil
|
||
end
|
||
self:invalidate()
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
function window:clear()
|
||
for i = #self.children, 1, -1 do
|
||
local child = self.children[i]
|
||
table.remove(self.children, i)
|
||
if child then
|
||
if child.on_unmount then
|
||
pcall(child.on_unmount, child)
|
||
end
|
||
child.parent = nil
|
||
child._parentWindow = nil
|
||
end
|
||
end
|
||
self:invalidate()
|
||
end
|
||
|
||
function window:set_background_color(color)
|
||
self.background_color = color
|
||
self.background_image = nil
|
||
self:invalidate()
|
||
end
|
||
|
||
function window:set_background_image(path)
|
||
self.background_image = path
|
||
self:invalidate()
|
||
end
|
||
|
||
function window:_scroll_bounds()
|
||
local sc = self._scroll
|
||
if not sc then return 0, 0, 0, 0 end
|
||
local cw = sc.content_width or self.w
|
||
local ch = sc.content_height or self.h
|
||
local minX = math.min(0, self.w - cw)
|
||
local maxX = 0
|
||
local minY = math.min(0, self.h - ch)
|
||
local maxY = 0
|
||
return minX, maxX, minY, maxY
|
||
end
|
||
|
||
function window:_handle_scroll_gesture(evt, x, y)
|
||
local sc = self._scroll
|
||
if not sc or not sc.enabled then
|
||
return false
|
||
end
|
||
if evt == "TOUCH_DOWN" then
|
||
sc.active = self:contains_point(x, y)
|
||
sc.dragging = false
|
||
sc.start_x = x
|
||
sc.start_y = y
|
||
sc.base_offset_x = sc.offset_x
|
||
sc.base_offset_y = sc.offset_y
|
||
sc.snapped = false
|
||
return false
|
||
elseif evt == "MOVE_X" or evt == "MOVE_Y" then
|
||
if not sc.active then return false end
|
||
sc.dragging = true
|
||
local dx = x - (sc.start_x or x)
|
||
local dy = y - (sc.start_y or y)
|
||
local minX, maxX, minY, maxY = self:_scroll_bounds()
|
||
local changed = false
|
||
local snap_horizontal = sc.snap_to_page and (sc.direction == "horizontal" or sc.direction == "both")
|
||
local snap_vertical = sc.snap_to_page and (sc.direction == "vertical" or sc.direction == "both")
|
||
if sc.direction == "horizontal" or sc.direction == "both" then
|
||
if not snap_horizontal then
|
||
local nx = clamp((sc.base_offset_x or 0) + dx, minX, maxX)
|
||
if nx ~= sc.offset_x then
|
||
sc.offset_x = nx
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
if sc.direction == "vertical" or sc.direction == "both" then
|
||
if not snap_vertical then
|
||
local ny = clamp((sc.base_offset_y or 0) + dy, minY, maxY)
|
||
if ny ~= sc.offset_y then
|
||
sc.offset_y = ny
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
if changed then
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
elseif evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
|
||
local was_dragging = sc.dragging
|
||
sc.active = false
|
||
sc.dragging = false
|
||
if was_dragging then
|
||
if sc.snap_to_page then
|
||
window_snap_axis(self, "x")
|
||
window_snap_axis(self, "y")
|
||
end
|
||
return true
|
||
end
|
||
elseif evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" then
|
||
if sc.snap_to_page and (sc.direction == "horizontal" or sc.direction == "both") then
|
||
local mode = (evt == "SWIPE_LEFT") and "increment" or "decrement"
|
||
window_snap_axis(self, "x", mode)
|
||
sc.active = false
|
||
sc.dragging = false
|
||
sc.snapped = true
|
||
return true
|
||
end
|
||
elseif evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
|
||
if sc.snap_to_page and (sc.direction == "vertical" or sc.direction == "both") then
|
||
local mode = (evt == "SWIPE_DOWN") and "increment" or "decrement"
|
||
window_snap_axis(self, "y", mode)
|
||
sc.active = false
|
||
sc.dragging = false
|
||
sc.snapped = true
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
function window:enable_scroll(opts)
|
||
opts = opts or {}
|
||
self._scroll = {
|
||
enabled = true,
|
||
direction = opts.direction or "vertical",
|
||
content_width = opts.content_width or opts.contentWidth or self.w,
|
||
content_height = opts.content_height or opts.contentHeight or self.h,
|
||
offset_x = 0,
|
||
offset_y = 0,
|
||
start_x = 0,
|
||
start_y = 0,
|
||
base_offset_x = 0,
|
||
base_offset_y = 0,
|
||
active = false,
|
||
dragging = false,
|
||
page_width = opts.page_width or self.w,
|
||
page_height = opts.page_height or self.h,
|
||
snap_to_page = opts.snap_to_page or false,
|
||
snapped = false
|
||
}
|
||
end
|
||
|
||
function window:set_content_size(w, h)
|
||
if not self._scroll then
|
||
self:enable_scroll({})
|
||
end
|
||
if w then self._scroll.content_width = w end
|
||
if h then self._scroll.content_height = h end
|
||
end
|
||
|
||
-- 启用子页面管理
|
||
function window:enable_subpage_manager(opts)
|
||
opts = opts or {}
|
||
if not self._managed then
|
||
self._managed = {
|
||
pages = {},
|
||
back_event_name = opts.back_event_name or "NAV.BACK",
|
||
on_back = opts.on_back
|
||
}
|
||
if sys and sys.subscribe then
|
||
sys.subscribe(self._managed.back_event_name, function()
|
||
if self._managed.on_back then
|
||
pcall(self._managed.on_back)
|
||
end
|
||
local anyVisible = false
|
||
for _, pg in pairs(self._managed.pages) do
|
||
if pg and pg.visible ~= false then
|
||
anyVisible = true
|
||
break
|
||
end
|
||
end
|
||
if not anyVisible then
|
||
self.visible = true
|
||
self.enabled = true
|
||
self:invalidate()
|
||
end
|
||
end)
|
||
end
|
||
end
|
||
return self
|
||
end
|
||
|
||
-- 配置子页面工厂
|
||
function window:configure_subpages(factories)
|
||
if not self._managed then
|
||
self:enable_subpage_manager()
|
||
end
|
||
self._managed.factories = self._managed.factories or {}
|
||
for k, v in pairs(factories or {}) do
|
||
self._managed.factories[k] = v
|
||
end
|
||
return self
|
||
end
|
||
|
||
-- 显示子页面
|
||
function window:show_subpage(name, factory)
|
||
if not self._managed then
|
||
error("enable_subpage_manager must be called before show_subpage")
|
||
end
|
||
-- 隐藏所有其他子页面
|
||
for key, pg in pairs(self._managed.pages) do
|
||
if pg and pg.visible ~= false then
|
||
pg.visible = false
|
||
pg.enabled = false
|
||
pg:invalidate()
|
||
end
|
||
end
|
||
-- 如果子页面不存在,则创建
|
||
if not self._managed.pages[name] then
|
||
local f = factory
|
||
if not f and self._managed.factories then
|
||
f = self._managed.factories[name]
|
||
end
|
||
if not f then
|
||
error("no factory for subpage '" .. tostring(name) .. "'")
|
||
end
|
||
self._managed.pages[name] = f()
|
||
self._managed.pages[name]._parentWindow = self
|
||
runtime.add(self._managed.pages[name])
|
||
end
|
||
-- 隐藏当前窗口,显示子页面
|
||
self.visible = false
|
||
self.enabled = false
|
||
self._managed.pages[name].visible = true
|
||
self._managed.pages[name].enabled = true
|
||
self:invalidate()
|
||
self._managed.pages[name]:invalidate()
|
||
end
|
||
|
||
-- 返回上级页面
|
||
function window:back()
|
||
if self._parentWindow then
|
||
self.visible = false
|
||
self.enabled = false
|
||
self:invalidate()
|
||
local parent = self._parentWindow
|
||
local anyVisible = false
|
||
if parent._managed and parent._managed.pages then
|
||
for _, pg in pairs(parent._managed.pages) do
|
||
if pg and pg.visible ~= false then
|
||
anyVisible = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
if not anyVisible then
|
||
parent.visible = true
|
||
parent.enabled = true
|
||
parent:invalidate()
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 关闭子页面
|
||
function window:close_subpage(name, opts)
|
||
if not self._managed or not self._managed.pages then
|
||
return false
|
||
end
|
||
opts = opts or {}
|
||
local pg = self._managed.pages[name]
|
||
if not pg then
|
||
return false
|
||
end
|
||
pg.visible = false
|
||
pg.enabled = false
|
||
pg:invalidate()
|
||
if opts.destroy == true then
|
||
runtime.remove(pg)
|
||
self._managed.pages[name] = nil
|
||
if collectgarbage then
|
||
collectgarbage("collect")
|
||
end
|
||
end
|
||
-- 检查是否还有其他可见的子页面
|
||
local anyVisible = false
|
||
for _, p in pairs(self._managed.pages) do
|
||
if p and p.visible ~= false then
|
||
anyVisible = true
|
||
break
|
||
end
|
||
end
|
||
if not anyVisible then
|
||
self.visible = true
|
||
self.enabled = true
|
||
self:invalidate()
|
||
end
|
||
return true
|
||
end
|
||
|
||
function window:draw(ctx)
|
||
local ax, ay = self:get_absolute_position()
|
||
if self.background_image and lcd then
|
||
if lcd.drawImage then
|
||
lcd.drawImage(ax, ay, self.background_image)
|
||
elseif lcd.showImage then
|
||
lcd.showImage(ax, ay, self.background_image)
|
||
else
|
||
ctx:fill_rect(ax, ay, self.w, self.h, self.background_color)
|
||
end
|
||
else
|
||
ctx:fill_rect(ax, ay, self.w, self.h, self.background_color)
|
||
end
|
||
for i = 1, #self.children do
|
||
local child = self.children[i]
|
||
if child and child.visible ~= false and child.draw then
|
||
child:draw(ctx)
|
||
end
|
||
end
|
||
end
|
||
|
||
function window:dispatch_pointer(evt, x, y)
|
||
if not self.visible or not self.enabled then return false end
|
||
local inside = self:contains_point(x, y) or (self._scroll and self._scroll.dragging)
|
||
if not inside and evt ~= "MOVE_X" and evt ~= "MOVE_Y" then
|
||
return false
|
||
end
|
||
if self:_handle_scroll_gesture(evt, x, y) then
|
||
return true
|
||
end
|
||
for i = #self.children, 1, -1 do
|
||
if self.children[i]:dispatch_pointer(evt, x, y) then
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
ui.window = function(opts)
|
||
return window:new(opts)
|
||
end
|
||
|
||
-- ================================
|
||
-- 6. 对外接口导出
|
||
-- ================================
|
||
|
||
function ui.sw_init(opts)
|
||
opts = opts or {}
|
||
if opts.theme == "light" or opts.theme == "dark" then
|
||
current_theme = opts.theme
|
||
end
|
||
runtime.bindInput()
|
||
end
|
||
|
||
function ui.theme()
|
||
return current_theme
|
||
end
|
||
|
||
function ui.add(widget)
|
||
return runtime.add(widget)
|
||
end
|
||
|
||
function ui.remove(widget)
|
||
return runtime.remove(widget)
|
||
end
|
||
|
||
function ui.clear(color)
|
||
ui.render.background(color or COLOR_BLACK)
|
||
end
|
||
|
||
-- 已废除:预计1.8.0删除
|
||
function ui.renderFrame()
|
||
return nil -- 返回空值
|
||
end
|
||
|
||
-- 已废除:预计1.8.0删除
|
||
function ui.refresh()
|
||
return nil -- 返回空值
|
||
end
|
||
|
||
return ui |