| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740 |
- --[[
- exEasyUI - 简化的UI组件库
- 版本号:1.6.1
- 作者: zengshuai
- 日期:2025-10-09
- =====================================
- 结构说明:
- 1. 常量定义 - UI颜色常量和调试配置
- 2. 硬件依赖 - 使用exlcd/extp初始化LCD和TP,并使用gtfont初始化字体(可选)
- 3. 核心部分 - 组件管理、事件分发、渲染系统
- 4. 组件部分 - 目前有6个组件
- - Button:按钮组件
- - CheckBox:复选框组件
- - Label:标签组件
- - Picture:图片组件
- - MessageBox:消息框组件
- - Window:窗口组件
- - ProgressBar:进度条组件
- 基于原exSimpleUI重构,将所有代码合并为单个文件,便于使用和维护。
- 支持触摸事件分发、组件渲染、主题切换等核心功能。
- ]]
- local screen_data = require "screen_data_table" -- 唯一引入屏幕配置参数的地方
- local exlcd = require "exlcd" -- 显示驱动模块
- local extp = require "extp" -- 触摸驱动模块
- gtfont_dev = gtfont_dev or nil -- 全局SPI设备句柄,避免被GC
- -- ================================
- -- 1. 常量定义
- -- ================================
- -- UI颜色常量
- 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
- -- Windows 11 风格颜色(v1.6.0新增)
- -- Light模式
- local COLOR_WIN11_LIGHT_DIALOG_BG = 0xF79E -- RGB(243, 243, 243) - 对话框背景
- local COLOR_WIN11_LIGHT_BUTTON_BG = 0xFFDF -- RGB(251, 251, 252) - 按钮背景
- local COLOR_WIN11_LIGHT_BUTTON_BORDER = 0xE73C -- RGB(229, 229, 229) - 按钮边框
- -- Dark模式
- local COLOR_WIN11_DARK_DIALOG_BG = 0x2104 -- RGB(32, 32, 32) - 对话框背景
- local COLOR_WIN11_DARK_BUTTON_BG = 0x3186 -- RGB(51, 51, 51) - 按钮背景
- local COLOR_WIN11_DARK_BUTTON_BORDER = 0x4A69 -- RGB(76, 76, 76) - 按钮边框
- -- ================================
- -- 2. 硬件依赖部分 (hw)
- -- ================================
- local hw = {}
- local FontAdapter = { _backend = "default", _size = 12, _gray = false, _name = nil }
- -- 硬件初始化入口
- function hw.init(opts)
- -- 初始化显示屏
- -- 使用screen_data配置表中的参数初始化LCD,在配置表中修改即可
- local lcd_init_success exlcd.init(screen_data.lcdargs)
-
- -- 检查LCD初始化是否成功
- if lcd_init_success then
- log.error("ui_main", "LCD初始化失败")
- return -- 初始化失败,退出任务
- end
-
- -- 通用显示设置
- lcd.setupBuff(nil, false) -- 设置帧缓冲区
- lcd.autoFlush(false) -- 禁止自动刷新
- -- -- 设置字体为模组自带的opposansm12中文字体
- -- lcd.setFont(lcd.font_opposansm12_chinese)
- -- 初始化触摸IC
- -- 使用配置表中的参数初始化触摸
- extp.init(screen_data.touch)
- extp.setPublishEnabled("all", true) -- 发布所有消息
- -- 自定义配置
- extp.setSlideThreshold(40) -- 设置滑动阈值为40像素
- extp.setLongPressThreshold(600) -- 设置长按阈值为600毫秒
- -- 字体后端装配(保持原逻辑,可选)
- local fcfg = opts.font or {}
- if fcfg.type == "gtfont" then
- local spi_id = (fcfg.spi and fcfg.spi.id) or 0
- local spi_cs = (fcfg.spi and fcfg.spi.cs) or 8
- local spi_clk = (fcfg.spi and fcfg.spi.clock) or (20 * 1000 * 1000)
- gtfont_dev = spi.deviceSetup(spi_id or 1, spi_cs or 12, 0, 0, 8, spi_clk or (20*1000*1000), spi.MSB, 1, 0)
- log.error("exEasyUI.gtfont", "spi.deviceSetup", type(gtfont_dev))
- if type(gtfont_dev) ~= "userdata" then
- log.error("exEasyUI.gtfont", "spi.deviceSetup error", type(gtfont_dev))
- gtfont_dev = nil
- end
- local gtfont_ok = gtfont.init(gtfont_dev)
- if gtfont_ok then
- FontAdapter._backend = "gtfont"
- FontAdapter._size = tonumber(fcfg.size or 16)
- FontAdapter._gray = not not fcfg.gray
- log.info("exEasyUI", "gtfont enabled", spi_id, spi_cs, FontAdapter._size)
- else
- FontAdapter._backend = "default"
- FontAdapter._size = 12
- FontAdapter._gray = false
- log.warn("exEasyUI", "gtfont init failed, fallback to default font")
- end
- else
- FontAdapter._backend = "default"
- FontAdapter._size = 12
- FontAdapter._gray = false
- FontAdapter._name = (fcfg and fcfg.name) or nil
- if lcd and lcd.setFont and lcd.font_opposansm12_chinese then
- lcd.setFont(lcd.font_opposansm12_chinese)
- end
- end
- return true
- end
- -- ================================
- -- 3. 核心部分 (core + event)
- -- ================================
- local core = {}
- local event = {}
- -- 组件注册表
- local registry = {}
- local last_action = nil
- local current_theme = "dark"
- -- 调试开关
- core.debug_touch = true
- -- 调试配置函数
- function core.debug(v)
- if v == nil then return { touch = not not core.debug_touch } end
- if type(v) == "boolean" then
- core.debug_touch = v
- return
- end
- if type(v) == "table" then
- if v.touch ~= nil then core.debug_touch = not not v.touch end
- return
- end
- end
- -- 添加组件到渲染队列
- function core.add(component)
- registry[#registry + 1] = component
- end
- -- 从注册表移除组件
- function core.remove(component)
- for i = #registry, 1, -1 do
- if registry[i] == component then
- table.remove(registry, i)
- return true
- end
- end
- return false
- end
- -- 清屏
- function core.clear(color)
- lcd.clear(color or COLOR_BLACK)
- end
- -- 渲染所有可见组件
- function core.render()
- for i = 1, #registry do
- local c = registry[i]
- if c and c.visible ~= false and c.draw then c:draw() end
- end
- lcd.flush()
- end
- -- 获取当前时间戳
- local function now_ms()
- if mcu and mcu.ticks then return mcu.ticks() end
- return (os.clock() or 0) * 1000
- end
- -- 命中测试
- local function hit_test(x, y, r)
- return x >= r.x and y >= r.y and x <= (r.x + r.w) and y <= (r.y + r.h)
- end
- -- 触摸事件分发(extp事件)
- function core.handleTouchEvent(evt, x, y)
- local start_ms
- if core.debug_touch then
- start_ms = now_ms()
- end
- for i = #registry, 1, -1 do
- local c = registry[i]
- if c and c.enabled ~= false and c.handleEvent and
- (c._capture == true or hit_test(x, y, { x = c.x, y = c.y, w = c.w, h = c.h })) then
- if c:handleEvent(evt, x, y) then
- if core.debug_touch and start_ms then
- local dt = now_ms() - start_ms
- log.info("exEasyUI", "consumed_by", tostring(c.__name or "component"), string.format("%.2fms", dt))
- end
- return true
- end
- end
- end
- if core.debug_touch and start_ms then
- local dt = now_ms() - start_ms
- if evt ~= "MOVE_X" and evt ~= "MOVE_Y" then
- log.info("exEasyUI", "not_consumed_cost", string.format("%.2fms", dt))
- end
- end
- return false
- end
- -- 系统初始化
- function core.init(opts)
- opts = opts or {}
-
- -- 主题设置:根据传入参数设置当前主题(light/dark)
- if opts.theme == "light" or opts.theme == "dark" then
- current_theme = opts.theme
- end
- -- 触摸事件订阅与转发(extp)
- -- extp_down_x, extp_down_y 记录按下时的原始坐标
- -- extp_curr_x, extp_curr_y 记录当前触摸点坐标(用于MOVE/滑动等)
- local extp_down_x, extp_down_y
- local extp_curr_x, extp_curr_y
- -- extp_dispatch: 触摸事件分发函数,负责将底层触摸事件转换为UI事件
- -- evt: 事件类型(如TOUCH_DOWN、MOVE_X、SINGLE_TAP等)
- -- a, b: 事件参数(如坐标或偏移量)
- local function extp_dispatch(evt, a, b)
- if evt == "TOUCH_DOWN" then
- -- 记录触摸按下时的原始坐标,a和b通常为触摸点的x、y坐标,若无法转换为数字则默认为0
- extp_down_x, extp_down_y = tonumber(a) or 0, tonumber(b) or 0
- extp_curr_x, extp_curr_y = extp_down_x, extp_down_y
- if core.debug_touch then log.info("exEasyUI", "extp", "TOUCH_DOWN", extp_curr_x, extp_curr_y) end
- -- 分发TOUCH_DOWN事件
- core.handleTouchEvent("TOUCH_DOWN", extp_curr_x, extp_curr_y)
- last_action = "TOUCH_DOWN"
- return
- end
- -- 若未按下则忽略后续事件
- if not extp_down_x or not extp_down_y then return end
- if evt == "MOVE_X" then
- -- 处理横向滑动,a为x方向偏移
- local dx = tonumber(a) or 0
- extp_curr_x = extp_down_x + dx
- if core.debug_touch then log.info("exEasyUI", "extp", "MOVE_X", extp_curr_x, extp_curr_y) end
- core.handleTouchEvent("MOVE_X", extp_curr_x, extp_curr_y)
- last_action = "MOVE_X"
- return
- elseif evt == "MOVE_Y" then
- -- 处理纵向滑动,b为y方向偏移
- local dy = tonumber(b) or 0
- extp_curr_y = extp_down_y + dy
- if core.debug_touch then log.info("exEasyUI", "extp", "MOVE_Y", extp_curr_x, extp_curr_y) end
- core.handleTouchEvent("MOVE_Y", extp_curr_x, extp_curr_y)
- last_action = "MOVE_Y"
- return
- elseif evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" then
- -- 处理左右滑动手势,a为x方向偏移
- local dx = tonumber(a) or 0
- extp_curr_x = extp_down_x + dx
- if core.debug_touch then log.info("exEasyUI", "extp", evt, extp_curr_x, extp_curr_y) end
- core.handleTouchEvent(evt, extp_curr_x, extp_curr_y)
- last_action = evt
- elseif evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
- -- 处理上下滑动手势,b为y方向偏移
- local dy = tonumber(b) or 0
- extp_curr_y = extp_down_y + dy
- if core.debug_touch then log.info("exEasyUI", "extp", evt, extp_curr_x, extp_curr_y) end
- core.handleTouchEvent(evt, extp_curr_x, extp_curr_y)
- last_action = evt
- elseif evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
- -- 处理单击/长按事件,a/b为最终坐标(若无则用当前坐标)
- local ux = tonumber(a) or extp_curr_x or 0
- local uy = tonumber(b) or extp_curr_y or 0
- if core.debug_touch then log.info("exEasyUI", "extp", evt, ux, uy) end
- core.handleTouchEvent(evt, ux, uy)
- last_action = evt
- end
- -- 触摸序列结束后,清空坐标状态
- if last_action == "SINGLE_TAP" or last_action == "LONG_PRESS" or
- last_action == "SWIPE_LEFT" or last_action == "SWIPE_RIGHT" or
- last_action == "SWIPE_UP" or last_action == "SWIPE_DOWN" then
- extp_down_x, extp_down_y = nil, nil
- extp_curr_x, extp_curr_y = nil, nil
- end
- end
- -- 订阅底层触摸事件(baseTouchEvent),由extp_dispatch处理
- sys.subscribe("baseTouchEvent", extp_dispatch)
- end
- -- 获取当前主题
- function core.getTheme()
- return current_theme
- end
- -- 事件系统:订阅事件
- function event.on(name, cb)
- return sys.subscribe(name, cb)
- end
- -- 事件系统:发送事件
- function event.emit(name, ...)
- return sys.publish(name, ...)
- end
- -- ================================
- -- 4. 组件部分
- -- ================================
- -- 通用绘图函数
- local function fill_rect(x1, y1, x2, y2, color)
- lcd.fill(x1, y1, x2, y2 + 1, color) -- 右下边界为不含区间, y2需要+1
- end
- local function stroke_rect(x1, y1, x2, y2, color)
- 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
- -- v1.6.1新增:绘制图片占位符(方框+X叉)
- local function draw_image_placeholder(x, y, w, h, bg_color, border_color)
- bg_color = bg_color or 0x8410 -- 默认灰色
- 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)
-
- -- 绘制X叉(对角线)
- lcd.drawLine(x, y, x + w - 1, y + h - 1, border_color)
- lcd.drawLine(x + w - 1, y, x, y + h - 1, border_color)
-
- -- 如果尺寸足够大,绘制内缩的X叉使其更明显
- 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
- -- FontAdapter 实现
- local function font_line_height(style)
- if FontAdapter._backend == "gtfont" then
- local sz = (style and style.size) or FontAdapter._size or 16
- return sz
- end
- -- default backend:优先使用样式中的 size,没有则回退 12
- if style and style.size then
- return tonumber(style.size) or 12
- end
- return 12
- end
- local function font_set(style)
- style = style or {}
- if FontAdapter._backend == "gtfont" then
- FontAdapter._size = tonumber(style.size or FontAdapter._size or 16)
- FontAdapter._gray = (style.gray ~= nil) and not not style.gray or FontAdapter._gray
- if lcd and lcd.setFont and lcd.drawGtfontUtf8 then
- lcd.setFont(lcd.drawGtfontUtf8)
- end
- return
- end
- -- default backend
- FontAdapter._name = style.name or FontAdapter._name
- if lcd and lcd.setFont then
- -- 优先按 name,其次按 size 猜测常见字体名,最后回退
- if FontAdapter._name and lcd["font_" .. FontAdapter._name] then
- lcd.setFont(lcd["font_" .. FontAdapter._name])
- elseif style and style.size then
- local size_num = tonumber(style.size)
- if size_num then
- local guess = "font_opposansm" .. tostring(size_num) .. "_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
- elseif lcd.font_opposansm12_chinese then
- lcd.setFont(lcd.font_opposansm12_chinese)
- end
- end
- end
- local function font_draw(text, x, y, color, style)
- color = color or COLOR_WHITE
- style = style or {}
- if FontAdapter._backend == "gtfont" then
- local sz = tonumber(style.size or FontAdapter._size or 16)
- if FontAdapter._gray and lcd.drawGtfontUtf8Gray then
- -- 固件灰度级目前不可调,传固定值4
- lcd.drawGtfontUtf8Gray(text, sz, 4, x, y, color)
- elseif lcd.drawGtfontUtf8 then
- lcd.drawGtfontUtf8(text, sz, x, y, color)
- else
- -- 回退:不应触达
- if lcd.drawStr then
- lcd.drawStr(x, y + 12, text, color)
- end
- end
- return
- end
- -- default backend:y 为顶部坐标,内部转换为基线
- if lcd and lcd.setFont then
- if FontAdapter._name and lcd["font_" .. FontAdapter._name] then
- lcd.setFont(lcd["font_" .. FontAdapter._name])
- else
- -- 尝试根据 size 选择合适字体
- local used = false
- if style and style.size then
- local guess = "font_opposansm" .. tostring(style.size) .. "_chinese"
- if lcd[guess] then
- lcd.setFont(lcd[guess])
- used = true
- end
- end
- if not used and lcd.font_opposansm12_chinese then
- lcd.setFont(lcd.font_opposansm12_chinese)
- end
- end
- end
- local lh = font_line_height(style)
- lcd.drawStr(x, y + lh, text, color)
- end
- -- 文本宽度测量
- local function font_measure(text, style)
- if not text or text == "" then return 0 end
- style = style or {}
- if FontAdapter._backend == "gtfont" then
- local sz = tonumber(style.size or FontAdapter._size or 16)
- local w = 0
- local i = 1
- while i <= #text do
- local b = string.byte(text, i)
- if b == 32 then -- space
- w = w + math.ceil(sz / 2)
- i = i + 1
- elseif b < 128 then
- w = w + math.ceil(sz / 2)
- i = i + 1
- else
- w = w + sz
- -- 简化处理UTF-8宽字节
- if i + 2 <= #text then i = i + 3 else i = i + 1 end
- end
- end
- return w
- end
- -- default backend,尽量使用原生接口
- if lcd and lcd.setFont then
- -- 尝试在测量前设置到期望字体,以匹配绘制
- if FontAdapter._name and lcd["font_" .. FontAdapter._name] then
- lcd.setFont(lcd["font_" .. FontAdapter._name])
- elseif style and 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
- if lcd and lcd.getStrWidth then return lcd.getStrWidth(text) end
- if lcd and lcd.strWidth then return lcd.strWidth(text) end
- if lcd and lcd.get_string_width then return lcd.get_string_width(text) end
- -- 估算:英文约为 size/2,中文约为 size
- local width = 0
- local i = 1
- while i <= #text do
- local byte = string.byte(text, i)
- if byte < 128 then
- width = width + math.ceil((tonumber(style.size) or 12) / 2)
- i = i + 1
- else
- width = width + (tonumber(style.size) or 12)
- i = i + 3
- end
- end
- return width
- end
- -- UTF-8字符获取(返回字符和字节长度)
- local function get_utf8_char(text, i)
- if not text or i > #text then return "", 0 end
- local byte = string.byte(text, i)
- if byte < 128 then
- -- ASCII字符(1字节)
- return string.sub(text, i, i), 1
- elseif byte >= 224 and byte < 240 then
- -- 3字节UTF-8字符(中文等)
- 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
- -- 2字节UTF-8字符
- 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
- -- 4字节UTF-8字符
- if i + 3 <= #text then
- return string.sub(text, i, i + 3), 4
- else
- return string.sub(text, i, i), 1
- end
- else
- -- 其他情况
- return string.sub(text, i, i), 1
- end
- 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 >= 48 and byte <= 57) or -- 0-9
- (byte >= 65 and byte <= 90) or -- A-Z
- (byte >= 97 and byte <= 122) -- a-z
-
- 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
- -- 兼容旧接口已移除,统一使用下方两个新 API
- -- 新增:直接绘制文本 API(支持 style.size/name/gray)
- local function draw_text_direct(x, y, text, opts)
- opts = opts or {}
- local color = opts.color or COLOR_WHITE
- local style = opts.style or {}
- font_draw(text or "", x, y, color, style)
- end
- -- 新增:在矩形内自适应(仅居中与边界约束,不缩放、不换行)
- local function draw_text_in_rect_centered(x, y, w, h, text, opts)
- opts = opts or {}
- local color = opts.color or COLOR_WHITE
- local style = opts.style or {}
- local padding = opts.padding or 0
- local tw = font_measure(text or "", style)
- local lh = font_line_height(style)
- local inner_x = x + padding
- local inner_y = y + padding
- local inner_w = w - padding * 2
- local inner_h = h - padding * 2
- local tx = inner_x + (inner_w - tw) // 2
- local ty = inner_y + (inner_h - lh) // 2
- -- 夹紧,避免越界
- tx = math.max(inner_x, tx)
- tx = math.min(inner_x + inner_w - tw, tx)
- font_draw(text or "", tx, ty, color, style)
- end
- -- 旧的宽度测量包装已移除,请直接使用 font_measure(text, style)
- -- Button组件 - 基础按钮,支持文本/图片、按下/抬起与点击回调,支持toggle模式
- local Button = {}
- Button.__index = Button
- function Button:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- o.x = opts.x or 0
- o.y = opts.y or 0
- o.w = opts.width or opts.w or 100
- o.h = opts.height or opts.h or 36
-
- -- 文本模式参数
- o.text = opts.text or "Button"
- o.textSize = opts.textSize or opts.size
- local dark = (current_theme == "dark")
- o.bgColor = opts.bgColor or (dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG)
- o.textColor = opts.textColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.borderColor = opts.borderColor or (dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER)
-
- -- 图片模式参数(v1.6.0新增,合并自ToolButton)
- o.src = opts.src
- o.src_pressed = opts.src_pressed
- o.src_toggled = opts.src_toggled
-
- -- Toggle模式参数(v1.6.0新增)
- o.toggle = opts.toggle or false
- o.toggled = opts.toggled or false
- o.onToggle = opts.onToggle
-
- -- 状态
- o.pressed = false
- o.onClick = opts.onClick
- o.visible = opts.visible ~= false
- o.enabled = opts.enabled ~= false
- o._imageCache = {} -- v1.6.1:缓存图片加载状态,避免重复检查和重复打印警告
- return o
- end
- function Button:draw()
- if not self.visible then return end
-
- -- 图片模式:优先显示图片
- if self.src then
- local path
- if self.toggle and self.toggled then
- path = self.src_toggled or self.src
- elseif self.pressed then
- path = self.src_pressed or self.src
- else
- path = self.src
- end
-
- -- v1.6.1修复:检查文件是否存在,并改进占位符显示(使用缓存避免重复检查)
- if type(path) == "string" and path ~= "" and path:lower():sub(-4) == ".jpg" then
- -- 检查缓存
- if self._imageCache[path] == nil then
- -- 未缓存,首次检查文件是否存在
- if io and io.exists and io.exists(path) then
- self._imageCache[path] = true -- 缓存:文件存在
- else
- self._imageCache[path] = false -- 缓存:文件不存在
- log.warn("Button", "图片文件不存在:", path)
- end
- end
-
- -- 根据缓存状态处理
- if self._imageCache[path] == true then
- lcd.showImage(self.x, self.y, path)
- else
- -- 文件不存在(已缓存),直接显示占位符,不再重复警告
- draw_image_placeholder(self.x, self.y, self.w, self.h, COLOR_GRAY, COLOR_WHITE)
- end
- else
- -- path无效或不是jpg,显示占位符
- draw_image_placeholder(self.x, self.y, self.w, self.h, COLOR_GRAY, COLOR_WHITE)
- end
- return
- end
-
- -- 文本模式:绘制文本按钮
- local bg = self.pressed and COLOR_GRAY or self.bgColor
- fill_rect(self.x, self.y, self.x + self.w - 1, self.y + self.h - 1, bg)
- stroke_rect(self.x, self.y, self.x + self.w - 1, self.y + self.h - 1, self.borderColor)
- draw_text_in_rect_centered(self.x, self.y, self.w, self.h, self.text, {
- color = self.textColor,
- style = { size = self.textSize },
- padding = 2
- })
- end
- function Button:setText(newText)
- self.text = tostring(newText or "")
- end
- function Button:handleEvent(evt, x, y)
- if not self.enabled then return false end
- local inside = hit_test(x, y, { x = self.x, y = self.y, w = self.w, h = self.h })
-
- if evt == "TOUCH_DOWN" and inside then
- self.pressed = true
- self._capture = true
- return true
- elseif evt == "MOVE_X" or evt == "MOVE_Y" then
- if self._capture then
- self.pressed = inside
- return true
- end
- elseif evt == "SINGLE_TAP" then
- local was_pressed = self.pressed
- self.pressed = false
- self._capture = false
- if was_pressed and inside then
- -- Toggle模式处理
- if self.toggle then
- self.toggled = not self.toggled
- if self.onToggle then self.onToggle(self.toggled, self) end
- end
- -- 触发点击回调
- if self.onClick then self.onClick(self) end
- return true
- end
- return true
- elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
- self.pressed = false
- self._capture = false
- return true
- end
- return false
- end
- -- CheckBox组件 - 复选框,支持选中状态切换
- local CheckBox = {}
- CheckBox.__index = CheckBox
- function CheckBox:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- o.x = opts.x or 0
- o.y = opts.y or 0
- o.boxSize = opts.boxSize or 16
- o.text = opts.text
- local dark = (current_theme == "dark")
- o.textColor = opts.textColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.borderColor = opts.borderColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.bgColor = opts.bgColor or (dark and COLOR_BLACK or COLOR_WHITE)
- o.tickColor = opts.tickColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.checked = opts.checked or false
- o.enabled = opts.enabled ~= false
- o.visible = opts.visible ~= false
- o.onChange = opts.onChange
- local text_w = (o.text and (#o.text * 6 + 6) or 0)
- o.w = o.boxSize + text_w
- o.h = math.max(o.boxSize, 16)
- return o
- end
- function CheckBox:draw()
- local x2 = self.x + self.boxSize - 1
- local y2 = self.y + self.boxSize - 1
- stroke_rect(self.x, self.y, x2, y2, self.borderColor)
- fill_rect(self.x + 2, self.y + 2, x2 - 2, y2 - 2, self.bgColor)
- if self.checked then
- local pad = 2
- fill_rect(self.x + pad, self.y + pad, x2 - pad, y2 - pad, self.tickColor)
- end
- if self.text then
- local lh = font_line_height(nil)
- local ty = self.y + (self.h - lh) // 2
- draw_text_direct(self.x + self.boxSize + 10, ty, self.text, { color = self.textColor })
- end
- end
- function CheckBox:setChecked(v)
- local nv = not not v
- if nv ~= self.checked then
- self.checked = nv
- if self.onChange then self.onChange(self.checked) end
- end
- end
- function CheckBox:toggle()
- self:setChecked(not self.checked)
- end
- function CheckBox:handleEvent(evt, x, y)
- if not self.enabled then return false end
- if evt == "SINGLE_TAP" then
- if x >= self.x and y >= self.y and x <= (self.x + self.w) and y <= (self.y + self.h) then
- self:toggle()
- return true
- end
- end
- return false
- end
- -- Label组件 - 文本标签,仅显示不响应事件
- local Label = {}
- Label.__index = Label
- function Label:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- o.x = opts.x or 0
- o.y = opts.y or 0
- o.text = tostring(opts.text or "")
- local dark = (current_theme == "dark")
- o.color = opts.color or (dark and COLOR_WHITE or COLOR_BLACK)
- o.font = opts.font
- o.size = opts.size or opts.textSize
- o.wordWrap = not not opts.wordWrap -- 是否启用换行
- o._autoW = (opts.w == nil)
- o._autoH = true -- 高度始终自动计算
- o.visible = opts.visible ~= false
- o.enabled = opts.enabled ~= false
-
- -- 宽度处理
- local style = { size = o.size }
- if opts.w then
- o.w = opts.w
- o._autoW = false
- else
- o.w = font_measure(o.text, style)
- o._autoW = true
- end
-
- -- 高度处理(根据是否换行)
- local lh = font_line_height(style)
- if o.wordWrap and not o._autoW then
- -- 启用换行且指定了宽度,计算多行高度
- local lines = wrap_text_lines(o.text, o.w, style)
- o.h = #lines * lh
- o._lines = lines
- else
- -- 单行高度
- o.h = lh
- o._lines = nil
- end
-
- return o
- end
- function Label:setText(t)
- self.text = tostring(t or "")
- local style = { size = self.size }
- local lh = font_line_height(style)
-
- -- 更新宽度(如果自动)
- if self._autoW then
- self.w = font_measure(self.text, style)
- end
-
- -- 更新高度和行缓存
- if self.wordWrap and not self._autoW then
- local lines = wrap_text_lines(self.text, self.w, style)
- self.h = #lines * lh
- self._lines = lines
- else
- self.h = lh
- self._lines = nil
- end
- end
- function Label:setSize(sz)
- self.size = tonumber(sz) or self.size
- local style = { size = self.size }
- local lh = font_line_height(style)
-
- -- 更新宽度(如果自动)
- if self._autoW then
- self.w = font_measure(self.text or "", style)
- end
-
- -- 更新高度和行缓存
- if self.wordWrap and not self._autoW then
- local lines = wrap_text_lines(self.text or "", self.w, style)
- self.h = #lines * lh
- self._lines = lines
- else
- self.h = lh
- self._lines = nil
- end
- end
- function Label:draw()
- if not self.visible then return end
-
- local style = { size = self.size }
-
- -- 若指定自定义字体指针,走默认后端路径(不支持换行)
- if self.font and lcd and lcd.setFont then
- lcd.setFont(self.font)
- local lh = font_line_height(nil)
- lcd.drawStr(self.x, self.y + lh, self.text, self.color)
- return
- end
-
- -- 换行模式
- if self.wordWrap and not self._autoW then
- local lines = self._lines or wrap_text_lines(self.text, self.w, style)
- local lh = font_line_height(style)
- for i = 1, #lines do
- local yPos = self.y + (i - 1) * lh
- draw_text_direct(self.x, yPos, lines[i], { color = self.color, style = style })
- end
- return
- end
-
- -- 无换行模式:截断显示
- if not self._autoW then
- -- 有指定宽度限制,需要截断
- local displayText = self.text
- local tw = font_measure(displayText, style)
- if tw > self.w then
- -- 逐字符截断,直到宽度合适
- local truncated = ""
- local i = 1
- while i <= #displayText do
- local char, charLen = get_utf8_char(displayText, i)
- local testText = truncated .. char
- if font_measure(testText, style) <= self.w then
- truncated = testText
- i = i + charLen
- else
- break
- end
- end
- displayText = truncated
- end
- draw_text_direct(self.x, self.y, displayText, { color = self.color, style = style })
- else
- -- 自动宽度,直接显示
- draw_text_direct(self.x, self.y, self.text, { color = self.color, style = style })
- end
- end
- function Label:handleEvent(evt, x, y)
- return false -- 文本不拦截事件
- end
- -- Picture组件 - 显示单图或轮播多图
- local Picture = {}
- Picture.__index = Picture
- function Picture: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 80
- o.h = opts.h or 80
- 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.visible = opts.visible ~= false
- o.enabled = opts.enabled ~= false
- o._imageCache = {} -- v1.6.1:缓存图片加载状态,避免重复检查和重复打印警告
- return o
- end
- function Picture:setSources(list)
- self.sources = list
- self.index = 1
- end
- function Picture:next()
- if self.sources and #self.sources > 0 then
- self.index = self.index % #self.sources + 1
- end
- end
- function Picture:prev()
- if self.sources and #self.sources > 0 then
- self.index = (self.index - 2) % #self.sources + 1
- end
- end
- function Picture:play()
- self.autoplay = true
- end
- function Picture:pause()
- self.autoplay = false
- end
- function Picture:draw()
- if not self.visible then return end
- -- 自动轮播
- if self.autoplay and self.sources and #self.sources > 1 then
- local t = now_ms()
- if (t - self._last_switch) >= self.interval then
- self:next()
- self._last_switch = t
- end
- end
- -- 选择当前图片路径
- local path
- if self.sources and #self.sources > 0 then
- path = self.sources[self.index]
- else
- path = self.src
- end
-
- -- v1.6.1修复:检查文件是否存在,并改进占位符显示(使用缓存避免重复检查)
- if type(path) == "string" and path ~= "" and path:lower():sub(-4) == ".jpg" then
- -- 检查缓存
- if self._imageCache[path] == nil then
- -- 未缓存,首次检查文件是否存在
- if io and io.exists and io.exists(path) then
- self._imageCache[path] = true -- 缓存:文件存在
- else
- self._imageCache[path] = false -- 缓存:文件不存在
- log.warn("Picture", "图片文件不存在:", path)
- end
- end
-
- -- 根据缓存状态处理
- if self._imageCache[path] == true then
- lcd.showImage(self.x, self.y, path)
- else
- -- 文件不存在(已缓存),显示占位符
- draw_image_placeholder(self.x, self.y, self.w, self.h, 0x4208, COLOR_WHITE)
- end
- elseif path then
- -- path不是jpg或无效路径,显示占位符
- draw_image_placeholder(self.x, self.y, self.w, self.h, 0x4208, COLOR_WHITE)
- end
- -- 如果path为nil,不显示任何内容(不绘制占位符)
- end
- function Picture:handleEvent(evt, x, y)
- return false -- 默认不消费事件
- end
- -- MessageBox组件 - 消息框,包含标题、文本和按钮组
- local MessageBox = {}
- MessageBox.__index = MessageBox
- function MessageBox:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- o.x = opts.x or 20
- o.y = opts.y or 40
- o.w = opts.width or opts.w or 280
- o.h = opts.height or opts.h or 160
- o.title = opts.title or "Info"
- o.message = opts.message or ""
- o.wordWrap = opts.wordWrap ~= false -- v1.6.1修复:默认启用自动换行,除非显式传入false
- o.textSize = opts.textSize or opts.size -- 文本字号
- local dark = (current_theme == "dark")
- o.borderColor = opts.borderColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.textColor = opts.textColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.bgColor = opts.bgColor or (dark and COLOR_BLACK or COLOR_WHITE)
- o.buttons = opts.buttons or { "OK" }
- o.onResult = opts.onResult
- o.visible = opts.visible ~= false -- v1.6.1修复:支持从opts读取visible参数,默认true
- o.enabled = opts.enabled ~= false -- v1.6.1修复:支持从opts读取enabled参数,默认true
- -- 内部按钮布局
- o._btns = {}
- local btn_w = 80
- local gap = 12
- local total_w = #o.buttons * btn_w + (#o.buttons - 1) * gap
- local bx = o.x + (o.w - total_w) // 2
- local by = o.y + o.h - 12 - 36
- for i = 1, #o.buttons do
- local label = o.buttons[i]
- local b = Button:new({ x = bx, y = by, w = btn_w, h = 36, text = label })
- b.onClick = function()
- if o.onResult then o.onResult(label) end
- o.visible = false
- -- v1.6.1修复:不再禁用enabled,允许MessageBox复用
- end
- o._btns[#o._btns + 1] = b
- bx = bx + btn_w + gap
- end
-
- -- 计算message文本可用区域
- o._msgPadding = 10 -- 左右内边距
- o._msgMaxWidth = o.w - o._msgPadding * 2
- o._msgStartY = 36 -- message文本起始Y(相对于MessageBox)
- -- v1.6.1修复:根据是否有按钮动态计算可用高度
- if #o.buttons > 0 then
- o._msgMaxHeight = o.h - 12 - 36 - o._msgStartY -- 有按钮:预留底部边距12 + 按钮高度36
- else
- o._msgMaxHeight = o.h - 10 - o._msgStartY -- 无按钮:只保留底部边距10
- end
-
- return o
- end
- function MessageBox:draw()
- if not self.visible then return end
- fill_rect(self.x, self.y, self.x + self.w - 1, self.y + self.h - 1, self.bgColor)
- stroke_rect(self.x, self.y, self.x + self.w - 1, self.y + self.h - 1, self.borderColor)
-
- -- 绘制标题
- draw_text_direct(self.x + 10, self.y + 8, self.title, { color = self.textColor, style = { size = self.textSize } })
-
- -- 绘制message文本
- local msgX = self.x + self._msgPadding
- local msgY = self.y + self._msgStartY
- local style = { size = self.textSize }
-
- if self.wordWrap then
- -- 换行模式:在固定高度内显示多行,超出截断
- local lines = wrap_text_lines(self.message, self._msgMaxWidth, style)
- local lh = font_line_height(style)
- local maxLines = math.floor(self._msgMaxHeight / lh)
-
- for i = 1, math.min(#lines, maxLines) do
- local yPos = msgY + (i - 1) * lh
- draw_text_direct(msgX, yPos, lines[i], { color = self.textColor, style = style })
- end
- else
- -- 无换行模式:单行显示
- draw_text_direct(msgX, msgY, self.message, { color = self.textColor, style = style })
- end
-
- -- 绘制按钮
- for i = 1, #self._btns do
- self._btns[i]:draw()
- end
- end
- function MessageBox:handleEvent(evt, x, y)
- if not self.enabled then return false end
- for i = 1, #self._btns do
- local b = self._btns[i]
- if hit_test(x, y, { x = b.x, y = b.y, w = b.w, h = b.h }) then
- return b:handleEvent(evt, x, y)
- end
- end
- return true -- 拦截其它事件
- end
- -- v1.6.1新增:MessageBox复用方法
- function MessageBox:show()
- self.visible = true
- self.enabled = true
- end
- function MessageBox:hide()
- self.visible = false
- end
- function MessageBox:setTitle(title)
- self.title = tostring(title or "")
- end
- function MessageBox:setMessage(message)
- self.message = tostring(message or "")
- -- 如果启用了换行,更新行缓存
- if self.wordWrap then
- local style = { size = self.textSize }
- self._lines = wrap_text_lines(self.message, self._msgMaxWidth, style)
- end
- end
- -- Window组件 - 窗口容器,支持子组件管理和子页面导航
- local Window = {}
- Window.__index = Window
- function Window:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- local sw, sh = lcd.getSize()
- o.x = opts.x or 0
- o.y = opts.y or 0
- o.w = opts.w or sw
- o.h = opts.h or sh
- o.backgroundImage = opts.backgroundImage
- local dark = (current_theme == "dark")
- o.backgroundColor = opts.backgroundColor or (dark and COLOR_WIN11_DARK_DIALOG_BG or COLOR_WIN11_LIGHT_DIALOG_BG)
- o.children = {}
- o.visible = opts.visible ~= false
- o.enabled = opts.enabled ~= false
- o._managed = nil
- o:enableSubpageManager()
- -- 滚动配置(0.1 版:纵向/横向)
- o._scroll = nil
- return o
- end
- function Window:add(child)
- self.children[#self.children + 1] = child
- end
- function Window:remove(child)
- for i = #self.children, 1, -1 do
- if self.children[i] == child then
- table.remove(self.children, i)
- return true
- end
- end
- return false
- end
- function Window:clear()
- self.children = {}
- end
- function Window:setBackgroundImage(path)
- self.backgroundImage = path
- end
- function Window:setBackgroundColor(color)
- self.backgroundColor = color
- self.backgroundImage = nil
- end
- function Window:draw()
- -- 背景
- if self.backgroundImage then
- lcd.showImage(self.x, self.y, self.backgroundImage)
- else
- lcd.fill(self.x, self.y, self.x + self.w, self.y + self.h, self.backgroundColor)
- end
- -- 子组件
- local offX, offY = 0, 0
- if self._scroll and self._scroll.enabled then
- if self._scroll.direction == "vertical" then
- offY = self._scroll.offsetY or 0
- elseif self._scroll.direction == "horizontal" then
- offX = self._scroll.offsetX or 0
- elseif self._scroll.direction == "both" then
- offX = self._scroll.offsetX or 0
- offY = self._scroll.offsetY or 0
- end
- end
- for i = 1, #self.children do
- local c = self.children[i]
- if c and c.visible ~= false and c.draw then
- local ox, oy = c.x, c.y
- if self._scroll and self._scroll.enabled then c.x = ox + offX c.y = oy + offY end
- c:draw()
- if self._scroll and self._scroll.enabled then c.x, c.y = ox, oy end
- end
- end
- end
- function Window:handleEvent(evt, x, y)
- if not self.enabled then return false end
- if not hit_test(x, y, { x = self.x, y = self.y, w = self.w, h = self.h }) then return false end
- -- 简易滚动(0.1):vertical/horizontal
- if self._scroll and self._scroll.enabled then
- local sc = self._scroll
- local contentW = sc.contentWidth or self.w
- local contentH = sc.contentHeight or self.h
- local minX = math.min(0, self.w - (contentW or self.w))
- local maxX = 0
- local minY = math.min(0, self.h - (contentH or self.h))
- local maxY = 0
- if evt == "TOUCH_DOWN" then
- sc.startX = x
- sc.startY = y
- sc.baseOffsetX = sc.offsetX or 0
- sc.baseOffsetY = sc.offsetY or 0
- sc.dragging = false
- sc.captured = false
- -- 透传按下给命中的子组件,便于组件进入按下态;若后续进入拖拽会被取消
- local tx = x - (sc.offsetX or 0)
- local ty = y - (sc.offsetY or 0)
- sc.downTarget = nil
- for i = #self.children, 1, -1 do
- local c = self.children[i]
- if c and c.enabled ~= false and c.handleEvent and
- hit_test(tx, ty, { x = c.x, y = c.y, w = c.w, h = c.h }) then
- sc.downTarget = c
- c:handleEvent("TOUCH_DOWN", tx, ty)
- break
- end
- end
- return true
- elseif evt == "MOVE_Y" or evt == "MOVE_X" then
- local dx = (x - (sc.startX or x))
- local dy = (y - (sc.startY or y))
- if not sc.dragging then
- local m = math.max(math.abs(dx), math.abs(dy))
- if m >= (sc.threshold or 10) then
- sc.dragging = true
- sc.captured = true
- -- 进入拖拽,取消先前按下态
- if sc.downTarget and sc.downTarget.handleEvent then
- local tx = x - (sc.offsetX or 0)
- local ty = y - (sc.offsetY or 0)
- sc.downTarget:handleEvent("LONG_PRESS", tx, ty)
- end
- sc.downTarget = nil
- else
- -- v1.6.1修复:未达拖拽阈值时(观望期),转发MOVE给downTarget让其实时更新状态
- if sc.downTarget and sc.downTarget.handleEvent then
- local tx = x - (sc.offsetX or 0)
- local ty = y - (sc.offsetY or 0)
- sc.downTarget:handleEvent(evt, tx, ty)
- end
- end
- end
- if sc.dragging then
- local nx = sc.baseOffsetX + dx
- local ny = sc.baseOffsetY + dy
- if sc.direction == "vertical" then
- if ny < minY then ny = minY end
- if ny > maxY then ny = maxY end
- sc.offsetY = ny
- elseif sc.direction == "horizontal" then
- if nx < minX then nx = minX end
- if nx > maxX then nx = maxX end
- sc.offsetX = nx
- else -- both
- if nx < minX then nx = minX end
- if nx > maxX then nx = maxX end
- if ny < minY then ny = minY end
- if ny > maxY then ny = maxY end
- sc.offsetX, sc.offsetY = nx, ny
- end
- return true
- end
- return true
- elseif evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
- if sc.dragging then
- -- 滑动期间禁用点击;若启用分页,则在抬手时做“就近吸附”(无论是否触发 SWIPE)
- if sc.pagingEnabled and (sc.direction == "horizontal" or sc.direction == "both") then
- local pageW = sc.pageWidth or self.w
- local totalW = sc.contentWidth or self.w
- local pages = math.max(1, math.floor((totalW + pageW - 1) / pageW))
- local cur = math.floor((-(sc.offsetX or 0) + pageW / 2) / pageW)
- if cur < 0 then cur = 0 end
- if cur > pages - 1 then cur = pages - 1 end
- sc.offsetX = -cur * pageW
- end
- sc.dragging = false
- sc.captured = false
- sc.downTarget = nil
- return true
- end
- -- 未拖拽:将事件分发给子组件(坐标转内容坐标)
- local tx = x - (sc.offsetX or 0)
- local ty = y - (sc.offsetY or 0)
- for i = #self.children, 1, -1 do
- local c = self.children[i]
- if c and c.enabled ~= false and c.handleEvent and
- hit_test(tx, ty, { x = c.x, y = c.y, w = c.w, h = c.h }) then
- if c:handleEvent(evt, tx, ty) then sc.downTarget = nil return true end
- end
- end
- sc.downTarget = nil
- return true
- elseif evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
- -- 抬手后的滑动手势:结束拖拽,并在需要时做分页吸附(仅横向)
- sc.dragging = false
- sc.captured = false
- if sc.pagingEnabled and (sc.direction == "horizontal" or sc.direction == "both") then
- local pageW = sc.pageWidth or self.w
- local totalW = sc.contentWidth or self.w
- local pages = math.max(1, math.floor((totalW + pageW - 1) / pageW))
- -- 当前页(offsetX 为负值向右移动内容)
- local cur = math.floor((-(sc.offsetX or 0) + pageW / 2) / pageW)
- if evt == "SWIPE_LEFT" then cur = cur + 1 elseif evt == "SWIPE_RIGHT" then cur = cur - 1 end
- if cur < 0 then cur = 0 end
- if cur > pages - 1 then cur = pages - 1 end
- sc.offsetX = -cur * pageW
- end
- return true
- else
- -- 其他事件(如 MOVE_X/SWIPE_*)在 0.1 版忽略或按需拦截
- return true
- end
- end
- -- 非滚动窗口:正常分发
- for i = #self.children, 1, -1 do
- local c = self.children[i]
- -- v1.6.1修复:添加_capture检查,让已捕获的组件(如按下的Button)能收到移出范围的MOVE事件
- if c and c.enabled ~= false and c.handleEvent and
- (c._capture == true or hit_test(x, y, { x = c.x, y = c.y, w = c.w, h = c.h })) then
- if c:handleEvent(evt, x, y) then return true end
- end
- end
- return true -- 拦截窗口区域内未被子组件消费的事件
- end
- -- 启用简易滚动(0.1)
- function Window:enableScroll(opts)
- opts = opts or {}
- self._scroll = {
- enabled = true,
- direction = opts.direction or "vertical",
- contentWidth = tonumber(opts.contentWidth or self.w) or self.w,
- contentHeight = tonumber(opts.contentHeight or self.h) or self.h,
- offsetX = 0,
- offsetY = 0,
- threshold = tonumber(opts.threshold or 10) or 10,
- pagingEnabled = not not opts.pagingEnabled,
- pageWidth = tonumber(opts.pageWidth or self.w) or self.w,
- dragging = false,
- captured = false,
- }
- return self
- end
- function Window:setContentSize(w, h)
- if not self._scroll then return end
- if w then self._scroll.contentWidth = tonumber(w) or self._scroll.contentWidth end
- if h then self._scroll.contentHeight = tonumber(h) or self._scroll.contentHeight end
- end
- -- 启用子页面管理
- function Window:enableSubpageManager(opts)
- opts = opts or {}
- if not self._managed then
- self._managed = {
- pages = {},
- backEventName = opts.backEventName or "NAV.BACK",
- onBack = opts.onBack
- }
- sys.subscribe(self._managed.backEventName, function()
- if self._managed.onBack then pcall(self._managed.onBack) 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
- end
- end)
- end
- return self
- end
- -- 配置子页面工厂
- function Window:configureSubpages(factories)
- if not self._managed then self:enableSubpageManager() 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:showSubpage(name, factory)
- if not self._managed then error("enableSubpageManager must be called before showSubpage") end
- for key, pg in pairs(self._managed.pages) do
- if pg and pg.visible ~= false then
- pg.visible = false
- pg.enabled = false
- 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
- core.add(self._managed.pages[name])
- end
- self.visible = false
- self._managed.pages[name].visible = true
- self._managed.pages[name].enabled = true
- end
- -- 返回上级页面
- function Window:back()
- if self._parentWindow then
- self.visible = false
- self.enabled = false
- 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
- end
- end
- end
- -- 关闭子页面
- function Window:closeSubpage(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
- if opts.destroy == true then
- core.remove(pg)
- self._managed.pages[name] = nil
- collectgarbage("collect")
- 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
- end
- return true
- end
- -- ProgressBar组件 - 进度条,支持百分比显示和主题适配
- local ProgressBar = {}
- ProgressBar.__index = ProgressBar
- function ProgressBar:new(opts)
- opts = opts or {}
- local o = setmetatable({}, self)
- o.x = opts.x or 0
- o.y = opts.y or 0
- o.w = opts.width or opts.w or 200
- o.h = opts.height or opts.h or 24
- o.progress = math.max(0, math.min(100, opts.progress or 0))
- o.showPercentage = opts.showPercentage ~= false
- o.text = opts.text
- o.textSize = opts.textSize or opts.size
- local dark = (current_theme == "dark")
- o.backgroundColor = opts.backgroundColor or (dark and COLOR_GRAY or 0xC618)
- o.progressColor = opts.progressColor or (dark and COLOR_BLUE or 0x001F)
- o.borderColor = opts.borderColor or (dark and COLOR_WHITE or 0x8410)
- o.textColor = opts.textColor or (dark and COLOR_WHITE or COLOR_BLACK)
- o.visible = opts.visible ~= false
- o.enabled = opts.enabled ~= false
- return o
- end
- function ProgressBar:setProgress(value)
- self.progress = math.max(0, math.min(100, value))
- end
- function ProgressBar:getProgress()
- return self.progress
- end
- function ProgressBar:setText(text)
- self.text = text
- end
- function ProgressBar:draw()
- if not self.visible then return end
-
- -- 绘制背景(可选:只绘制内区,避免与边框重复像素)
- local padding = 1
- fill_rect(self.x + padding, self.y + padding, self.x + self.w - padding, self.y + self.h - padding, self.backgroundColor)
-
- -- 绘制边框
- stroke_rect(self.x, self.y, self.x + self.w, self.y + self.h, self.borderColor)
-
- -- 计算进度条填充
- padding = 1
- local inner_left = self.x + padding
- local inner_top = self.y + padding
- local inner_right = self.x + self.w - padding -- 包含式
- local inner_bottom = self.y + self.h - padding -- 包含式
- local inner_width = inner_right - inner_left
- local fill_width = math.floor(inner_width * (self.progress / 100))
- if fill_width > 0 then
- local x1 = inner_left
- local x2 = inner_left + fill_width
- fill_rect(x1, inner_top, x2, inner_bottom, self.progressColor)
- end
-
- -- 绘制文本
- if self.showPercentage or self.text then
- local display_text = self.text or (self.progress .. "%")
- draw_text_in_rect_centered(self.x, self.y, self.w, self.h, display_text, {
- color = self.textColor,
- style = { size = self.textSize },
- padding = 2
- })
- end
- end
- function ProgressBar:handleEvent(evt, x, y)
- return false -- 进度条默认不处理触摸事件
- end
- -- ================================
- -- 主模块导出
- -- ================================
- local M = {}
- -- 核心API导出
- M.init = core.init
- M.add = core.add
- M.remove = core.remove
- M.clear = core.clear
- M.render = core.render
- M.handleTouchEvent = core.handleTouchEvent
- M.debug = core.debug
- M.getTheme = core.getTheme
- -- 硬件支持
- M.hw = hw
- -- 事件系统
- M.event = event
- -- 组件构造函数
- M.Button = function(opts) return Button:new(opts) end
- M.CheckBox = function(opts) return CheckBox:new(opts) end
- M.Label = function(opts) return Label:new(opts) end
- M.Picture = function(opts) return Picture:new(opts) end
- M.MessageBox = function(opts) return MessageBox:new(opts) end
- M.Window = function(opts) return Window:new(opts) end
- M.ProgressBar = function(opts) return ProgressBar:new(opts) end
- -- 字体 API 导出
- M.font = {
- set = function(style) return font_set(style) end,
- measure = function(text, style) return font_measure(text, style) end,
- lineHeight = function(style) return font_line_height(style) end
- }
- -- 对外导出常用颜色常量,便于在业务侧直接使用
- M.COLOR_WHITE = COLOR_WHITE
- M.COLOR_BLACK = COLOR_BLACK
- M.COLOR_GRAY = COLOR_GRAY
- M.COLOR_BLUE = COLOR_BLUE
- M.COLOR_RED = COLOR_RED
- M.COLOR_GREEN = COLOR_GREEN
- M.COLOR_YELLOW = COLOR_YELLOW
- M.COLOR_CYAN = COLOR_CYAN
- M.COLOR_MAGENTA = COLOR_MAGENTA
- M.COLOR_ORANGE = COLOR_ORANGE
- M.COLOR_PINK = COLOR_PINK
- -- Windows 11 Light模式颜色
- M.COLOR_WIN11_LIGHT_DIALOG_BG = COLOR_WIN11_LIGHT_DIALOG_BG
- M.COLOR_WIN11_LIGHT_BUTTON_BG = COLOR_WIN11_LIGHT_BUTTON_BG
- M.COLOR_WIN11_LIGHT_BUTTON_BORDER = COLOR_WIN11_LIGHT_BUTTON_BORDER
- -- Windows 11 Dark模式颜色
- M.COLOR_WIN11_DARK_DIALOG_BG = COLOR_WIN11_DARK_DIALOG_BG
- M.COLOR_WIN11_DARK_BUTTON_BG = COLOR_WIN11_DARK_BUTTON_BG
- M.COLOR_WIN11_DARK_BUTTON_BORDER = COLOR_WIN11_DARK_BUTTON_BORDER
- return M
|