exeasyui.lua 128 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920
  1. --[[
  2. exEasyUI v1.7.0 - 新架构发布版
  3. ================================
  4. 结构说明:
  5. 1. 常量定义 - UI颜色常量和调试配置
  6. 2. 硬件依赖 - LCD/TP 初始化、字体后端装配
  7. 3. 核心部分
  8. 3.1 渲染子系统
  9. 3.2 运行时与事件系统
  10. 3.3 调试模块
  11. 3.4 Widget 基类
  12. 4. 工具函数 - 绘图工具、字体工具、文本处理工具
  13. 5. 组件部分 - 组件按名称拼音排序,组件列表:
  14. button、
  15. check_box、
  16. combo_box、
  17. input、
  18. keyboard、
  19. label、
  20. message_box、
  21. picture、
  22. progress_bar、
  23. window、
  24. 6. 对外接口导出
  25. ]]
  26. local ui = {
  27. version = "1.7.0",
  28. hw = {},
  29. runtime = {},
  30. render = {},
  31. widget = {},
  32. debug = {}
  33. }
  34. -- 依赖模块(由 LuatOS 侧提供)
  35. local exlcd = require "exlcd"
  36. local extp = require "extp"
  37. local lcd = lcd
  38. local spi = spi
  39. local gtfont = gtfont
  40. local hzfont = hzfont or rawget(_G, "hzfont")
  41. -- 前置声明(便于分段组织)
  42. local clamp
  43. local now_ms
  44. local fill_rect
  45. local stroke_rect
  46. local font_line_height
  47. local font_measure
  48. local font_draw
  49. local Canvas
  50. local canvas
  51. local handle_debug_stats
  52. local render_state
  53. -- 运行时表提前声明,便于硬件模块引用
  54. local runtime = {
  55. roots = {},
  56. pointer_capture = nil,
  57. input_bound = false,
  58. last_pointer = { x = 0, y = 0 },
  59. touch_anchor_x = 0,
  60. touch_anchor_y = 0
  61. }
  62. ui.runtime = runtime
  63. -- 调试状态表提前声明
  64. local debug_state = {
  65. enabled = false,
  66. last_stats = nil,
  67. last_log_ms = 0,
  68. accum_frame_ms = 0,
  69. accum_start_ms = 0,
  70. timer_id = nil
  71. }
  72. -- ================================
  73. -- 1. 常量定义
  74. -- ================================
  75. local COLOR_WHITE = 0xFFFF
  76. local COLOR_BLACK = 0x0000
  77. local COLOR_GRAY = 0x8410
  78. local COLOR_BLUE = 0x001F
  79. local COLOR_RED = 0xF800
  80. local COLOR_GREEN = 0x07E0
  81. local COLOR_YELLOW = 0xFFE0
  82. local COLOR_CYAN = 0x07FF
  83. local COLOR_MAGENTA = 0xF81F
  84. local COLOR_ORANGE = 0xFC00
  85. local COLOR_PINK = 0xF81F
  86. local COLOR_SKY_BLUE = 0x65BE
  87. local COLOR_WIN11_LIGHT_DIALOG_BG = 0xF79E
  88. local COLOR_WIN11_LIGHT_BUTTON_BG = 0xFFDF
  89. local COLOR_WIN11_LIGHT_BUTTON_BORDER = 0xE73C
  90. local COLOR_WIN11_DARK_DIALOG_BG = 0x2104
  91. local COLOR_WIN11_DARK_BUTTON_BG = 0x3186
  92. local COLOR_WIN11_DARK_BUTTON_BORDER = 0x4A69
  93. ui.COLOR_WHITE = COLOR_WHITE
  94. ui.COLOR_BLACK = COLOR_BLACK
  95. ui.COLOR_GRAY = COLOR_GRAY
  96. ui.COLOR_BLUE = COLOR_BLUE
  97. ui.COLOR_RED = COLOR_RED
  98. ui.COLOR_GREEN = COLOR_GREEN
  99. ui.COLOR_YELLOW = COLOR_YELLOW
  100. ui.COLOR_CYAN = COLOR_CYAN
  101. ui.COLOR_MAGENTA = COLOR_MAGENTA
  102. ui.COLOR_ORANGE = COLOR_ORANGE
  103. ui.COLOR_PINK = COLOR_PINK
  104. ui.COLOR_SKY_BLUE = COLOR_SKY_BLUE
  105. ui.COLOR_WIN11_LIGHT_DIALOG_BG = COLOR_WIN11_LIGHT_DIALOG_BG
  106. ui.COLOR_WIN11_LIGHT_BUTTON_BG = COLOR_WIN11_LIGHT_BUTTON_BG
  107. ui.COLOR_WIN11_LIGHT_BUTTON_BORDER = COLOR_WIN11_LIGHT_BUTTON_BORDER
  108. ui.COLOR_WIN11_DARK_DIALOG_BG = COLOR_WIN11_DARK_DIALOG_BG
  109. ui.COLOR_WIN11_DARK_BUTTON_BG = COLOR_WIN11_DARK_BUTTON_BG
  110. ui.COLOR_WIN11_DARK_BUTTON_BORDER = COLOR_WIN11_DARK_BUTTON_BORDER
  111. local DEBUG_LOG_INTERVAL_MS = 1000
  112. local current_theme = "light"
  113. gtfont_dev = gtfont_dev or nil
  114. local FontAdapter = {
  115. _backend = "default",
  116. _size = 12,
  117. _gray = false,
  118. _name = nil,
  119. _hz_antialias = -1
  120. }
  121. -- ================================
  122. -- 2. 硬件依赖
  123. -- ================================
  124. local function configure_font_backend(opts)
  125. opts = opts or {}
  126. local function fallback_default()
  127. FontAdapter._backend = "default"
  128. FontAdapter._size = 12
  129. FontAdapter._gray = false
  130. FontAdapter._name = opts.name
  131. FontAdapter._hz_antialias = -1
  132. if lcd and lcd.setFont and lcd.font_opposansm12_chinese then
  133. lcd.setFont(lcd.font_opposansm12_chinese)
  134. log.info("exEasyUI", "使用默认的font_opposansm12_chinese字体")
  135. else
  136. log.warn("exEasyUI", "该固件不支持默认的font_opposansm12_chinese字体,将没有中文支持,请更换支持该字体的固件 ")
  137. end
  138. end
  139. if opts.type == "gtfont" and gtfont and spi then
  140. local spi_id = (opts.spi and opts.spi.id) or 0
  141. local spi_cs = (opts.spi and opts.spi.cs) or 8
  142. local spi_clk = 20 * 1000 * 1000
  143. gtfont_dev = spi.deviceSetup(spi_id, spi_cs, 0, 0, 8, spi_clk, spi.MSB, 1, 0)
  144. local ok = gtfont_dev and gtfont.init(gtfont_dev)
  145. if ok then
  146. FontAdapter._backend = "gtfont"
  147. FontAdapter._size = tonumber(opts.size or 16)
  148. FontAdapter._gray = false
  149. log.info("exEasyUI", "gtfont enabled", spi_id, spi_cs, FontAdapter._size)
  150. return
  151. else
  152. log.warn("exEasyUI", "gtfont init failed, fallback")
  153. end
  154. elseif opts.type == "hzfont" and hzfont then
  155. local cache_size = tonumber(opts.cache_size) or 256
  156. cache_size = (cache_size == 128 or cache_size == 256 or cache_size == 512 or cache_size == 1024 or cache_size == 2048) and
  157. cache_size or 256
  158. local ok = hzfont.init(opts.path, cache_size)
  159. if ok then
  160. FontAdapter._backend = "hzfont"
  161. FontAdapter._size = tonumber(opts.size or 16)
  162. local aa = tonumber(opts.antialias or -1) or -1
  163. if not (aa == -1 or aa == 1 or aa == 2 or aa == 4) then
  164. aa = -1
  165. end
  166. FontAdapter._hz_antialias = aa
  167. log.info("exEasyUI", "hzfont enabled", opts.path or "builtin", FontAdapter._size)
  168. return
  169. else
  170. log.warn("exEasyUI", "hzfont init failed, fallback")
  171. end
  172. end
  173. fallback_default()
  174. end
  175. function ui.hw_init(opts)
  176. if not opts then
  177. log.error("ui.hw_init", "opts is nil")
  178. return false
  179. end
  180. local lcd_ok = exlcd.init(opts.lcd_config)
  181. if not lcd_ok then
  182. log.error("exEasyUI", "LCD init failed")
  183. return false
  184. end
  185. if opts.enable_buffer ~= false and lcd and lcd.setupBuff then
  186. lcd.setupBuff(nil, true)
  187. log.info("exEasyUI", "framebuffer enabled")
  188. end
  189. if lcd and lcd.autoFlush then
  190. lcd.autoFlush(false)
  191. end
  192. if lcd and lcd.setAcchw and lcd.ACC_HW_JPEG then
  193. lcd.setAcchw(lcd.ACC_HW_JPEG, opts.enable_hardware_decode and true or false)
  194. end
  195. -- 初始化触摸IC
  196. -- 使用配置表中的参数初始化触摸
  197. local tp_config = opts.tp_config
  198. extp.init(tp_config)
  199. -- 设置消息发布状态
  200. if tp_config and tp_config.message_enabled then
  201. if type(tp_config.message_enabled) == "table" then
  202. for msg_type, enabled in pairs(tp_config.message_enabled) do
  203. if type(msg_type) == "string" and type(enabled) == "boolean" then
  204. local success = extp.set_publish_enabled(msg_type, enabled)
  205. if not success then
  206. log.warn("exEasyUI", "设置消息发布状态失败:", msg_type, enabled)
  207. end
  208. end
  209. end
  210. elseif type(tp_config.message_enabled) == "string" then
  211. local success = extp.set_publish_enabled(tp_config.message_enabled, true)
  212. if not success then
  213. log.warn("exEasyUI", "设置消息发布状态失败:", tp_config.message_enabled)
  214. end
  215. end
  216. end
  217. -- 设置滑动阈值
  218. if tp_config and tp_config.swipe_threshold then
  219. if type(tp_config.swipe_threshold) == "number" and tp_config.swipe_threshold > 0 then
  220. extp.set_swipe_threshold(tp_config.swipe_threshold)
  221. end
  222. end
  223. -- 设置长按阈值
  224. if tp_config and tp_config.long_press_threshold then
  225. if type(tp_config.long_press_threshold) == "number" and tp_config.long_press_threshold > 0 then
  226. extp.set_long_press_threshold(tp_config.long_press_threshold)
  227. end
  228. end
  229. runtime.bindInput()
  230. local width, height
  231. if opts.lcd_config and opts.lcd_config.w then
  232. width = (opts.lcd_config and opts.lcd_config.w) or (render_state and render_state.viewport_w)
  233. height = (opts.lcd_config and opts.lcd_config.h) or (render_state and render_state.viewport_h)
  234. else
  235. width, height = lcd.getSize()
  236. end
  237. -- 设定字体依赖
  238. configure_font_backend(opts.font_config or {})
  239. ui.render.set_viewport(width, height)
  240. return true
  241. end
  242. -- ================================
  243. -- 3. 核心部分
  244. -- ================================
  245. -- 3.1 渲染子系统
  246. render_state = {
  247. dirty_regions = {},
  248. full_refresh = true,
  249. need_present = false,
  250. viewport_w = 320,
  251. viewport_h = 240,
  252. clear_color = COLOR_BLACK
  253. }
  254. ui.render.set_viewport = function(w, h)
  255. if w then render_state.viewport_w = w end
  256. if h then render_state.viewport_h = h end
  257. end
  258. ui.render.background = function(color)
  259. render_state.clear_color = color or COLOR_BLACK
  260. render_state.full_refresh = true
  261. render_state.need_present = true
  262. end
  263. ui.render.invalidate = function(rect)
  264. render_state.need_present = true
  265. if not rect then
  266. render_state.full_refresh = true
  267. return
  268. end
  269. render_state.dirty_regions[#render_state.dirty_regions + 1] = rect
  270. end
  271. local function accumulate_dirty_y_range()
  272. if render_state.full_refresh then
  273. return 0, render_state.viewport_h - 1
  274. end
  275. if #render_state.dirty_regions == 0 then
  276. return nil, nil
  277. end
  278. local min_y = render_state.viewport_h
  279. local max_y = 0
  280. for i = 1, #render_state.dirty_regions do
  281. local r = render_state.dirty_regions[i]
  282. local rmin = clamp(r.y, 0, render_state.viewport_h - 1)
  283. local rmax = clamp(r.y + r.h - 1, 0, render_state.viewport_h - 1)
  284. if rmin < min_y then min_y = rmin end
  285. if rmax > max_y then max_y = rmax end
  286. end
  287. if min_y > max_y then
  288. return nil, nil
  289. end
  290. return min_y, max_y
  291. end
  292. local function reset_dirty_state()
  293. render_state.dirty_regions = {}
  294. render_state.full_refresh = false
  295. render_state.need_present = false
  296. end
  297. local function draw_widget_tree(widget, dirty, stats)
  298. if not widget.visible then return end
  299. if stats then stats.widgets = stats.widgets + 1 end
  300. if widget.draw then
  301. widget:draw(canvas, dirty)
  302. end
  303. if widget.children then
  304. for i = 1, #widget.children do
  305. draw_widget_tree(widget.children[i], dirty, stats)
  306. end
  307. end
  308. end
  309. local function render_frame()
  310. if not render_state.need_present then
  311. if lcd and lcd.flush then
  312. lcd.flush()
  313. end
  314. return false
  315. end
  316. local start_ms = now_ms()
  317. local y_min, y_max = accumulate_dirty_y_range()
  318. if not y_min then
  319. reset_dirty_state()
  320. return false
  321. end
  322. if render_state.full_refresh and canvas and canvas.clear then
  323. canvas:clear(render_state.clear_color)
  324. end
  325. local stats = {
  326. widgets = 0,
  327. dirty_span = (y_max and y_min) and (y_max - y_min + 1) or 0,
  328. full_refresh = render_state.full_refresh
  329. }
  330. local dirty_span = { y_min = y_min, y_max = y_max }
  331. for i = 1, #runtime.roots do
  332. draw_widget_tree(runtime.roots[i], dirty_span, stats)
  333. end
  334. stats.frame_ms = now_ms() - start_ms
  335. handle_debug_stats(stats)
  336. if lcd and lcd.flush then
  337. lcd.flush()
  338. end
  339. reset_dirty_state()
  340. return true
  341. end
  342. ui.render.present = render_frame
  343. -- 3.1.1 图片缓存管理器
  344. local image_cache = {
  345. _zbuff_cache = {}, -- 路径 -> zbuff 映射
  346. _loading = {}, -- 正在加载的路径集合(防止重复加载)
  347. _failed = {} -- 加载失败的路径集合(避免重复尝试)
  348. }
  349. -- 获取图片 zbuff(按需加载)
  350. function image_cache.get_zbuff(path)
  351. if not path or type(path) ~= "string" or path == "" then
  352. return nil
  353. end
  354. -- 检查是否已缓存
  355. if image_cache._zbuff_cache[path] then
  356. return image_cache._zbuff_cache[path]
  357. end
  358. -- 检查是否正在加载(防止重复加载)
  359. if image_cache._loading[path] then
  360. return nil
  361. end
  362. -- 检查是否已失败
  363. if image_cache._failed[path] then
  364. return nil
  365. end
  366. -- 检查文件是否存在
  367. if io and io.exists then
  368. if not io.exists(path) then
  369. image_cache._failed[path] = true
  370. log.warn("image_cache", "图片文件不存在", path)
  371. return nil
  372. end
  373. end
  374. -- 检查 lcd.image2raw 是否可用
  375. if not lcd or not lcd.image2raw then
  376. return nil
  377. end
  378. -- 标记为正在加载
  379. image_cache._loading[path] = true
  380. -- 解码图片
  381. local ok, zbuff = pcall(lcd.image2raw, path)
  382. image_cache._loading[path] = false
  383. if ok and zbuff then
  384. -- 缓存成功
  385. image_cache._zbuff_cache[path] = zbuff
  386. return zbuff
  387. else
  388. -- 解码失败
  389. image_cache._failed[path] = true
  390. log.warn("image_cache", "图片解码失败", path)
  391. return nil
  392. end
  393. end
  394. -- 预加载图片
  395. function image_cache.preload(path)
  396. if not path or type(path) ~= "string" or path == "" then
  397. return false
  398. end
  399. -- 如果已缓存,直接返回
  400. if image_cache._zbuff_cache[path] then
  401. return true
  402. end
  403. -- 尝试加载
  404. local zbuff = image_cache.get_zbuff(path)
  405. return zbuff ~= nil
  406. end
  407. -- 清除缓存
  408. function image_cache.clear(path)
  409. if path then
  410. -- 清除指定路径
  411. image_cache._zbuff_cache[path] = nil
  412. image_cache._loading[path] = nil
  413. image_cache._failed[path] = nil
  414. else
  415. -- 清除所有缓存
  416. image_cache._zbuff_cache = {}
  417. image_cache._loading = {}
  418. image_cache._failed = {}
  419. end
  420. end
  421. -- 检查缓存状态
  422. function image_cache.is_cached(path)
  423. if not path then return false end
  424. return image_cache._zbuff_cache[path] ~= nil
  425. end
  426. ui.image_cache = image_cache
  427. -- 3.2 运行时与事件系统
  428. local function dispatch_pointer(evt, a, b)
  429. for i = #runtime.roots, 1, -1 do
  430. local root = runtime.roots[i]
  431. if root:dispatch_pointer(evt, a, b) then
  432. return true
  433. end
  434. end
  435. return false
  436. end
  437. local function debug_touch_log(evt, rawA, rawB, cursorX, cursorY)
  438. if not debug_state.enabled then return end
  439. local function sval(v)
  440. if v == nil then return "nil" end
  441. return tostring(v)
  442. end
  443. -- log.info("exEasyUI.debug.tp", string.format("evt=%s raw=(%s,%s) cursor=(%s,%s)",
  444. -- tostring(evt or ""), sval(rawA), sval(rawB), sval(cursorX), sval(cursorY)))
  445. end
  446. local function handle_touch_event(evt, a, b)
  447. local rawA = a
  448. local rawB = b
  449. if evt == "TOUCH_DOWN" or evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
  450. runtime.last_pointer.x = tonumber(a) or 0
  451. runtime.last_pointer.y = tonumber(b) or 0
  452. runtime.touch_anchor_x = runtime.last_pointer.x
  453. runtime.touch_anchor_y = runtime.last_pointer.y
  454. debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
  455. return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
  456. end
  457. if evt == "MOVE_X" then
  458. local delta = tonumber(a) or 0
  459. if runtime.touch_anchor_x == nil then
  460. runtime.touch_anchor_x = runtime.last_pointer.x
  461. end
  462. runtime.last_pointer.x = (runtime.touch_anchor_x or 0) + delta
  463. debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
  464. return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
  465. elseif evt == "MOVE_Y" then
  466. local delta = tonumber(b) or 0
  467. if runtime.touch_anchor_y == nil then
  468. runtime.touch_anchor_y = runtime.last_pointer.y
  469. end
  470. runtime.last_pointer.y = (runtime.touch_anchor_y or 0) + delta
  471. debug_touch_log(evt, rawA, rawB, runtime.last_pointer.x, runtime.last_pointer.y)
  472. return dispatch_pointer(evt, runtime.last_pointer.x, runtime.last_pointer.y)
  473. else
  474. local ax = tonumber(a)
  475. local ay = tonumber(b)
  476. debug_touch_log(evt, rawA, rawB, ax, ay)
  477. return dispatch_pointer(evt, ax, ay)
  478. end
  479. end
  480. function runtime.bindInput()
  481. if runtime.input_bound then return end
  482. sys.subscribe("BASE_TOUCH_EVENT", handle_touch_event)
  483. runtime.input_bound = true
  484. end
  485. function runtime.add(widget)
  486. runtime.roots[#runtime.roots + 1] = widget
  487. widget.root = true
  488. if widget.on_mount then widget:on_mount() end
  489. widget:invalidate()
  490. return widget
  491. end
  492. function runtime.remove(widget)
  493. for i = #runtime.roots, 1, -1 do
  494. if runtime.roots[i] == widget then
  495. table.remove(runtime.roots, i)
  496. if widget.on_unmount then widget:on_unmount() end
  497. render_state.full_refresh = true
  498. render_state.need_present = true
  499. return true
  500. end
  501. end
  502. return false
  503. end
  504. local function debug_emit_summary()
  505. local total = debug_state.accum_frame_ms or 0
  506. local usage = (total / DEBUG_LOG_INTERVAL_MS) * 100
  507. log.info("exEasyUI.debug", string.format("最近1s绘制耗时=%.1fms 耗时占比=%.1f%%", total, usage))
  508. debug_state.accum_frame_ms = 0
  509. debug_state.accum_start_ms = now_ms()
  510. end
  511. local function debug_timer_tick()
  512. if not debug_state.enabled then return end
  513. debug_emit_summary()
  514. end
  515. handle_debug_stats = function(stats)
  516. debug_state.last_stats = stats
  517. if not debug_state.enabled then return end
  518. if stats and stats.frame_ms then
  519. log.info("exEasyUI.debug", string.format("单次绘制耗时=%.1fms 绘制组件=%d 脏区高度=%dpx 绘制方式=%s",
  520. stats.frame_ms or 0,
  521. stats.widgets or 0,
  522. stats.dirty_span or 0,
  523. stats.full_refresh and "全屏" or "局部"))
  524. debug_state.accum_frame_ms = (debug_state.accum_frame_ms or 0) + (stats.frame_ms or 0)
  525. end
  526. if not debug_state.timer_id then
  527. local now = now_ms()
  528. if debug_state.accum_start_ms == 0 then
  529. debug_state.accum_start_ms = now
  530. end
  531. local window_ms = DEBUG_LOG_INTERVAL_MS
  532. if now - debug_state.accum_start_ms >= window_ms then
  533. debug_emit_summary()
  534. end
  535. end
  536. end
  537. function ui.debug.enable(enabled)
  538. enabled = not not enabled
  539. if enabled and not debug_state.enabled then
  540. debug_state.enabled = true
  541. debug_state.accum_frame_ms = 0
  542. debug_state.accum_start_ms = now_ms()
  543. if sys and sys.timerLoopStart then
  544. debug_state.timer_id = sys.timerLoopStart(debug_timer_tick, DEBUG_LOG_INTERVAL_MS)
  545. end
  546. elseif (not enabled) and debug_state.enabled then
  547. debug_state.enabled = false
  548. if debug_state.timer_id and sys and sys.timerStop then
  549. sys.timerStop(debug_state.timer_id)
  550. end
  551. debug_state.timer_id = nil
  552. debug_state.accum_frame_ms = 0
  553. debug_state.accum_start_ms = 0
  554. end
  555. end
  556. function ui.debug.set_level(level)
  557. if level == "off" then
  558. ui.debug.enable(false)
  559. else
  560. ui.debug.enable(true)
  561. end
  562. end
  563. function ui.debug.get_stats()
  564. return debug_state.last_stats
  565. end
  566. setmetatable(ui.debug, {
  567. __call = function(_, enabled)
  568. ui.debug.enable(enabled)
  569. end
  570. })
  571. -- 3.4 Widget 基类
  572. local BaseWidget = {}
  573. BaseWidget.__index = BaseWidget
  574. function BaseWidget:new(opts)
  575. opts = opts or {}
  576. local o = setmetatable({}, self)
  577. o.x = opts.x or 0
  578. o.y = opts.y or 0
  579. o.w = opts.w or 0
  580. o.h = opts.h or 0
  581. o.visible = opts.visible ~= false
  582. o.enabled = opts.enabled ~= false
  583. o.children = {}
  584. o.theme = opts.theme
  585. return o
  586. end
  587. function BaseWidget:get_absolute_position()
  588. local x = self.x or 0
  589. local y = self.y or 0
  590. local parent = self.parent
  591. while parent do
  592. x = x + (parent.x or 0)
  593. y = y + (parent.y or 0)
  594. if parent._scroll then
  595. x = x + (parent._scroll.offset_x or 0)
  596. y = y + (parent._scroll.offset_y or 0)
  597. end
  598. parent = parent.parent
  599. end
  600. return x, y
  601. end
  602. function BaseWidget:add(child)
  603. self.children[#self.children + 1] = child
  604. child.parent = self
  605. child:invalidate()
  606. return child
  607. end
  608. function BaseWidget:get_bounds()
  609. local ax, ay = self:get_absolute_position()
  610. return { x = ax, y = ay, w = self.w, h = self.h }
  611. end
  612. function BaseWidget:invalidate(rect)
  613. local bounds = rect or self:get_bounds()
  614. ui.render.invalidate(bounds)
  615. end
  616. function BaseWidget:contains_point(px, py)
  617. local bounds = self:get_bounds()
  618. return px >= bounds.x and py >= bounds.y and
  619. px <= (bounds.x + bounds.w) and py <= (bounds.y + bounds.h)
  620. end
  621. function BaseWidget:handle_event()
  622. return false
  623. end
  624. function BaseWidget:dispatch_pointer(evt, x, y)
  625. if not self.visible or not self.enabled then
  626. return false
  627. end
  628. if self.children then
  629. for i = #self.children, 1, -1 do
  630. if self.children[i]:dispatch_pointer(evt, x, y) then
  631. return true
  632. end
  633. end
  634. end
  635. if self.handle_event ~= BaseWidget.handle_event then
  636. return self:handle_event(evt, x, y)
  637. end
  638. return false
  639. end
  640. ui.widget.Base = BaseWidget
  641. -- ================================
  642. -- 4. 工具函数
  643. -- ================================
  644. clamp = function(v, minv, maxv)
  645. if v < minv then return minv end
  646. if v > maxv then return maxv end
  647. return v
  648. end
  649. now_ms = function()
  650. if mcu and mcu.ticks then
  651. return mcu.ticks()
  652. end
  653. if sys and sys.tick then
  654. return sys.tick()
  655. end
  656. return (os.time() or 0) * 1000
  657. end
  658. fill_rect = function(x1, y1, x2, y2, color)
  659. if not lcd or not lcd.fill then return end
  660. lcd.fill(x1, y1, x2, y2 + 1, color)
  661. end
  662. stroke_rect = function(x1, y1, x2, y2, color)
  663. if not lcd then return end
  664. if lcd.drawLine then
  665. lcd.drawLine(x1, y1, x2, y1, color)
  666. lcd.drawLine(x2, y1, x2, y2, color)
  667. lcd.drawLine(x2, y2, x1, y2, color)
  668. lcd.drawLine(x1, y2, x1, y1, color)
  669. end
  670. end
  671. font_line_height = function(style)
  672. if FontAdapter._backend == "gtfont" or FontAdapter._backend == "hzfont" then
  673. return tonumber(style and style.size or FontAdapter._size or 16)
  674. end
  675. if lcd and style and style.size then
  676. local guess = "font_opposansm" .. tostring(style.size) .. "_chinese"
  677. if lcd[guess] then
  678. return tonumber(style.size)
  679. end
  680. end
  681. return FontAdapter._size or 12
  682. end
  683. font_measure = function(text, style)
  684. if not text or text == "" then return 0 end
  685. style = style or {}
  686. if FontAdapter._backend == "gtfont" and lcd and lcd.getGtfontStrWidth then
  687. return lcd.getGtfontStrWidth(text, tonumber(style.size or FontAdapter._size or 16))
  688. end
  689. if FontAdapter._backend == "hzfont" and lcd and lcd.getHzFontStrWidth then
  690. return lcd.getHzFontStrWidth(text, tonumber(style.size or FontAdapter._size or 16))
  691. end
  692. if lcd and lcd.getStrWidth then
  693. return lcd.getStrWidth(text)
  694. end
  695. local width = 0
  696. local i = 1
  697. local size = tonumber(style.size) or FontAdapter._size or 12
  698. while i <= #text do
  699. local byte = string.byte(text, i)
  700. if byte < 128 then
  701. width = width + math.ceil(size / 2)
  702. i = i + 1
  703. else
  704. width = width + size
  705. i = i + 3
  706. end
  707. end
  708. return width
  709. end
  710. font_draw = function(text, x, y, color, style)
  711. if not lcd then return end
  712. style = style or {}
  713. color = color or COLOR_WHITE
  714. if FontAdapter._backend == "gtfont" and lcd.drawGtfontUtf8 then
  715. local sz = tonumber(style.size or FontAdapter._size or 16)
  716. if FontAdapter._gray and lcd.drawGtfontUtf8Gray then
  717. lcd.drawGtfontUtf8Gray(text, sz, 4, x, y, color)
  718. return
  719. end
  720. lcd.drawGtfontUtf8(text, sz, x, y, color)
  721. return
  722. end
  723. if FontAdapter._backend == "hzfont" and lcd.drawHzfontUtf8 then
  724. local sz = tonumber(style.size or FontAdapter._size or 16)
  725. local lh = font_line_height(style)
  726. lcd.drawHzfontUtf8(x, y + lh, text, sz, color, FontAdapter._hz_antialias or -1)
  727. return
  728. end
  729. if lcd.setFont then
  730. if FontAdapter._name and lcd["font_" .. FontAdapter._name] then
  731. lcd.setFont(lcd["font_" .. FontAdapter._name])
  732. elseif style.size then
  733. local guess = "font_opposansm" .. tostring(style.size) .. "_chinese"
  734. if lcd[guess] then
  735. lcd.setFont(lcd[guess])
  736. elseif lcd.font_opposansm12_chinese then
  737. lcd.setFont(lcd.font_opposansm12_chinese)
  738. end
  739. elseif lcd.font_opposansm12_chinese then
  740. lcd.setFont(lcd.font_opposansm12_chinese)
  741. end
  742. end
  743. local lh = font_line_height(style)
  744. if lcd.drawStr then
  745. lcd.drawStr(x, y + lh, text, color)
  746. end
  747. end
  748. Canvas = {}
  749. Canvas.__index = Canvas
  750. function Canvas:new()
  751. return setmetatable({}, Canvas)
  752. end
  753. function Canvas:clear(color)
  754. if lcd and lcd.clear then
  755. lcd.clear(color or COLOR_BLACK)
  756. end
  757. end
  758. function Canvas:fill_rect(x, y, w, h, color)
  759. if w <= 0 or h <= 0 then return end
  760. fill_rect(x, y, x + w - 1, y + h - 1, color)
  761. end
  762. function Canvas:stroke_rect(x, y, w, h, color)
  763. if w <= 0 or h <= 0 then return end
  764. stroke_rect(x, y, x + w - 1, y + h - 1, color)
  765. end
  766. function Canvas:draw_text(text, x, y, color, style)
  767. font_draw(text, x, y, color, style)
  768. end
  769. function Canvas:text_width(text, style)
  770. return font_measure(text, style)
  771. end
  772. function Canvas:line_height(style)
  773. return font_line_height(style)
  774. end
  775. function Canvas:draw_text_in_rect_centered(x, y, w, h, text, opts)
  776. opts = opts or {}
  777. local padding = opts.padding or 0
  778. local style = opts.style or {}
  779. local color = opts.color or COLOR_WHITE
  780. local tw = self:text_width(text or "", style)
  781. local lh = self:line_height(style)
  782. local inner_w = math.max(0, w - padding * 2)
  783. local inner_h = math.max(0, h - padding * 2)
  784. local tx = x + padding + (inner_w - tw) // 2
  785. local ty = y + padding + (inner_h - lh) // 2
  786. self:draw_text(text or "", tx, ty, color, style)
  787. end
  788. canvas = Canvas:new()
  789. local function get_utf8_char(text, i)
  790. if not text or i > #text then return "", 0 end
  791. local byte = string.byte(text, i)
  792. if not byte then return "", 0 end
  793. if byte < 128 then
  794. return string.sub(text, i, i), 1
  795. elseif byte >= 224 and byte < 240 then
  796. if i + 2 <= #text then
  797. return string.sub(text, i, i + 2), 3
  798. else
  799. return string.sub(text, i, i), 1
  800. end
  801. elseif byte >= 192 and byte < 224 then
  802. if i + 1 <= #text then
  803. return string.sub(text, i, i + 1), 2
  804. else
  805. return string.sub(text, i, i), 1
  806. end
  807. elseif byte >= 240 then
  808. if i + 3 <= #text then
  809. return string.sub(text, i, i + 3), 4
  810. else
  811. return string.sub(text, i, i), 1
  812. end
  813. end
  814. return string.sub(text, i, i), 1
  815. end
  816. local function wrap_text_lines(text, maxWidth, style)
  817. if not text or text == "" then return { "" } end
  818. if not maxWidth or maxWidth <= 0 then return { text } end
  819. local lines = {}
  820. local currentLine = ""
  821. local currentWidth = 0
  822. local wordBuffer = ""
  823. local wordWidth = 0
  824. local i = 1
  825. while i <= #text do
  826. local char, charLen = get_utf8_char(text, i)
  827. local charWidth = font_measure(char, style)
  828. local byte = string.byte(text, i)
  829. local isAlphaNum = (byte and ((byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122)))
  830. if isAlphaNum then
  831. wordBuffer = wordBuffer .. char
  832. wordWidth = wordWidth + charWidth
  833. i = i + charLen
  834. else
  835. if wordBuffer ~= "" then
  836. if currentWidth + wordWidth > maxWidth then
  837. if currentLine ~= "" then
  838. table.insert(lines, currentLine)
  839. currentLine = wordBuffer
  840. currentWidth = wordWidth
  841. else
  842. currentLine = wordBuffer
  843. currentWidth = wordWidth
  844. end
  845. else
  846. currentLine = currentLine .. wordBuffer
  847. currentWidth = currentWidth + wordWidth
  848. end
  849. wordBuffer = ""
  850. wordWidth = 0
  851. end
  852. if char == " " then
  853. if currentWidth + charWidth <= maxWidth then
  854. currentLine = currentLine .. char
  855. currentWidth = currentWidth + charWidth
  856. else
  857. if currentLine ~= "" then
  858. table.insert(lines, currentLine)
  859. end
  860. currentLine = ""
  861. currentWidth = 0
  862. end
  863. else
  864. if currentWidth + charWidth > maxWidth then
  865. if currentLine ~= "" then
  866. table.insert(lines, currentLine)
  867. end
  868. currentLine = char
  869. currentWidth = charWidth
  870. else
  871. currentLine = currentLine .. char
  872. currentWidth = currentWidth + charWidth
  873. end
  874. end
  875. i = i + charLen
  876. end
  877. end
  878. if wordBuffer ~= "" then
  879. if currentWidth + wordWidth > maxWidth and currentLine ~= "" then
  880. table.insert(lines, currentLine)
  881. currentLine = wordBuffer
  882. else
  883. currentLine = currentLine .. wordBuffer
  884. end
  885. end
  886. if currentLine ~= "" then
  887. table.insert(lines, currentLine)
  888. end
  889. if #lines == 0 then
  890. lines = { "" }
  891. end
  892. return lines
  893. end
  894. local function fit_text_to_width(text, maxWidth, style, opts)
  895. opts = opts or {}
  896. if not text then return "" end
  897. if not maxWidth or maxWidth <= 0 then return text end
  898. if font_measure(text, style) <= maxWidth then
  899. return text
  900. end
  901. local ellipsis = opts.ellipsis and "..." or ""
  902. local reserve = opts.ellipsis and font_measure("...", style) or 0
  903. local limit = maxWidth - reserve
  904. if limit <= 0 then
  905. return opts.ellipsis and "..." or ""
  906. end
  907. local truncated = ""
  908. local used = 0
  909. local i = 1
  910. while i <= #text do
  911. local char, len = get_utf8_char(text, i)
  912. local cw = font_measure(char, style)
  913. if used + cw > limit then
  914. break
  915. end
  916. truncated = truncated .. char
  917. used = used + cw
  918. i = i + len
  919. end
  920. if opts.ellipsis then
  921. return truncated .. "..."
  922. end
  923. return truncated
  924. end
  925. local function draw_text_direct(x, y, text, opts)
  926. opts = opts or {}
  927. font_draw(text or "", x, y, opts.color or COLOR_WHITE, opts.style or {})
  928. end
  929. local function draw_text_in_rect_centered(x, y, w, h, text, opts)
  930. opts = opts or {}
  931. local padding = opts.padding or 0
  932. local style = opts.style or {}
  933. local color = opts.color or COLOR_WHITE
  934. local tw = font_measure(text or "", style)
  935. local lh = font_line_height(style)
  936. local inner_w = math.max(0, w - padding * 2)
  937. local inner_h = math.max(0, h - padding * 2)
  938. local tx = x + padding + (inner_w - tw) // 2
  939. local ty = y + padding + (inner_h - lh) // 2
  940. font_draw(text or "", tx, ty, color, style)
  941. end
  942. local function draw_image_placeholder(x, y, w, h, bg_color, border_color)
  943. bg_color = bg_color or COLOR_GRAY
  944. border_color = border_color or COLOR_WHITE
  945. fill_rect(x, y, x + w - 1, y + h - 1, bg_color)
  946. stroke_rect(x, y, x + w - 1, y + h - 1, border_color)
  947. if lcd and lcd.drawLine then
  948. lcd.drawLine(x, y, x + w - 1, y + h - 1, border_color)
  949. lcd.drawLine(x + w - 1, y, x, y + h - 1, border_color)
  950. if w >= 20 and h >= 20 then
  951. local margin = math.min(w, h) // 8
  952. lcd.drawLine(x + margin, y + margin, x + w - 1 - margin, y + h - 1 - margin, border_color)
  953. lcd.drawLine(x + w - 1 - margin, y + margin, x + margin, y + h - 1 - margin, border_color)
  954. end
  955. end
  956. end
  957. -- 箭头绘制工具(在给定矩形内绘制上下左右箭头)
  958. local function draw_arrow_icon(x, y, w, h, direction, color)
  959. local cx = x + w // 2
  960. local cy = y + h // 2
  961. -- 控制箭头尺寸(增大内边距,整体缩短约 1/3)
  962. local padX = math.max(1, w // 3)
  963. local padY = math.max(1, h // 3)
  964. local leftX = x + padX
  965. local rightX = x + w - padX
  966. local topY = y + padY
  967. local bottomY = y + h - padY
  968. if direction == "up" then
  969. lcd.drawLine(leftX, bottomY, cx, topY, color)
  970. lcd.drawLine(rightX, bottomY, cx, topY, color)
  971. elseif direction == "down" then
  972. lcd.drawLine(leftX, topY, cx, bottomY, color)
  973. lcd.drawLine(rightX, topY, cx, bottomY, color)
  974. elseif direction == "left" then
  975. -- 左侧中点 -> 右上/右下(<)
  976. lcd.drawLine(leftX, cy, rightX, topY, color)
  977. lcd.drawLine(leftX, cy, rightX, bottomY, color)
  978. elseif direction == "right" then
  979. -- 右侧中点 -> 左上/左下(>)
  980. lcd.drawLine(rightX, cy, leftX, topY, color)
  981. lcd.drawLine(rightX, cy, leftX, bottomY, color)
  982. end
  983. end
  984. -- ================================
  985. -- 5. 组件部分(按拼音排序)
  986. -- 组件列表:button、check_box、combo_box、input、keyboard、label、message_box、picture、progress_bar、window
  987. -- ================================
  988. -- 5.1 button
  989. local button = setmetatable({}, { __index = BaseWidget })
  990. button.__index = button
  991. function button:new(opts)
  992. opts = opts or {}
  993. opts.w = opts.w or opts.width or 100
  994. opts.h = opts.h or opts.height or 36
  995. local o = BaseWidget.new(self, opts)
  996. local dark = (current_theme == "dark")
  997. o.text = opts.text or "Button"
  998. o.text_style = { size = opts.text_size or 12 }
  999. o.colors = {
  1000. bg = opts.bg_color or (dark and COLOR_GRAY or COLOR_WHITE),
  1001. pressed = opts.pressed_color or COLOR_SKY_BLUE,
  1002. border = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK),
  1003. text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
  1004. }
  1005. o.src = opts.src
  1006. o.src_pressed = opts.src_pressed
  1007. o.src_toggled = opts.src_toggled
  1008. o.toggle = opts.toggle or false
  1009. o.toggled = opts.toggled or false
  1010. o.on_toggle = opts.on_toggle
  1011. o.on_click = opts.on_click
  1012. o.pressed = false
  1013. o._imageCache = {}
  1014. return o
  1015. end
  1016. local function button_resolve_image(self)
  1017. if not self.src then return nil end
  1018. if self.toggle and self.toggled then
  1019. return self.src_toggled or self.src
  1020. elseif self.pressed then
  1021. return self.src_pressed or self.src
  1022. end
  1023. return self.src
  1024. end
  1025. function button:draw(ctx)
  1026. if not self.visible then return end
  1027. local ax, ay = self:get_absolute_position()
  1028. local img = button_resolve_image(self)
  1029. -- 优先使用图片缓存(lcd.image2raw + lcd.draw)
  1030. if img and lcd and lcd.image2raw and lcd.draw then
  1031. local zbuff = ui.image_cache.get_zbuff(img)
  1032. if zbuff then
  1033. -- 使用 zbuff 绘制,lcd.draw 会自动使用 zbuff 内部的 width 和 height
  1034. lcd.draw(ax, ay, nil, nil, zbuff)
  1035. return
  1036. end
  1037. end
  1038. -- 绘制按钮背景和文本
  1039. local bg = self.pressed and self.colors.pressed or self.colors.bg
  1040. ctx:fill_rect(ax, ay, self.w, self.h, bg)
  1041. ctx:stroke_rect(ax, ay, self.w, self.h, self.colors.border)
  1042. local text_w = ctx:text_width(self.text or "", self.text_style)
  1043. local text_h = ctx:line_height(self.text_style)
  1044. local tx = ax + math.max(0, (self.w - text_w) // 2)
  1045. local ty = ay + math.max(0, (self.h - text_h) // 2)
  1046. ctx:draw_text(self.text or "", tx, ty, self.colors.text, self.text_style)
  1047. end
  1048. function button:set_text(new_text)
  1049. self.text = tostring(new_text or "")
  1050. self:invalidate()
  1051. end
  1052. function button:handle_event(evt, x, y)
  1053. if not self.enabled then return false end
  1054. local inside = self:contains_point(x or 0, y or 0)
  1055. if evt == "TOUCH_DOWN" and inside then
  1056. self.pressed = true
  1057. self._capture = true
  1058. self:invalidate()
  1059. return true
  1060. elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
  1061. local new_pressed = inside
  1062. if new_pressed ~= self.pressed then
  1063. self.pressed = new_pressed
  1064. self:invalidate()
  1065. end
  1066. return true
  1067. elseif evt == "SINGLE_TAP" then
  1068. local was_pressed = self.pressed
  1069. self.pressed = false
  1070. self._capture = false
  1071. if was_pressed and inside then
  1072. if self.toggle then
  1073. self.toggled = not self.toggled
  1074. if self.on_toggle then
  1075. pcall(self.on_toggle, self.toggled, self)
  1076. end
  1077. end
  1078. if self.on_click then
  1079. pcall(self.on_click, self)
  1080. end
  1081. self:invalidate()
  1082. return true
  1083. end
  1084. self:invalidate()
  1085. return was_pressed
  1086. elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
  1087. if self._capture then
  1088. self.pressed = false
  1089. self._capture = false
  1090. self:invalidate()
  1091. return true
  1092. end
  1093. end
  1094. return false
  1095. end
  1096. ui.button = function(opts)
  1097. return button:new(opts)
  1098. end
  1099. -- 5.2 CheckBox
  1100. local check_box = setmetatable({}, { __index = BaseWidget })
  1101. check_box.__index = check_box
  1102. function check_box:new(opts)
  1103. opts = opts or {}
  1104. opts.box_size = opts.box_size or 20
  1105. local text_style = { size = opts.font_size or 12 }
  1106. local text_width = opts.text and font_measure(opts.text, text_style) or 0
  1107. opts.w = math.max(opts.w or 0, opts.box_size + (opts.text and (10 + text_width) or 0))
  1108. opts.h = math.max(opts.h or 0, opts.box_size, font_line_height(text_style))
  1109. local o = BaseWidget.new(self, opts)
  1110. o.text = opts.text or ""
  1111. o.text_style = text_style
  1112. o.box_size = opts.box_size
  1113. o.checked = opts.checked or false
  1114. o.on_change = opts.on_change
  1115. local dark = (current_theme == "dark")
  1116. o.colors = {
  1117. border = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK),
  1118. bg = opts.bg_color or (dark and COLOR_BLACK or COLOR_WHITE),
  1119. tick = opts.tick_color or COLOR_SKY_BLUE,
  1120. text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
  1121. }
  1122. return o
  1123. end
  1124. function check_box:set_checked(v)
  1125. local nv = not not v
  1126. if nv == self.checked then return end
  1127. self.checked = nv
  1128. self:invalidate()
  1129. if self.on_change then
  1130. pcall(self.on_change, self.checked, self)
  1131. end
  1132. end
  1133. function check_box:toggle()
  1134. self:set_checked(not self.checked)
  1135. end
  1136. function check_box:draw(ctx)
  1137. local ax, ay = self:get_absolute_position()
  1138. local size = self.box_size
  1139. ctx:stroke_rect(ax, ay, size, size, self.colors.border)
  1140. ctx:fill_rect(ax + 2, ay + 2, size - 4, size - 4, self.colors.bg)
  1141. if self.checked then
  1142. ctx:fill_rect(ax + 4, ay + 4, size - 8, size - 8, self.colors.tick)
  1143. end
  1144. if self.text and self.text ~= "" then
  1145. local lh = ctx:line_height(self.text_style)
  1146. local ty = ay + (self.h - lh) // 2
  1147. ctx:draw_text(self.text, ax + size + 10, ty, self.colors.text, self.text_style)
  1148. end
  1149. end
  1150. function check_box:handle_event(evt, x, y)
  1151. if evt == "SINGLE_TAP" then
  1152. if x and y and self:contains_point(x, y) then
  1153. self:toggle()
  1154. return true
  1155. end
  1156. elseif evt == "TOUCH_DOWN" then
  1157. return self:contains_point(x or 0, y or 0)
  1158. end
  1159. return false
  1160. end
  1161. ui.check_box = function(opts)
  1162. return check_box:new(opts)
  1163. end
  1164. -- 5.3 ComboBox
  1165. local dropdown_panel = setmetatable({}, { __index = BaseWidget })
  1166. dropdown_panel.__index = dropdown_panel
  1167. function dropdown_panel:new(owner)
  1168. local o = BaseWidget.new(self, { x = 0, y = 0, w = 0, h = 0 })
  1169. o.owner = owner
  1170. o.visible = false
  1171. o.item_height = (owner and owner.dropdown_item_height) or 32
  1172. o.padding = 4
  1173. o.scroll_offset = 0
  1174. o.max_scroll_offset = 0
  1175. o.hovered_index = -1
  1176. o.pressed_index = -1
  1177. o.scroll_threshold = 10
  1178. o.drag_start_y = 0
  1179. o.scroll_base_offset = 0
  1180. o.is_dragging = false
  1181. o._host_is_window = false
  1182. return o
  1183. end
  1184. function dropdown_panel:update_layout()
  1185. local owner = self.owner
  1186. if not owner then return end
  1187. self.w = owner.w
  1188. local itemCount = #(owner.options or {})
  1189. local maxVisible = math.max(1, math.min(itemCount, owner.max_visible_items or 5))
  1190. self.visible_count = maxVisible
  1191. self.h = maxVisible * self.item_height + self.padding * 2
  1192. self.max_scroll_offset = math.max(0, itemCount - maxVisible)
  1193. self.scroll_offset = clamp(self.scroll_offset, 0, self.max_scroll_offset)
  1194. if self._host_is_window and owner._parentWindow then
  1195. self.x = owner.x
  1196. self.y = owner.y + owner.h
  1197. else
  1198. local ax, ay = owner:get_absolute_position()
  1199. self.x = ax
  1200. self.y = ay + owner.h
  1201. end
  1202. end
  1203. function dropdown_panel:draw(ctx)
  1204. if not self.visible then return end
  1205. local owner = self.owner
  1206. if not owner then return end
  1207. local ax, ay = self:get_absolute_position()
  1208. local dark = (current_theme == "dark")
  1209. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  1210. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  1211. ctx:fill_rect(ax, ay, self.w, self.h, bg_color)
  1212. ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
  1213. local startIdx = self.scroll_offset + 1
  1214. local endIdx = math.min(#owner.options, startIdx + (self.visible_count or owner.max_visible_items or 5) - 1)
  1215. local textStyle = owner.text_style
  1216. local lh = ctx:line_height(textStyle)
  1217. for i = startIdx, endIdx do
  1218. local itemY = ay + self.padding + (i - startIdx) * self.item_height
  1219. local isHovered = (i == self.hovered_index)
  1220. local isPressed = (i == self.pressed_index)
  1221. local isSelected = (i == owner.selected_index)
  1222. if isPressed then
  1223. ctx:fill_rect(ax + self.padding, itemY, self.w - self.padding * 2, self.item_height, COLOR_GRAY)
  1224. elseif isHovered then
  1225. ctx:fill_rect(ax + self.padding, itemY, self.w - self.padding * 2, self.item_height, COLOR_SKY_BLUE)
  1226. end
  1227. local labelColor = owner.colors.text
  1228. if isHovered then
  1229. labelColor = COLOR_WHITE
  1230. end
  1231. local text = owner:_normalize_option_text(owner.options[i])
  1232. local textX = ax + self.padding + 6
  1233. local textY = itemY + (self.item_height - lh) // 2
  1234. if isSelected then
  1235. ctx:draw_text("*", textX, textY, labelColor, textStyle)
  1236. textX = textX + ctx:text_width("*", textStyle) + 4
  1237. end
  1238. ctx:draw_text(text, textX, textY, labelColor, textStyle)
  1239. end
  1240. -- 绘制滚动指示器(如果需要滚动)
  1241. if self.max_scroll_offset > 0 then
  1242. local scrollBarWidth = 4
  1243. local scrollBarX = ax + self.w - scrollBarWidth - 2
  1244. local scrollBarHeight = self.h - 4
  1245. local scrollBarY = ay + 2
  1246. -- 滚动条背景
  1247. ctx:fill_rect(scrollBarX, scrollBarY, scrollBarWidth, scrollBarHeight, COLOR_WHITE)
  1248. -- 滚动条滑块(基于滚动偏移计算)
  1249. local maxVisibleItems = self.visible_count or owner.max_visible_items or 5
  1250. local totalItems = #owner.options
  1251. local thumbHeight = math.max(10, math.floor(scrollBarHeight * maxVisibleItems / totalItems))
  1252. -- 计算滑块位置:基于当前滚动偏移量
  1253. local thumbY
  1254. if self.max_scroll_offset > 0 then
  1255. thumbY = scrollBarY +
  1256. math.floor((self.scroll_offset / self.max_scroll_offset) * (scrollBarHeight - thumbHeight))
  1257. else
  1258. thumbY = scrollBarY
  1259. end
  1260. ctx:fill_rect(scrollBarX, thumbY, scrollBarWidth, thumbHeight, border_color)
  1261. end
  1262. end
  1263. function dropdown_panel:handle_event(evt, x, y)
  1264. if not (self.visible and self.owner and self.owner.enabled) then return false end
  1265. local inside = self:contains_point(x, y)
  1266. if not inside then
  1267. if evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
  1268. self:hide()
  1269. return true
  1270. end
  1271. return false
  1272. end
  1273. local owner = self.owner
  1274. local ax, ay = self:get_absolute_position()
  1275. if evt == "TOUCH_DOWN" then
  1276. self.drag_start_y = y
  1277. self.scroll_base_offset = self.scroll_offset
  1278. self.is_dragging = false
  1279. local relativeY = y - ay - self.padding
  1280. local pressedIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
  1281. if pressedIndex >= 1 and pressedIndex <= #owner.options then
  1282. self.pressed_index = pressedIndex
  1283. else
  1284. self.pressed_index = -1
  1285. end
  1286. self._capture = true
  1287. self:invalidate()
  1288. return true
  1289. elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
  1290. local dy = y - self.drag_start_y
  1291. if not self.is_dragging and math.abs(dy) >= self.scroll_threshold then
  1292. self.is_dragging = true
  1293. self.pressed_index = -1
  1294. self.hovered_index = -1
  1295. self:invalidate()
  1296. end
  1297. if self.is_dragging then
  1298. local newOffset = self.scroll_base_offset + math.floor(-dy / self.item_height)
  1299. newOffset = clamp(newOffset, 0, self.max_scroll_offset)
  1300. if newOffset ~= self.scroll_offset then
  1301. self.scroll_offset = newOffset
  1302. self:invalidate()
  1303. end
  1304. else
  1305. local relativeY = y - ay - self.padding
  1306. local hoverIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
  1307. if hoverIndex >= 1 and hoverIndex <= #owner.options then
  1308. if hoverIndex ~= self.hovered_index then
  1309. self.hovered_index = hoverIndex
  1310. self:invalidate()
  1311. end
  1312. else
  1313. if self.hovered_index ~= -1 then
  1314. self.hovered_index = -1
  1315. self:invalidate()
  1316. end
  1317. end
  1318. end
  1319. return true
  1320. elseif evt == "SINGLE_TAP" and self._capture then
  1321. self._capture = false
  1322. local relativeY = y - ay - self.padding
  1323. local clickedIndex = math.floor(relativeY / self.item_height) + self.scroll_offset + 1
  1324. self.pressed_index = -1
  1325. if self.hovered_index ~= -1 then
  1326. self.hovered_index = -1
  1327. self:invalidate()
  1328. end
  1329. if not self.is_dragging and clickedIndex >= 1 and clickedIndex <= #owner.options then
  1330. owner:set_selected(clickedIndex)
  1331. self:hide()
  1332. return true
  1333. end
  1334. self.is_dragging = false
  1335. return true
  1336. elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
  1337. self._capture = false
  1338. if self.pressed_index ~= -1 or self.hovered_index ~= -1 then
  1339. self.pressed_index = -1
  1340. self.hovered_index = -1
  1341. self:invalidate()
  1342. end
  1343. self.is_dragging = false
  1344. return true
  1345. end
  1346. return false
  1347. end
  1348. function dropdown_panel:show()
  1349. local owner = self.owner
  1350. if not owner then return end
  1351. if self.visible then return end
  1352. self._host_is_window = owner._parentWindow ~= nil
  1353. if self._host_is_window and owner._parentWindow then
  1354. owner._parentWindow:add(self)
  1355. else
  1356. runtime.add(self)
  1357. end
  1358. self:update_layout()
  1359. self.visible = true
  1360. self.hovered_index = owner.selected_index or -1
  1361. self.pressed_index = -1
  1362. self.is_dragging = false
  1363. end
  1364. function dropdown_panel:hide()
  1365. if not self.visible then return end
  1366. self.visible = false
  1367. self.hovered_index = -1
  1368. self.pressed_index = -1
  1369. self.is_dragging = false
  1370. self._capture = false
  1371. if self._host_is_window and self.parent then
  1372. self.parent:remove(self)
  1373. else
  1374. runtime.remove(self)
  1375. end
  1376. self._host_is_window = false
  1377. end
  1378. local combo_box = setmetatable({}, { __index = BaseWidget })
  1379. combo_box.__index = combo_box
  1380. function combo_box:new(opts)
  1381. opts = opts or {}
  1382. opts.w = opts.width or opts.w or 200
  1383. opts.h = opts.height or opts.h or 36
  1384. local o = BaseWidget.new(self, opts)
  1385. o.options = opts.options or {}
  1386. o.selected_index = clamp(opts.selected or 1, 1, math.max(1, #o.options))
  1387. o.placeholder = opts.placeholder or "请选择"
  1388. o.max_visible_items = opts.max_visible_items or 5
  1389. o.dropdown_item_height = opts.item_height or 32
  1390. o.text_style = { size = opts.text_size or opts.size or 12 }
  1391. local dark = (current_theme == "dark")
  1392. o.colors = {
  1393. bg = opts.bg_color or (dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG),
  1394. border = opts.border_color or (dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER),
  1395. text = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK),
  1396. arrow = opts.arrow_color or COLOR_SKY_BLUE
  1397. }
  1398. o.on_select = opts.on_select
  1399. o.pressed = false
  1400. o._dropdown = dropdown_panel:new(o)
  1401. return o
  1402. end
  1403. function combo_box:_normalize_option_text(item)
  1404. if type(item) == "table" then
  1405. return tostring(item.text or item.value or "")
  1406. end
  1407. return tostring(item or "")
  1408. end
  1409. function combo_box:set_options(options)
  1410. self.options = options or {}
  1411. if self.selected_index > #self.options then
  1412. self.selected_index = #self.options > 0 and #self.options or 1
  1413. end
  1414. self:invalidate()
  1415. end
  1416. function combo_box:set_selected(index)
  1417. if index < 1 or index > #self.options then return end
  1418. self.selected_index = index
  1419. self:invalidate()
  1420. if self.on_select then
  1421. local ok, err = pcall(self.on_select, self:get_selected_value(), index, self:get_selected_text())
  1422. if not ok then
  1423. log.warn("ComboBox", "on_select error", err)
  1424. end
  1425. end
  1426. end
  1427. function combo_box:get_selected_index()
  1428. return self.selected_index or 0
  1429. end
  1430. function combo_box:get_selected_text()
  1431. if not self.options or #self.options == 0 then
  1432. return self.placeholder
  1433. end
  1434. return self:_normalize_option_text(self.options[self.selected_index])
  1435. end
  1436. function combo_box:get_selected_value()
  1437. if not self.options or #self.options == 0 then
  1438. return nil
  1439. end
  1440. local item = self.options[self.selected_index]
  1441. if type(item) == "table" then
  1442. return item.value
  1443. end
  1444. return item
  1445. end
  1446. function combo_box:draw(ctx)
  1447. if not self.visible then return end
  1448. local ax, ay = self:get_absolute_position()
  1449. local bg_color = self.pressed and COLOR_GRAY or self.colors.bg
  1450. ctx:fill_rect(ax, ay, self.w, self.h, bg_color)
  1451. ctx:stroke_rect(ax, ay, self.w, self.h, self.colors.border)
  1452. local textPadding = 8
  1453. local arrowSpace = 20
  1454. local style = self.text_style
  1455. local maxTextWidth = math.max(0, self.w - textPadding * 2 - arrowSpace)
  1456. local text = self:get_selected_text()
  1457. text = fit_text_to_width(text, maxTextWidth, style, { ellipsis = true })
  1458. local textY = ay + (self.h - ctx:line_height(style)) // 2
  1459. ctx:draw_text(text, ax + textPadding, textY, self.colors.text, style)
  1460. if lcd and lcd.drawLine then
  1461. local arrowX = ax + self.w - arrowSpace // 2 - 4
  1462. local arrowY = ay + self.h // 2
  1463. if self._dropdown.visible then
  1464. lcd.drawLine(arrowX - 5, arrowY + 2, arrowX, arrowY - 2, self.colors.arrow)
  1465. lcd.drawLine(arrowX, arrowY - 2, arrowX + 5, arrowY + 2, self.colors.arrow)
  1466. else
  1467. lcd.drawLine(arrowX - 5, arrowY - 2, arrowX, arrowY + 2, self.colors.arrow)
  1468. lcd.drawLine(arrowX, arrowY + 2, arrowX + 5, arrowY - 2, self.colors.arrow)
  1469. end
  1470. end
  1471. end
  1472. function combo_box:handle_event(evt, x, y)
  1473. if not self.enabled then return false end
  1474. local inside = self:contains_point(x or 0, y or 0)
  1475. if evt == "TOUCH_DOWN" and inside then
  1476. self.pressed = true
  1477. return true
  1478. elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self.pressed then
  1479. self.pressed = inside
  1480. return true
  1481. elseif evt == "SINGLE_TAP" then
  1482. local was_pressed = self.pressed
  1483. self.pressed = false
  1484. if was_pressed and inside then
  1485. if self._dropdown.visible then
  1486. self._dropdown:hide()
  1487. else
  1488. self._dropdown:show()
  1489. end
  1490. return true
  1491. end
  1492. elseif evt == "LONG_PRESS" or evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" or evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
  1493. self.pressed = false
  1494. end
  1495. return false
  1496. end
  1497. function combo_box:on_unmount()
  1498. if self._dropdown and self._dropdown.visible then
  1499. self._dropdown:hide()
  1500. end
  1501. end
  1502. ui.combo_box = function(opts)
  1503. return combo_box:new(opts)
  1504. end
  1505. -- 5.4 Label
  1506. local label = setmetatable({}, { __index = BaseWidget })
  1507. label.__index = label
  1508. function label:new(opts)
  1509. opts = opts or {}
  1510. local o = BaseWidget.new(self, opts)
  1511. o.text = tostring(opts.text or "")
  1512. o.text_style = { size = opts.size or opts.text_size }
  1513. local dark = (current_theme == "dark")
  1514. o.color = opts.color or (dark and COLOR_WHITE or COLOR_BLACK)
  1515. o.word_wrap = not not opts.word_wrap
  1516. o._autoWidth = not opts.w
  1517. o:_reflow()
  1518. return o
  1519. end
  1520. function label:_reflow()
  1521. local style = self.text_style
  1522. if self.word_wrap and not self._autoWidth and self.w and self.w > 0 then
  1523. self._lines = wrap_text_lines(self.text, self.w, style)
  1524. local lh = font_line_height(style)
  1525. self.h = math.max(self.h or 0, #self._lines * lh)
  1526. else
  1527. self._lines = nil
  1528. if self._autoWidth then
  1529. self.w = font_measure(self.text, style)
  1530. end
  1531. self.h = font_line_height(style)
  1532. end
  1533. end
  1534. function label:set_text(text)
  1535. self.text = tostring(text or "")
  1536. self:_reflow()
  1537. self:invalidate()
  1538. end
  1539. function label:set_size(sz)
  1540. self.text_style.size = tonumber(sz) or self.text_style.size
  1541. self:_reflow()
  1542. self:invalidate()
  1543. end
  1544. function label:draw(ctx)
  1545. if not self.visible then return end
  1546. local ax, ay = self:get_absolute_position()
  1547. local style = self.text_style
  1548. if self.word_wrap and self._lines then
  1549. local lh = ctx:line_height(style)
  1550. for i = 1, #self._lines do
  1551. ctx:draw_text(self._lines[i], ax, ay + (i - 1) * lh, self.color, style)
  1552. end
  1553. else
  1554. local text = self.text
  1555. if not self._autoWidth and self.w and self.w > 0 then
  1556. text = fit_text_to_width(text, self.w, style, { ellipsis = false })
  1557. end
  1558. ctx:draw_text(text, ax, ay, self.color, style)
  1559. end
  1560. end
  1561. function label:handle_event()
  1562. return false
  1563. end
  1564. ui.label = function(opts)
  1565. return label:new(opts)
  1566. end
  1567. -- 5.5 Input
  1568. local input = setmetatable({}, { __index = BaseWidget })
  1569. input.__index = input
  1570. function input:new(opts)
  1571. opts = opts or {}
  1572. opts.w = opts.w or opts.width or 200
  1573. opts.h = opts.h or opts.height or 36
  1574. local o = BaseWidget.new(self, opts)
  1575. -- 文本属性
  1576. o.text = opts.text or ""
  1577. o.placeholder = opts.placeholder or "请输入文本"
  1578. o.max_length = opts.max_length
  1579. -- 输入类型
  1580. o.input_type = opts.input_type or "text" -- text/number/password/email
  1581. -- 外观属性
  1582. local dark = (current_theme == "dark")
  1583. o.bg_color = opts.bg_color or (dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG)
  1584. o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
  1585. o.placeholder_color = opts.placeholder_color or COLOR_GRAY
  1586. o.border_color = opts.border_color or (dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER)
  1587. o.focused_border_color = opts.focused_border_color or COLOR_SKY_BLUE
  1588. o.text_style = { size = opts.text_size or opts.size or 12 }
  1589. -- 状态属性
  1590. o.focused = false
  1591. o.keyboard = nil -- 关联的键盘实例
  1592. -- 回调函数
  1593. o.on_text_changed = opts.on_text_changed
  1594. o.on_focus_changed = opts.on_focus_changed
  1595. o.on_confirm = opts.on_confirm
  1596. -- 内部状态
  1597. o._textOffset = 0 -- 文本滚动偏移(用于长文本显示)
  1598. o._pressed = false -- TOUCH_DOWN 时的视觉反馈
  1599. -- 键盘配置
  1600. o.keyboard_click_effect = opts.keyboard_click_effect ~= false
  1601. return o
  1602. end
  1603. -- 文本操作方法
  1604. function input:set_text(text)
  1605. local newText = tostring(text or "")
  1606. if self.max_length and #newText > self.max_length then
  1607. newText = string.sub(newText, 1, self.max_length)
  1608. end
  1609. if self.text ~= newText then
  1610. self.text = newText
  1611. self:invalidate()
  1612. if self.on_text_changed then
  1613. pcall(self.on_text_changed, self.text, self)
  1614. end
  1615. end
  1616. end
  1617. function input:get_text()
  1618. return self.text
  1619. end
  1620. -- 别名方法(兼容驼峰命名)
  1621. function input:getText()
  1622. return self:get_text()
  1623. end
  1624. function input:setText(text)
  1625. return self:set_text(text)
  1626. end
  1627. function input:set_placeholder(text)
  1628. self.placeholder = tostring(text or "")
  1629. self:invalidate()
  1630. end
  1631. function input:insert_text(text)
  1632. if not text or text == "" then return end
  1633. local newText = self.text .. text
  1634. self:set_text(newText)
  1635. end
  1636. function input:delete_text(start_pos, length)
  1637. if not self.text or self.text == "" then return end
  1638. length = length or 1
  1639. start_pos = math.max(1, math.min(start_pos, #self.text + 1))
  1640. local end_pos = math.min(start_pos + length - 1, #self.text)
  1641. if start_pos > end_pos then return end
  1642. local beforeText = string.sub(self.text, 1, start_pos - 1)
  1643. local afterText = string.sub(self.text, end_pos + 1)
  1644. self:set_text(beforeText .. afterText)
  1645. end
  1646. -- 焦点管理
  1647. function input:focus()
  1648. if self.focused then return end
  1649. -- 隐藏其他 Input 的键盘(确保同时只有一个 Input 有焦点)
  1650. for i = 1, #runtime.roots do
  1651. local root = runtime.roots[i]
  1652. if root and root._isKeyboard and root ~= self.keyboard then
  1653. root:hide()
  1654. end
  1655. end
  1656. self.focused = true
  1657. -- 创建键盘实例(每个 Input 拥有自己的 keyboard 实例)
  1658. if not self.keyboard then
  1659. -- 通过 ui.keyboard 访问(keyboard 组件在后面定义)
  1660. if ui.keyboard then
  1661. self.keyboard = ui.keyboard({
  1662. input = self,
  1663. enable_click_effect = self.keyboard_click_effect
  1664. })
  1665. self.keyboard._isKeyboard = true -- 标记为键盘组件
  1666. end
  1667. end
  1668. -- 显示键盘
  1669. if self.keyboard then
  1670. self.keyboard:show() -- 键盘位置在 show() 内部计算(屏幕中下对齐底边)
  1671. self.keyboard:set_input_type(self.input_type)
  1672. end
  1673. -- 触发焦点变化回调
  1674. if self.on_focus_changed then
  1675. pcall(self.on_focus_changed, true, self)
  1676. end
  1677. end
  1678. function input:blur()
  1679. if not self.focused then return end
  1680. self.focused = false
  1681. -- 隐藏键盘
  1682. if self.keyboard and self.keyboard:is_visible() then
  1683. self.keyboard:hide()
  1684. end
  1685. -- 触发焦点变化回调
  1686. if self.on_focus_changed then
  1687. pcall(self.on_focus_changed, false, self)
  1688. end
  1689. self:invalidate()
  1690. end
  1691. function input:is_focused()
  1692. return self.focused
  1693. end
  1694. -- 绘制方法
  1695. function input:draw(ctx)
  1696. if not self.visible then return end
  1697. local ax, ay = self:get_absolute_position()
  1698. -- 绘制背景
  1699. ctx:fill_rect(ax, ay, self.w, self.h, self.bg_color)
  1700. -- 绘制边框
  1701. local border_color = (self.focused or self._pressed) and self.focused_border_color or self.border_color
  1702. ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
  1703. -- 文本绘制区域
  1704. local textPadding = 8
  1705. local textX = ax + textPadding
  1706. local textY = ay + (self.h - ctx:line_height(self.text_style)) // 2
  1707. local maxTextWidth = self.w - textPadding * 2
  1708. -- 确定要显示的文本
  1709. local displayText = self.text
  1710. local text_color = self.text_color
  1711. if not displayText or displayText == "" then
  1712. displayText = self.placeholder
  1713. text_color = self.placeholder_color
  1714. elseif self.input_type == "password" then
  1715. displayText = string.rep("*", #self.text)
  1716. end
  1717. -- 处理长文本滚动显示
  1718. local textWidth = ctx:text_width(displayText, self.text_style)
  1719. if textWidth > maxTextWidth then
  1720. -- 使用 fit_text_to_width 工具函数截断文本
  1721. displayText = fit_text_to_width(displayText, maxTextWidth, self.text_style, { ellipsis = false })
  1722. end
  1723. -- 绘制文本
  1724. if displayText and displayText ~= "" then
  1725. ctx:draw_text(displayText, textX, textY, text_color, self.text_style)
  1726. end
  1727. end
  1728. -- 事件处理
  1729. function input:handle_event(evt, x, y)
  1730. if not self.enabled then return false end
  1731. local inside = self:contains_point(x or 0, y or 0)
  1732. if evt == "TOUCH_DOWN" then
  1733. if inside then
  1734. self._pressed = true
  1735. self:invalidate()
  1736. return true
  1737. end
  1738. elseif evt == "SINGLE_TAP" then
  1739. if inside then
  1740. self._pressed = false
  1741. self:focus()
  1742. self:invalidate()
  1743. return true
  1744. end
  1745. return false
  1746. end
  1747. return false
  1748. end
  1749. ui.input = function(opts)
  1750. return input:new(opts)
  1751. end
  1752. -- 5.6 Keyboard
  1753. local keyboard = setmetatable({}, { __index = BaseWidget })
  1754. keyboard.__index = keyboard
  1755. function keyboard:new(opts)
  1756. opts = opts or {}
  1757. local o = BaseWidget.new(self, {
  1758. x = 0,
  1759. y = 0,
  1760. w = opts.width or 300,
  1761. h = opts.height or 450,
  1762. visible = false
  1763. })
  1764. -- 关联的 Input 组件
  1765. o.input = opts.input
  1766. -- 是否启用点击变色效果
  1767. o.enable_click_effect = opts.enable_click_effect ~= false
  1768. -- 键盘布局参数
  1769. o.keySize = 90
  1770. o.keyGap = 0
  1771. -- 输入模式
  1772. o.isNumberMode = false
  1773. o.isPinyin9KeyMode = false
  1774. -- 字母键盘按键映射
  1775. o.letterMappings = {
  1776. { text = "ABC", chars = { "a", "b", "c", "A", "B", "C" }, type = "letters", keyId = 1 },
  1777. { text = "DEF", chars = { "d", "e", "f", "D", "E", "F" }, type = "letters", keyId = 2 },
  1778. { text = "GHI", chars = { "g", "h", "i", "G", "H", "I" }, type = "letters", keyId = 3 },
  1779. { text = "JKL", chars = { "j", "k", "l", "J", "K", "L" }, type = "letters", keyId = 4 },
  1780. { text = "MNO", chars = { "m", "n", "o", "M", "N", "O" }, type = "letters", keyId = 5 },
  1781. { text = "PQRS", chars = { "p", "q", "r", "s", "P", "Q", "R", "S" }, type = "letters", keyId = 6 },
  1782. { text = "TUV", chars = { "t", "u", "v", "T", "U", "V" }, type = "letters", keyId = 7 },
  1783. { text = "WXYZ", chars = { "w", "x", "y", "z", "W", "X", "Y", "Z" }, type = "letters", keyId = 8 },
  1784. { text = "空格", chars = { " " }, type = "space" },
  1785. { text = "delete", chars = {}, type = "delete" },
  1786. { text = "NUM", chars = {}, type = "num" },
  1787. { text = "中/EN", chars = {}, type = "lang" }
  1788. }
  1789. -- 数字键盘按键映射
  1790. o.numberMappings = {
  1791. { text = "1", chars = { "1" }, type = "number" },
  1792. { text = "2", chars = { "2" }, type = "number" },
  1793. { text = "3", chars = { "3" }, type = "number" },
  1794. { text = "4", chars = { "4" }, type = "number" },
  1795. { text = "5", chars = { "5" }, type = "number" },
  1796. { text = "6", chars = { "6" }, type = "number" },
  1797. { text = "7", chars = { "7" }, type = "number" },
  1798. { text = "8", chars = { "8" }, type = "number" },
  1799. { text = "9", chars = { "9" }, type = "number" },
  1800. { text = "delete", chars = {}, type = "delete" },
  1801. { text = "0", chars = { "0" }, type = "number" },
  1802. { text = "EN", chars = {}, type = "letter" }
  1803. }
  1804. -- 根据模式设置按键映射
  1805. o.keyMappings = o.letterMappings
  1806. -- 按键布局
  1807. o.keyLayout = {}
  1808. o:build_key_layout()
  1809. -- 候选字符相关状态
  1810. o.selectedKey = nil
  1811. o.currentCandidates = {}
  1812. o._pressedCandidateIndex = nil
  1813. -- 9键拼音输入模式相关属性
  1814. o.keySequence = {} -- 当前按键序列(存储按键ID:1-8)
  1815. o.syllableCandidates = {} -- 音节候选列表
  1816. o.selectedSyllableIndex = 1 -- 当前选中的音节索引
  1817. o.currentSyllable = "" -- 当前选中的音节(已确认)
  1818. o.pinyinCandidates = {} -- 候选字列表(UTF-8字符串数组)
  1819. o.selectedCandidateIndex = 1 -- 当前选中的候选字索引
  1820. o.syllablePageIndex = 1 -- 音节列表当前页索引
  1821. o.candidatePageIndex = 1 -- 候选字列表当前页索引(每页8个候选字)
  1822. o.pinyinModule = nil -- pinyin模块缓存
  1823. -- 候选区显示状态
  1824. o.displayStartIndex = 1 -- 预览框显示的起始字符位置
  1825. o._pressedSyllableIndex = nil -- 当前按下的音节索引
  1826. o._backButtonPressed = false -- 返回按钮按下状态
  1827. return o
  1828. end
  1829. function keyboard:build_key_layout()
  1830. local start_x = self.x + 30 -- 左侧预留30px(用于未来音节选择区)
  1831. local start_y = self.y + 95 -- 顶部控制栏50px + 候选区50px
  1832. local keySize = self.keySize
  1833. local keyGap = self.keyGap
  1834. -- 构建3×4按键布局
  1835. self.keyLayout = {}
  1836. local keyIndex = 1
  1837. for row = 0, 3 do
  1838. for col = 0, 2 do
  1839. if keyIndex <= #self.keyMappings then
  1840. local key = {
  1841. x = start_x + col * (keySize + keyGap),
  1842. y = start_y + row * (keySize + keyGap),
  1843. w = keySize,
  1844. h = keySize,
  1845. text = self.keyMappings[keyIndex].text,
  1846. chars = self.keyMappings[keyIndex].chars,
  1847. type = self.keyMappings[keyIndex].type,
  1848. keyId = self.keyMappings[keyIndex].keyId, -- 用于拼音输入
  1849. pressed = false
  1850. }
  1851. table.insert(self.keyLayout, key)
  1852. keyIndex = keyIndex + 1
  1853. end
  1854. end
  1855. end
  1856. end
  1857. function keyboard:show()
  1858. -- 计算键盘位置(屏幕中下对齐底边)
  1859. local sw = render_state.viewport_w
  1860. local sh = render_state.viewport_h
  1861. self.x = (sw - self.w) // 2 -- 水平居中
  1862. self.y = sh - self.h -- 底部对齐
  1863. -- 重新构建按键布局
  1864. self:build_key_layout()
  1865. self.visible = true
  1866. self.enabled = true
  1867. -- 重置状态
  1868. self.selectedKey = nil
  1869. self.currentCandidates = {}
  1870. self.displayStartIndex = 1
  1871. -- 重置拼音输入状态
  1872. self.keySequence = {}
  1873. self.syllableCandidates = {}
  1874. self.selectedSyllableIndex = 1
  1875. self.currentSyllable = ""
  1876. self.pinyinCandidates = {}
  1877. self.selectedCandidateIndex = 1
  1878. self.syllablePageIndex = 1
  1879. self.candidatePageIndex = 1
  1880. self._pressedSyllableIndex = nil
  1881. self._pressedCandidateIndex = nil
  1882. -- 添加到运行时根组件列表(顶层显示)
  1883. runtime.add(self)
  1884. end
  1885. function keyboard:hide()
  1886. self.visible = false
  1887. self.enabled = false
  1888. -- 从运行时根组件列表移除
  1889. runtime.remove(self)
  1890. -- 通知 Input 组件失去焦点
  1891. if self.input and self.input.focused then
  1892. self.input:blur()
  1893. end
  1894. end
  1895. function keyboard:is_visible()
  1896. return self.visible
  1897. end
  1898. function keyboard:set_input_type(inputType)
  1899. -- 根据输入类型切换键盘模式
  1900. if inputType == "number" then
  1901. self:switch_to_number_mode()
  1902. else
  1903. self:switch_to_letter_mode()
  1904. end
  1905. end
  1906. function keyboard:switch_to_number_mode()
  1907. if not self.isNumberMode then
  1908. self.isNumberMode = true
  1909. self.isPinyin9KeyMode = false -- 切换到数字模式时关闭拼音模式
  1910. self.keyMappings = self.numberMappings
  1911. self:build_key_layout()
  1912. -- 清除候选字符状态
  1913. self.selectedKey = nil
  1914. self.currentCandidates = {}
  1915. self._pressedCandidateIndex = nil
  1916. -- 清除拼音输入状态
  1917. self.keySequence = {}
  1918. self.syllableCandidates = {}
  1919. self.currentSyllable = ""
  1920. self.pinyinCandidates = {}
  1921. self:invalidate()
  1922. end
  1923. end
  1924. function keyboard:switch_to_letter_mode()
  1925. if self.isNumberMode then
  1926. self.isNumberMode = false
  1927. -- 切换到字母模式时不清除拼音模式(保持当前状态)
  1928. self.keyMappings = self.letterMappings
  1929. self:build_key_layout()
  1930. -- 清除候选字符状态
  1931. self.selectedKey = nil
  1932. self.currentCandidates = {}
  1933. self._pressedCandidateIndex = nil
  1934. self:invalidate()
  1935. end
  1936. end
  1937. -- 切换到9键拼音模式
  1938. function keyboard:switch_to_pinyin_9key_mode()
  1939. self.isPinyin9KeyMode = true
  1940. self.keySequence = {}
  1941. self.syllableCandidates = {}
  1942. self.selectedSyllableIndex = 1
  1943. self.currentSyllable = ""
  1944. self.pinyinCandidates = {}
  1945. self.selectedCandidateIndex = 1
  1946. self.syllablePageIndex = 1
  1947. self.candidatePageIndex = 1
  1948. self._pressedSyllableIndex = nil
  1949. -- 加载pinyin模块(模组自带的核心库,不需要require)
  1950. if not self.pinyinModule then
  1951. self.pinyinModule = pinyin
  1952. if not self.pinyinModule then
  1953. log.warn("Keyboard", "pinyin模块不可用")
  1954. self.isPinyin9KeyMode = false
  1955. return false
  1956. end
  1957. end
  1958. self:invalidate()
  1959. return true
  1960. end
  1961. -- 处理9键输入
  1962. function keyboard:on_pinyin_9key_input(keyId)
  1963. -- keyId: 1-8 对应 ABC-WXYZ
  1964. if not self.isPinyin9KeyMode then
  1965. return
  1966. end
  1967. -- 限制按键序列最大长度为5(中文最多5个音节拼音)
  1968. if #self.keySequence >= 5 then
  1969. log.warn("Keyboard", "按键序列已达最大长度5")
  1970. return
  1971. end
  1972. -- 如果已经有选中的音节,先清除候选字状态
  1973. if self.currentSyllable ~= "" then
  1974. self.currentSyllable = ""
  1975. self.pinyinCandidates = {}
  1976. self.selectedCandidateIndex = 1
  1977. self.candidatePageIndex = 1
  1978. end
  1979. -- 追加到按键序列
  1980. table.insert(self.keySequence, keyId)
  1981. -- 查询可能的音节(输入第一个按键后即开始显示)
  1982. if self.pinyinModule and self.pinyinModule.querySyllables then
  1983. local syllables = self.pinyinModule.querySyllables(self.keySequence)
  1984. self.syllableCandidates = syllables or {}
  1985. self.selectedSyllableIndex = 1
  1986. self.syllablePageIndex = 1
  1987. log.info("Keyboard", "按键序列:", table.concat(self.keySequence, ","),
  1988. "音节数:", #self.syllableCandidates)
  1989. else
  1990. self.syllableCandidates = {}
  1991. end
  1992. self:invalidate()
  1993. end
  1994. -- 选择音节
  1995. function keyboard:select_syllable(syllable)
  1996. if not self.isPinyin9KeyMode or not syllable then
  1997. return false
  1998. end
  1999. -- 确认选中的音节
  2000. self.currentSyllable = syllable
  2001. -- 查询该音节对应的候选字(使用queryUtf8直接返回UTF-8字符串数组)
  2002. if self.pinyinModule and self.pinyinModule.queryUtf8 then
  2003. local chars = self.pinyinModule.queryUtf8(syllable)
  2004. self.pinyinCandidates = chars or {}
  2005. self.selectedCandidateIndex = 1
  2006. self.candidatePageIndex = 1
  2007. log.info("Keyboard", "选中音节:", syllable, "候选字数:", #self.pinyinCandidates)
  2008. else
  2009. self.pinyinCandidates = {}
  2010. end
  2011. -- 清空按键序列(准备输入下一个字)
  2012. self.keySequence = {}
  2013. self.syllableCandidates = {}
  2014. self.selectedSyllableIndex = 1
  2015. self.syllablePageIndex = 1
  2016. self:invalidate()
  2017. return true
  2018. end
  2019. -- 选择候选字
  2020. function keyboard:select_candidate(index)
  2021. if not self.isPinyin9KeyMode then
  2022. return false
  2023. end
  2024. -- index 是相对索引(1-8),需要计算实际索引(考虑分页)
  2025. local actualIndex = (self.candidatePageIndex - 1) * 8 + index
  2026. if actualIndex >= 1 and actualIndex <= #self.pinyinCandidates then
  2027. local char = self.pinyinCandidates[actualIndex] -- 直接使用UTF-8字符串
  2028. -- 插入到输入框
  2029. if self.input then
  2030. self.input:insert_text(char)
  2031. end
  2032. -- 重置状态,准备输入下一个字
  2033. self.currentSyllable = ""
  2034. self.pinyinCandidates = {}
  2035. self.selectedCandidateIndex = 1
  2036. self.candidatePageIndex = 1
  2037. self:invalidate()
  2038. return true
  2039. end
  2040. return false
  2041. end
  2042. -- 删除键处理(9键模式)
  2043. function keyboard:on_pinyin_9key_delete()
  2044. if not self.isPinyin9KeyMode then
  2045. return
  2046. end
  2047. -- 如果正在选择音节(有按键序列未确认)
  2048. if #self.keySequence > 0 then
  2049. -- 删除最后一个按键,并根据剩余按键序列重新查询音节
  2050. table.remove(self.keySequence, #self.keySequence)
  2051. if #self.keySequence > 0 then
  2052. if self.pinyinModule and self.pinyinModule.querySyllables then
  2053. local syllables = self.pinyinModule.querySyllables(self.keySequence)
  2054. self.syllableCandidates = syllables or {}
  2055. self.selectedSyllableIndex = 1
  2056. self.syllablePageIndex = 1
  2057. else
  2058. self.syllableCandidates = {}
  2059. self.selectedSyllableIndex = 1
  2060. self.syllablePageIndex = 1
  2061. end
  2062. else
  2063. -- 没有按键了,清空音节候选
  2064. self.syllableCandidates = {}
  2065. self.selectedSyllableIndex = 1
  2066. self.syllablePageIndex = 1
  2067. end
  2068. log.info("Keyboard", "删除一位按键,当前序列:", table.concat(self.keySequence, ","))
  2069. self:invalidate()
  2070. return
  2071. end
  2072. -- 如果已选中音节并在选择候选汉字阶段,删除应清空音节并回到按键输入
  2073. if self.currentSyllable ~= "" then
  2074. self.currentSyllable = ""
  2075. self.pinyinCandidates = {}
  2076. self.selectedCandidateIndex = 1
  2077. self.candidatePageIndex = 1
  2078. log.info("Keyboard", "清空已选音节,返回按键输入阶段")
  2079. self:invalidate()
  2080. return
  2081. else
  2082. -- 没有选择音节,删除输入框中的最后一个字符
  2083. if self.input then
  2084. local currentText = self.input:get_text()
  2085. if currentText and #currentText > 0 then
  2086. -- 按 UTF-8 字符删除最后一个字符,避免残留半个字节导致 "�"
  2087. local lastStart = 1
  2088. local i = 1
  2089. while i <= #currentText do
  2090. local _, charLen = get_utf8_char(currentText, i)
  2091. lastStart = i
  2092. i = i + math.max(charLen, 1)
  2093. end
  2094. local deleteLen = #currentText - lastStart + 1
  2095. self.input:delete_text(lastStart, deleteLen)
  2096. end
  2097. end
  2098. end
  2099. end
  2100. -- 绘制方法
  2101. function keyboard:draw(ctx)
  2102. if not self.visible then return end
  2103. local ax, ay = self:get_absolute_position()
  2104. local dark = (current_theme == "dark")
  2105. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2106. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2107. -- 绘制键盘背景
  2108. ctx:fill_rect(ax, ay, self.w, self.h, bg_color)
  2109. ctx:stroke_rect(ax, ay, self.w, self.h, border_color)
  2110. -- 绘制顶部控制栏(返回按钮和预览区)
  2111. self:draw_top_bar(ctx, ax, ay)
  2112. -- 绘制候选区(音节或候选字)
  2113. if self.isPinyin9KeyMode then
  2114. -- 显示候选字选择区(始终显示)
  2115. self:draw_pinyin_candidates(ctx, ax, ay)
  2116. else
  2117. -- 显示预览区(英文模式)
  2118. self:draw_preview_area(ctx, ax, ay)
  2119. -- 绘制候选字符区(英文模式)
  2120. self:draw_candidate_area(ctx, ax, ay)
  2121. end
  2122. -- 绘制左侧音节选择区(9键拼音模式)
  2123. if self.isPinyin9KeyMode then
  2124. self:draw_left_syllable_panel(ctx, ax, ay)
  2125. end
  2126. -- 绘制按键
  2127. for i = 1, #self.keyLayout do
  2128. self:draw_key(ctx, self.keyLayout[i])
  2129. end
  2130. end
  2131. function keyboard:draw_top_bar(ctx, ax, ay)
  2132. local dark = (current_theme == "dark")
  2133. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2134. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2135. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2136. local button_bg_color = bg_color
  2137. -- 返回按钮
  2138. local backBtnX = ax + 10
  2139. local backBtnY = ay + 5
  2140. local backBtnW = 60
  2141. local backBtnH = 35
  2142. -- 检查返回按钮是否被按下
  2143. local backBtnbg_color = (self.enable_click_effect and self._backButtonPressed) and COLOR_GRAY or button_bg_color
  2144. ctx:fill_rect(backBtnX, backBtnY, backBtnW, backBtnH, backBtnbg_color)
  2145. ctx:stroke_rect(backBtnX, backBtnY, backBtnW, backBtnH, border_color)
  2146. local back_text = "返回"
  2147. local back_style = { size = 12 }
  2148. ctx:draw_text_in_rect_centered(backBtnX, backBtnY, backBtnW, backBtnH, back_text, {
  2149. color = text_color,
  2150. style = back_style
  2151. })
  2152. -- 输入预览区
  2153. if self.input then
  2154. local previewX = backBtnX + backBtnW + 10 -- 预览框起始位置
  2155. local previewW = self.w - 90 -- 预览框宽度:300px - 90px = 210px
  2156. local previewText = self.input:get_text()
  2157. -- 处理预览文本显示(上方仅预览已输入的汉字/文本,不再显示音节)
  2158. local displayText = previewText
  2159. if displayText == "" then
  2160. displayText = "输入预览"
  2161. else
  2162. local style = { size = 12 }
  2163. local textWidth = ctx:text_width(displayText, style)
  2164. local maxTextWidth = previewW - 20 -- 左右各留10像素
  2165. if textWidth > maxTextWidth then
  2166. displayText = fit_text_to_width(displayText, maxTextWidth, style, { ellipsis = false })
  2167. end
  2168. end
  2169. -- 输入预览区:有边框,高35px
  2170. local previewAreaY = backBtnY
  2171. local previewAreaH = backBtnH
  2172. ctx:fill_rect(previewX, previewAreaY, previewW, previewAreaH, button_bg_color)
  2173. ctx:stroke_rect(previewX, previewAreaY, previewW, previewAreaH, border_color)
  2174. -- 左对齐绘制,左边距10px
  2175. local previewtext_color = (previewText == "") and COLOR_GRAY or text_color
  2176. ctx:draw_text(displayText, previewX + 10, previewAreaY + (previewAreaH - ctx:line_height({ size = 12 })) // 2,
  2177. previewtext_color, { size = 12 })
  2178. -- 新增:音节预览区(位于预览区下方5px,高20px,无边框)
  2179. if self.isPinyin9KeyMode then
  2180. local syllablePreviewY = previewAreaY + previewAreaH
  2181. local syllableText = ""
  2182. if #self.keySequence > 0 then
  2183. -- 显示按键序列(如:abc+mno)
  2184. local keyToLetters = {
  2185. [1] = "abc",
  2186. [2] = "def",
  2187. [3] = "ghi",
  2188. [4] = "jkl",
  2189. [5] = "mno",
  2190. [6] = "pqrs",
  2191. [7] = "tuv",
  2192. [8] = "wxyz"
  2193. }
  2194. local keyPreview = {}
  2195. for _, keyId in ipairs(self.keySequence) do
  2196. table.insert(keyPreview, keyToLetters[keyId] or "")
  2197. end
  2198. syllableText = table.concat(keyPreview, "+")
  2199. elseif self.currentSyllable ~= "" then
  2200. -- 显示已选中的音节
  2201. syllableText = self.currentSyllable
  2202. end
  2203. if syllableText ~= "" then
  2204. ctx:draw_text(syllableText, previewX + 10,
  2205. syllablePreviewY + (20 - ctx:line_height({ size = 12 })) // 2,
  2206. text_color, { size = 12 })
  2207. end
  2208. end
  2209. end
  2210. end
  2211. function keyboard:draw_preview_area(ctx, ax, ay)
  2212. if not self.input then return end
  2213. local previewY = ay + 5 -- 和返回按键平行
  2214. local previewHeight = 35 -- 和返回按键高度一致
  2215. local previewX = ax + 80 -- 预览框起始位置(返回键后)
  2216. local previewW = self.w - 90 -- 预览框宽度
  2217. local dark = (current_theme == "dark")
  2218. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2219. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2220. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2221. -- 绘制预览区背景
  2222. ctx:fill_rect(previewX, previewY, previewW, previewHeight, bg_color)
  2223. ctx:stroke_rect(previewX, previewY, previewW, previewHeight, border_color)
  2224. -- 绘制预览文本
  2225. local previewText = self.input:get_text() or ""
  2226. if previewText == "" then
  2227. previewText = "输入预览"
  2228. text_color = COLOR_GRAY
  2229. end
  2230. -- 处理长文本
  2231. local previewStyle = { size = 12 }
  2232. local textWidth = ctx:text_width(previewText, previewStyle)
  2233. local maxTextWidth = previewW - 20 -- 左右各留10像素
  2234. if textWidth > maxTextWidth then
  2235. previewText = fit_text_to_width(previewText, maxTextWidth, previewStyle, { ellipsis = false })
  2236. end
  2237. local textHeight = ctx:line_height(previewStyle)
  2238. local textX = previewX + 10
  2239. local textY = previewY + (previewHeight - textHeight) // 2
  2240. ctx:draw_text(previewText, textX, textY, text_color, previewStyle)
  2241. end
  2242. function keyboard:draw_key(ctx, key)
  2243. local dark = (current_theme == "dark")
  2244. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2245. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2246. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2247. local presse_dbg_color = COLOR_GRAY
  2248. local btnbg_color = (self.enable_click_effect and key.pressed) and presse_dbg_color or bg_color
  2249. ctx:fill_rect(key.x, key.y, key.w, key.h, btnbg_color)
  2250. ctx:stroke_rect(key.x, key.y, key.w, key.h, border_color)
  2251. -- -- 绘制按键文本
  2252. local displayText = key.text
  2253. local textStyle = { size = 12 }
  2254. local textWidth = ctx:text_width(displayText, textStyle)
  2255. local textHeight = ctx:line_height(textStyle)
  2256. local textX = key.x + (key.w - textWidth) // 2
  2257. local textY = key.y + (key.h - textHeight) // 2
  2258. ctx:draw_text(displayText, textX, textY, text_color, textStyle)
  2259. end
  2260. -- 事件处理
  2261. function keyboard:handle_event(evt, x, y)
  2262. if not self.visible or not self.enabled then
  2263. return false
  2264. end
  2265. local inside = self:contains_point(x or 0, y or 0)
  2266. -- 检查是否点击了返回按钮
  2267. local backBtnX = self.x + 10
  2268. local backBtnY = self.y + 5
  2269. local backBtnW = 60
  2270. local backBtnH = 40
  2271. if x >= backBtnX and x < backBtnX + backBtnW and
  2272. y >= backBtnY and y < backBtnY + backBtnH then
  2273. if evt == "TOUCH_DOWN" then
  2274. self._backButtonPressed = true
  2275. self:invalidate()
  2276. return true
  2277. elseif evt == "SINGLE_TAP" then
  2278. self._backButtonPressed = false
  2279. self:hide()
  2280. return true
  2281. elseif evt == "MOVE_X" or evt == "MOVE_Y" then
  2282. self._backButtonPressed = false
  2283. self:invalidate()
  2284. return true
  2285. end
  2286. end
  2287. -- 处理候选字左右翻页按键(9键拼音模式)
  2288. if self.isPinyin9KeyMode and #self.pinyinCandidates > 0 then
  2289. if self:handle_candidate_arrow_touch(evt, x, y) then
  2290. return true
  2291. end
  2292. end
  2293. -- 处理候选字选择区触摸(9键拼音模式)
  2294. if self.isPinyin9KeyMode and #self.pinyinCandidates > 0 then
  2295. if self:handle_candidate_panel_touch(evt, x, y) then
  2296. return true
  2297. end
  2298. end
  2299. -- 处理音节选择区触摸(9键拼音模式)
  2300. if self.isPinyin9KeyMode and #self.syllableCandidates > 0 and self.currentSyllable == "" then
  2301. if self:handle_syllable_panel_touch(evt, x, y) then
  2302. return true
  2303. end
  2304. end
  2305. -- 处理候选字符选择(英文模式)
  2306. if not self.isPinyin9KeyMode and #self.currentCandidates > 0 then
  2307. local candidateY = self.y + 50
  2308. local candidateHeight = 50
  2309. local candidateBtnSize = 30
  2310. for i = 1, 10 do
  2311. local btnX = self.x + (i - 1) * candidateBtnSize
  2312. local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2313. if i <= #self.currentCandidates and
  2314. x >= btnX and x < btnX + candidateBtnSize and
  2315. y >= btnY and y < btnY + candidateBtnSize then
  2316. if evt == "TOUCH_DOWN" then
  2317. self._pressedCandidateIndex = i
  2318. self:invalidate()
  2319. return true
  2320. elseif evt == "SINGLE_TAP" then
  2321. local char = self.currentCandidates[i]
  2322. self:on_candidate_selected(char)
  2323. return true
  2324. elseif evt == "MOVE_X" or evt == "MOVE_Y" then
  2325. if self._pressedCandidateIndex ~= i then
  2326. self._pressedCandidateIndex = nil
  2327. self:invalidate()
  2328. end
  2329. return true
  2330. end
  2331. end
  2332. end
  2333. end
  2334. -- 处理按键点击
  2335. if evt == "TOUCH_DOWN" and inside then
  2336. -- 检查是否点击了按键
  2337. for i = 1, #self.keyLayout do
  2338. local key = self.keyLayout[i]
  2339. if x >= key.x and x < key.x + key.w and
  2340. y >= key.y and y < key.y + key.h then
  2341. key.pressed = true
  2342. self._capture = true
  2343. self:invalidate()
  2344. return true
  2345. end
  2346. end
  2347. return true
  2348. elseif evt == "SINGLE_TAP" and self._capture then
  2349. -- 处理按键释放
  2350. for i = 1, #self.keyLayout do
  2351. local key = self.keyLayout[i]
  2352. if key.pressed then
  2353. key.pressed = false
  2354. self:on_key_pressed(key)
  2355. self:invalidate()
  2356. break
  2357. end
  2358. end
  2359. self._capture = false
  2360. return true
  2361. elseif (evt == "MOVE_X" or evt == "MOVE_Y") and self._capture then
  2362. -- 更新按键按下状态
  2363. for i = 1, #self.keyLayout do
  2364. local key = self.keyLayout[i]
  2365. local wasPressed = key.pressed
  2366. key.pressed = (x >= key.x and x < key.x + key.w and
  2367. y >= key.y and y < key.y + key.h)
  2368. if wasPressed ~= key.pressed then
  2369. self:invalidate()
  2370. end
  2371. end
  2372. return true
  2373. end
  2374. return false
  2375. end
  2376. -- 处理按键按下
  2377. function keyboard:on_key_pressed(key)
  2378. if not key then return end
  2379. -- 删除键处理
  2380. if key.type == "delete" then
  2381. -- 9键拼音模式下的删除键处理
  2382. if self.isPinyin9KeyMode then
  2383. self:on_pinyin_9key_delete()
  2384. return
  2385. end
  2386. -- 清除候选字符状态
  2387. self.selectedKey = nil
  2388. self.currentCandidates = {}
  2389. self._pressedCandidateIndex = nil
  2390. if self.input then
  2391. local currentText = self.input:get_text()
  2392. if currentText and #currentText > 0 then
  2393. -- 按 UTF-8 字符删除最后一个字符
  2394. local lastStart = 1
  2395. local i = 1
  2396. while i <= #currentText do
  2397. local _, charLen = get_utf8_char(currentText, i)
  2398. lastStart = i
  2399. i = i + math.max(charLen, 1)
  2400. end
  2401. local deleteLen = #currentText - lastStart + 1
  2402. self.input:delete_text(lastStart, deleteLen)
  2403. end
  2404. end
  2405. self:invalidate()
  2406. return
  2407. end
  2408. -- 数字/字母模式切换
  2409. if key.type == "num" then
  2410. self:switch_to_number_mode()
  2411. return
  2412. elseif key.type == "letter" then
  2413. self:switch_to_letter_mode()
  2414. return
  2415. end
  2416. -- 9键拼音模式下的字母键处理
  2417. if self.isPinyin9KeyMode and key.type == "letters" and key.keyId then
  2418. self:on_pinyin_9key_input(key.keyId)
  2419. return
  2420. end
  2421. -- 9键拼音模式下的空格键处理
  2422. if self.isPinyin9KeyMode and key.type == "space" then
  2423. if self.input then
  2424. self.input:insert_text(" ")
  2425. end
  2426. return
  2427. end
  2428. -- 语言切换键(中/EN)
  2429. if key.type == "lang" then
  2430. if self.isPinyin9KeyMode then
  2431. -- 关闭拼音模式,切换到英文模式
  2432. self.isPinyin9KeyMode = false
  2433. -- 清除拼音输入状态
  2434. self.keySequence = {}
  2435. self.syllableCandidates = {}
  2436. self.currentSyllable = ""
  2437. self.pinyinCandidates = {}
  2438. self.selectedCandidateIndex = 1
  2439. self.syllablePageIndex = 1
  2440. self.candidatePageIndex = 1
  2441. self._pressedSyllableIndex = nil
  2442. -- 如果当前是数字模式,需要先切换到字母模式
  2443. if self.isNumberMode then
  2444. self:switch_to_letter_mode()
  2445. end
  2446. else
  2447. -- 切换到拼音模式
  2448. -- 如果当前是数字模式,需要先切换到字母模式
  2449. if self.isNumberMode then
  2450. self:switch_to_letter_mode()
  2451. end
  2452. -- 尝试切换到拼音模式
  2453. local success = self:switch_to_pinyin_9key_mode()
  2454. if not success then
  2455. log.warn("Keyboard", "切换到拼音模式失败,pinyin模块不可用")
  2456. end
  2457. end
  2458. self:invalidate()
  2459. return
  2460. end
  2461. -- 数字/字母模式切换
  2462. if key.type == "num" then
  2463. self:switch_to_number_mode()
  2464. return
  2465. elseif key.type == "letter" then
  2466. self:switch_to_letter_mode()
  2467. return
  2468. end
  2469. -- 普通按键处理(字母/数字)
  2470. if key.chars and #key.chars > 0 then
  2471. -- 清除之前的候选字符状态
  2472. self.selectedKey = nil
  2473. self.currentCandidates = {}
  2474. self._pressedCandidateIndex = nil
  2475. -- 处理数字直接输入
  2476. if key.type == "number" then
  2477. local char = key.chars[1]
  2478. if self.input then
  2479. self.input:insert_text(char)
  2480. end
  2481. -- 处理空格键
  2482. elseif key.type == "space" then
  2483. if self.input then
  2484. self.input:insert_text(" ")
  2485. end
  2486. else
  2487. -- 字母键:显示候选字符,让用户选择
  2488. self.selectedKey = key
  2489. self.currentCandidates = key.chars
  2490. self:invalidate()
  2491. end
  2492. end
  2493. end
  2494. -- 处理候选字符选择
  2495. function keyboard:on_candidate_selected(char)
  2496. if char and char ~= "" then
  2497. if self.input then
  2498. self.input:insert_text(char)
  2499. end
  2500. end
  2501. -- 清除候选状态和按键状态
  2502. self.selectedKey = nil
  2503. self.currentCandidates = {}
  2504. self._pressedCandidateIndex = nil
  2505. -- 清除所有按键状态
  2506. for i = 1, #self.keyLayout do
  2507. self.keyLayout[i].pressed = false
  2508. end
  2509. self:invalidate()
  2510. end
  2511. -- 绘制候选字符区
  2512. function keyboard:draw_candidate_area(ctx, ax, ay)
  2513. local candidateY = ay + 50 -- 候选区Y坐标(预览区下方10px)
  2514. local candidateHeight = 50
  2515. local candidateBtnSize = 30
  2516. local dark = (current_theme == "dark")
  2517. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2518. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2519. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2520. local presse_dbg_color = COLOR_GRAY
  2521. -- 候选按键固定10个,从左到右排列
  2522. for i = 1, 10 do
  2523. local btnX = ax + (i - 1) * candidateBtnSize
  2524. local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2525. -- 根据是否有候选字符决定显示内容
  2526. if i <= #self.currentCandidates then
  2527. local char = self.currentCandidates[i]
  2528. -- 检查候选按键是否被按下
  2529. local isPressed = (self._pressedCandidateIndex == i)
  2530. local btnbg_color = (self.enable_click_effect and isPressed) and presse_dbg_color or bg_color
  2531. ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, btnbg_color)
  2532. ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
  2533. -- 绘制候选字符文本
  2534. local textStyle = { size = 12 }
  2535. local textWidth = ctx:text_width(char, textStyle)
  2536. local textHeight = ctx:line_height(textStyle)
  2537. local textX = btnX + (candidateBtnSize - textWidth) // 2
  2538. local textY = btnY + (candidateBtnSize - textHeight) // 2
  2539. ctx:draw_text(char, textX, textY, text_color, textStyle)
  2540. else
  2541. -- 没有候选字符时显示空按钮
  2542. ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, bg_color)
  2543. ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
  2544. end
  2545. end
  2546. end
  2547. -- 处理候选字左右翻页按键
  2548. function keyboard:handle_candidate_arrow_touch(evt, x, y)
  2549. local candidateY = self.y + 50
  2550. local candidateHeight = 50
  2551. local candidateBtnSize = 30
  2552. local arrowW = candidateBtnSize
  2553. -- 左侧翻页按键(←)
  2554. local leftArrowX = self.x
  2555. local leftArrowY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2556. if x >= leftArrowX and x < leftArrowX + arrowW and
  2557. y >= leftArrowY and y < leftArrowY + candidateBtnSize then
  2558. if evt == "SINGLE_TAP" then
  2559. if self.candidatePageIndex > 1 then
  2560. self.candidatePageIndex = self.candidatePageIndex - 1
  2561. self.selectedCandidateIndex = (self.candidatePageIndex - 1) * 8 + 1
  2562. self:invalidate()
  2563. end
  2564. return true
  2565. end
  2566. end
  2567. -- 右侧翻页按键(→)
  2568. local rightArrowX = self.x + self.w - arrowW
  2569. local rightArrowY = leftArrowY
  2570. if x >= rightArrowX and x < rightArrowX + arrowW and
  2571. y >= rightArrowY and y < rightArrowY + candidateBtnSize then
  2572. if evt == "SINGLE_TAP" then
  2573. local maxPage = math.ceil(#self.pinyinCandidates / 8)
  2574. if self.candidatePageIndex < maxPage then
  2575. self.candidatePageIndex = self.candidatePageIndex + 1
  2576. self.selectedCandidateIndex = (self.candidatePageIndex - 1) * 8 + 1
  2577. self:invalidate()
  2578. end
  2579. return true
  2580. end
  2581. end
  2582. return false
  2583. end
  2584. -- 处理候选字选择区触摸
  2585. function keyboard:handle_candidate_panel_touch(evt, x, y)
  2586. local candidateY = self.y + 50
  2587. local candidateHeight = 50
  2588. local candidateBtnSize = 30
  2589. local arrowW = candidateBtnSize
  2590. local candidatestart_x = self.x + arrowW
  2591. for i = 1, 8 do
  2592. local idx = (self.candidatePageIndex - 1) * 8 + i
  2593. local btnX = candidatestart_x + (i - 1) * candidateBtnSize
  2594. local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2595. if idx <= #self.pinyinCandidates and
  2596. x >= btnX and x < btnX + candidateBtnSize and
  2597. y >= btnY and y < btnY + candidateBtnSize then
  2598. if evt == "TOUCH_DOWN" then
  2599. self._pressedCandidateIndex = idx
  2600. self:invalidate()
  2601. return true
  2602. elseif evt == "SINGLE_TAP" then
  2603. self:select_candidate(i) -- 传入相对索引(1-8)
  2604. self._pressedCandidateIndex = nil
  2605. return true
  2606. elseif evt == "MOVE_X" or evt == "MOVE_Y" then
  2607. if self._pressedCandidateIndex ~= idx then
  2608. self._pressedCandidateIndex = nil
  2609. self:invalidate()
  2610. end
  2611. return true
  2612. end
  2613. end
  2614. end
  2615. return false
  2616. end
  2617. -- 处理音节选择区触摸
  2618. function keyboard:handle_syllable_panel_touch(evt, x, y)
  2619. local syllableBtnSize = 30
  2620. local syllableAreaX = self.x
  2621. local syllableAreaY = self.y + 95
  2622. local start_y = syllableAreaY
  2623. -- 上一页按钮(第一个小格子)
  2624. local topBtnY = start_y
  2625. if x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
  2626. y >= topBtnY and y < topBtnY + syllableBtnSize then
  2627. if evt == "SINGLE_TAP" then
  2628. if self.syllablePageIndex > 1 then
  2629. self.syllablePageIndex = self.syllablePageIndex - 1
  2630. self.selectedSyllableIndex = (self.syllablePageIndex - 1) * 10 + 1
  2631. self:invalidate()
  2632. end
  2633. return true
  2634. end
  2635. end
  2636. -- 中间10个音节按钮(从第二个小格子开始)
  2637. local syllablestart_y = start_y + syllableBtnSize
  2638. for i = 1, 10 do
  2639. local idx = (self.syllablePageIndex - 1) * 10 + i
  2640. local btnY = syllablestart_y + (i - 1) * syllableBtnSize
  2641. if idx <= #self.syllableCandidates and
  2642. x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
  2643. y >= btnY and y < btnY + syllableBtnSize then
  2644. if evt == "TOUCH_DOWN" then
  2645. self._pressedSyllableIndex = idx
  2646. self:invalidate()
  2647. return true
  2648. elseif evt == "SINGLE_TAP" then
  2649. local syllable = self.syllableCandidates[idx]
  2650. self.selectedSyllableIndex = idx
  2651. self:select_syllable(syllable)
  2652. self._pressedSyllableIndex = nil
  2653. return true
  2654. elseif evt == "MOVE_X" or evt == "MOVE_Y" then
  2655. if self._pressedSyllableIndex ~= idx then
  2656. self._pressedSyllableIndex = nil
  2657. self:invalidate()
  2658. end
  2659. return true
  2660. end
  2661. end
  2662. end
  2663. -- 下一页按钮(第12个小格子)
  2664. local bottomBtnY = start_y + 11 * syllableBtnSize
  2665. if x >= syllableAreaX and x < syllableAreaX + syllableBtnSize and
  2666. y >= bottomBtnY and y < bottomBtnY + syllableBtnSize then
  2667. if evt == "SINGLE_TAP" then
  2668. local maxPage = math.ceil(#self.syllableCandidates / 10)
  2669. if self.syllablePageIndex < maxPage then
  2670. self.syllablePageIndex = self.syllablePageIndex + 1
  2671. self.selectedSyllableIndex = (self.syllablePageIndex - 1) * 10 + 1
  2672. self:invalidate()
  2673. end
  2674. return true
  2675. end
  2676. end
  2677. return false
  2678. end
  2679. -- 绘制左侧音节选择区
  2680. function keyboard:draw_left_syllable_panel(ctx, ax, ay)
  2681. local syllableBtnSize = 30 -- 每个音节按钮大小(30x30)
  2682. local syllableAreaX = ax -- 左侧预留区域X坐标
  2683. local syllableAreaY = ay + 95 -- 从按键区域上方开始(与大格子对齐)
  2684. -- 大格子高度是90px,4个大格子总高度360px
  2685. -- 12个小格子,每个30px,总共360px,正好对齐
  2686. -- 每3个小格子对齐一个大格子(90px = 3 * 30px)
  2687. local keySize = 90 -- 大格子高度
  2688. local totalHeight = 4 * keySize -- 4个大格子的总高度 = 360px
  2689. local dark = (current_theme == "dark")
  2690. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2691. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2692. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2693. local selecte_dbg_color = COLOR_SKY_BLUE
  2694. local selected_text_color = COLOR_WHITE
  2695. local presse_dbg_color = COLOR_GRAY
  2696. -- 12个小格子,每个30px,总共360px,正好等于4个大格子的高度
  2697. local start_y = syllableAreaY
  2698. -- 1. 最上面的上一页切换按键(↑)- 第一个大格子的第一个小格子位置
  2699. local topBtnY = start_y
  2700. ctx:fill_rect(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, bg_color)
  2701. ctx:stroke_rect(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, border_color)
  2702. -- 使用 draw_arrow_icon 绘制箭头图标
  2703. draw_arrow_icon(syllableAreaX, topBtnY, syllableBtnSize, syllableBtnSize, "up", text_color)
  2704. -- 2. 中间10个音节选择按键
  2705. -- 从第二个小格子开始,每3个小格子对应一个大格子
  2706. -- 索引1是上一页,索引2-11是10个音节,索引12是下一页
  2707. local syllablestart_y = start_y + syllableBtnSize -- 从第二个小格子开始
  2708. for i = 1, 10 do
  2709. local idx = (self.syllablePageIndex - 1) * 10 + i
  2710. local btnY = syllablestart_y + (i - 1) * syllableBtnSize
  2711. if idx <= #self.syllableCandidates then
  2712. local syllable = self.syllableCandidates[idx]
  2713. local isSelected = (idx == self.selectedSyllableIndex)
  2714. local isPressed = (self._pressedSyllableIndex == idx)
  2715. local btnbg_color
  2716. if self.enable_click_effect and isPressed then
  2717. btnbg_color = presse_dbg_color
  2718. elseif isSelected then
  2719. btnbg_color = selecte_dbg_color
  2720. else
  2721. btnbg_color = bg_color
  2722. end
  2723. local btntext_color = (isSelected or (self.enable_click_effect and isPressed)) and selected_text_color or
  2724. text_color
  2725. ctx:fill_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, btnbg_color)
  2726. ctx:stroke_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, border_color)
  2727. ctx:draw_text_in_rect_centered(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, syllable, {
  2728. color = btntext_color,
  2729. style = { size = 10 }
  2730. })
  2731. else
  2732. -- 空按钮
  2733. ctx:fill_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, bg_color)
  2734. ctx:stroke_rect(syllableAreaX, btnY, syllableBtnSize, syllableBtnSize, border_color)
  2735. end
  2736. end
  2737. -- 3. 最下面的下一页切换按键(↓)- 第4个大格子的第3个小格子位置(最后一个)
  2738. local bottomBtnY = start_y + 11 * syllableBtnSize -- 第12个小格子(索引12)
  2739. ctx:fill_rect(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, bg_color)
  2740. ctx:stroke_rect(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, border_color)
  2741. draw_arrow_icon(syllableAreaX, bottomBtnY, syllableBtnSize, syllableBtnSize, "down", text_color)
  2742. end
  2743. -- 绘制候选字选择区
  2744. function keyboard:draw_pinyin_candidates(ctx, ax, ay)
  2745. local candidateY = ay + 50 -- 候选区Y坐标
  2746. local candidateHeight = 50
  2747. -- 中文候选带左右翻页:左右各占1格(30px),中间8格候选
  2748. local candidateBtnSize = 30 -- 每个候选按钮大小(30x30)
  2749. local dark = (current_theme == "dark")
  2750. local bg_color = dark and COLOR_WIN11_DARK_BUTTON_BG or COLOR_WIN11_LIGHT_BUTTON_BG
  2751. local border_color = dark and COLOR_WIN11_DARK_BUTTON_BORDER or COLOR_WIN11_LIGHT_BUTTON_BORDER
  2752. local text_color = dark and COLOR_WHITE or COLOR_BLACK
  2753. local selecte_dbg_color = COLOR_SKY_BLUE
  2754. local selected_text_color = COLOR_WHITE
  2755. local presse_dbg_color = COLOR_GRAY
  2756. -- 左侧分页按键(←)
  2757. local arrowW = candidateBtnSize
  2758. local leftArrowX = ax
  2759. local leftArrowY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2760. ctx:fill_rect(leftArrowX, leftArrowY, arrowW, candidateBtnSize, bg_color)
  2761. ctx:stroke_rect(leftArrowX, leftArrowY, arrowW, candidateBtnSize, border_color)
  2762. draw_arrow_icon(leftArrowX, leftArrowY, arrowW, candidateBtnSize, "left", text_color)
  2763. -- 右侧分页按键(→)
  2764. local rightArrowX = ax + self.w - arrowW
  2765. local rightArrowY = leftArrowY
  2766. ctx:fill_rect(rightArrowX, rightArrowY, arrowW, candidateBtnSize, bg_color)
  2767. ctx:stroke_rect(rightArrowX, rightArrowY, arrowW, candidateBtnSize, border_color)
  2768. draw_arrow_icon(rightArrowX, rightArrowY, arrowW, candidateBtnSize, "right", text_color)
  2769. -- 候选按键固定8个(居中区域,从 ax + arrowW 开始)
  2770. local candidatestart_x = ax + arrowW
  2771. for i = 1, 8 do
  2772. local idx = (self.candidatePageIndex - 1) * 8 + i
  2773. local btnX = candidatestart_x + (i - 1) * candidateBtnSize
  2774. local btnY = candidateY + (candidateHeight - candidateBtnSize) // 2
  2775. if idx <= #self.pinyinCandidates then
  2776. local char = self.pinyinCandidates[idx] -- 直接使用UTF-8字符串
  2777. local isSelected = (idx == self.selectedCandidateIndex)
  2778. local isPressed = (self._pressedCandidateIndex == idx)
  2779. local btnbg_color
  2780. if self.enable_click_effect and isPressed then
  2781. btnbg_color = presse_dbg_color
  2782. elseif isSelected then
  2783. btnbg_color = selecte_dbg_color
  2784. else
  2785. btnbg_color = bg_color
  2786. end
  2787. local btntext_color = (isSelected or (self.enable_click_effect and isPressed)) and selected_text_color or
  2788. text_color
  2789. ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, btnbg_color)
  2790. ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
  2791. -- 使用字体渲染候选字(优先使用hzfont,如果不可用则降级到其他字体后端)
  2792. -- 通过 ctx:draw_text 统一接口,字体后端在 ui.init() 中配置
  2793. local textStyle = { size = 12 }
  2794. ctx:draw_text_in_rect_centered(btnX, btnY, candidateBtnSize, candidateBtnSize, char, {
  2795. color = btntext_color,
  2796. style = textStyle
  2797. })
  2798. else
  2799. -- 空按钮
  2800. ctx:fill_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, bg_color)
  2801. ctx:stroke_rect(btnX, btnY, candidateBtnSize, candidateBtnSize, border_color)
  2802. end
  2803. end
  2804. end
  2805. -- 绘制音节候选区(显示在候选字选择区的位置,但内容不同)
  2806. function keyboard:draw_syllable_candidates(ctx, ax, ay)
  2807. -- 音节候选区暂时不单独绘制,由左侧音节选择区处理
  2808. -- 这里可以预留,如果需要显示音节预览可以在这里实现
  2809. end
  2810. ui.keyboard = function(opts)
  2811. return keyboard:new(opts)
  2812. end
  2813. -- 5.7 MessageBox
  2814. local message_box = setmetatable({}, { __index = BaseWidget })
  2815. message_box.__index = message_box
  2816. function message_box:new(opts)
  2817. opts = opts or {}
  2818. opts.w = opts.width or opts.w or 280
  2819. opts.h = opts.height or opts.h or 160
  2820. opts.x = opts.x or 20
  2821. opts.y = opts.y or 40
  2822. local o = BaseWidget.new(self, opts)
  2823. o.title = opts.title or "Info"
  2824. o.message = opts.message or ""
  2825. o.word_wrap = opts.word_wrap ~= false
  2826. local dark = (current_theme == "dark")
  2827. o.border_color = opts.border_color or (dark and COLOR_WHITE or COLOR_BLACK)
  2828. o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
  2829. o.bg_color = opts.bg_color or (dark and COLOR_WIN11_DARK_DIALOG_BG or COLOR_WIN11_LIGHT_DIALOG_BG)
  2830. o.buttons = opts.buttons or { "OK" }
  2831. o.on_result = opts.on_result
  2832. o.text_style = { size = opts.text_size or opts.size or 12 }
  2833. o._buttons = {}
  2834. o:_layout_buttons()
  2835. o:_layout_message()
  2836. return o
  2837. end
  2838. function message_box:_layout_buttons()
  2839. self._buttons = {}
  2840. local count = #self.buttons
  2841. if count == 0 then return end
  2842. local btnW = 80
  2843. local gap = 12
  2844. local total = count * btnW + (count - 1) * gap
  2845. local start_x = (self.w - total) // 2
  2846. local btnY = self.h - 12 - 36
  2847. for i = 1, count do
  2848. local label = tostring(self.buttons[i])
  2849. local btn = button:new({ x = start_x, y = btnY, w = btnW, h = 36, text = label })
  2850. btn.on_click = function()
  2851. if self.on_result then
  2852. local ok, err = pcall(self.on_result, label, self)
  2853. if not ok then
  2854. log.warn("MessageBox", "on_result error", err)
  2855. end
  2856. end
  2857. self.visible = false
  2858. end
  2859. self:add(btn)
  2860. self._buttons[#self._buttons + 1] = btn
  2861. start_x = start_x + btnW + gap
  2862. end
  2863. end
  2864. function message_box:_layout_message()
  2865. self._msgPadding = 10
  2866. self._msgstart_y = 36
  2867. local reserved = (#self.buttons > 0) and (12 + 36) or 10
  2868. self._msgHeight = self.h - reserved - self._msgstart_y
  2869. self._msgWidth = self.w - self._msgPadding * 2
  2870. if self.word_wrap then
  2871. self._messageLines = wrap_text_lines(self.message, self._msgWidth, self.text_style)
  2872. local lh = font_line_height(self.text_style)
  2873. self._maxLines = math.max(1, math.floor(self._msgHeight / lh))
  2874. else
  2875. self._messageLines = nil
  2876. end
  2877. end
  2878. function message_box:set_message(message)
  2879. self.message = tostring(message or "")
  2880. self:_layout_message()
  2881. self:invalidate()
  2882. end
  2883. function message_box:set_title(title)
  2884. self.title = tostring(title or "")
  2885. self:invalidate()
  2886. end
  2887. function message_box:show()
  2888. self.visible = true
  2889. self.enabled = true
  2890. self:invalidate()
  2891. end
  2892. function message_box:hide()
  2893. self.visible = false
  2894. self:invalidate()
  2895. end
  2896. function message_box:draw(ctx)
  2897. if not self.visible then return end
  2898. local ax, ay = self:get_absolute_position()
  2899. ctx:fill_rect(ax, ay, self.w, self.h, self.bg_color)
  2900. ctx:stroke_rect(ax, ay, self.w, self.h, self.border_color)
  2901. ctx:draw_text(self.title, ax + 10, ay + 8, self.text_color, self.text_style)
  2902. local style = self.text_style
  2903. local lh = ctx:line_height(style)
  2904. local start_y = ay + self._msgstart_y
  2905. if self.word_wrap then
  2906. local lines = self._messageLines or wrap_text_lines(self.message, self._msgWidth, style)
  2907. local limit = math.min(#lines, self._maxLines or #lines)
  2908. for i = 1, limit do
  2909. ctx:draw_text(lines[i], ax + self._msgPadding, start_y + (i - 1) * lh, self.text_color, style)
  2910. end
  2911. else
  2912. local text = fit_text_to_width(self.message, self._msgWidth, style, { ellipsis = true })
  2913. ctx:draw_text(text, ax + self._msgPadding, start_y, self.text_color, style)
  2914. end
  2915. end
  2916. function message_box:handle_event()
  2917. if not (self.visible and self.enabled) then return false end
  2918. return true
  2919. end
  2920. ui.message_box = function(opts)
  2921. return message_box:new(opts)
  2922. end
  2923. -- 5.6 Picture
  2924. local picture = setmetatable({}, { __index = BaseWidget })
  2925. picture.__index = picture
  2926. function picture:new(opts)
  2927. opts = opts or {}
  2928. local o = BaseWidget.new(self, opts)
  2929. o.src = opts.src
  2930. o.sources = opts.sources
  2931. o.index = opts.index or 1
  2932. o.autoplay = not not opts.autoplay
  2933. o.interval = opts.interval or 1000
  2934. o._last_switch = now_ms()
  2935. o._imageCache = {}
  2936. o._timer_id = nil
  2937. if o.w == 0 then o.w = 80 end
  2938. if o.h == 0 then o.h = 80 end
  2939. -- 如果启用自动播放,启动定时器
  2940. if o.autoplay and o.sources and #o.sources > 1 then
  2941. o:_start_autoplay_timer()
  2942. end
  2943. return o
  2944. end
  2945. function picture:set_sources(list)
  2946. self.sources = list
  2947. self.index = 1
  2948. -- 如果启用自动播放且有多个图片,重启定时器
  2949. if self.autoplay and list and #list > 1 then
  2950. self:_stop_autoplay_timer()
  2951. self:_start_autoplay_timer()
  2952. elseif not list or #list <= 1 then
  2953. self:_stop_autoplay_timer()
  2954. end
  2955. end
  2956. function picture:next()
  2957. if not self.sources or #self.sources == 0 then return end
  2958. self.index = (self.index % #self.sources) + 1
  2959. end
  2960. function picture:prev()
  2961. if not self.sources or #self.sources == 0 then return end
  2962. self.index = (self.index - 2) % #self.sources + 1
  2963. end
  2964. function picture:_start_autoplay_timer()
  2965. if self._timer_id then return end
  2966. if not (self.sources and #self.sources > 1) then return end
  2967. -- 使用定时器定期触发切换
  2968. local function autoplay_tick()
  2969. if not self.autoplay or not self.visible then
  2970. self:_stop_autoplay_timer()
  2971. return
  2972. end
  2973. if not self.sources or #self.sources <= 1 then
  2974. self:_stop_autoplay_timer()
  2975. return
  2976. end
  2977. local t = now_ms()
  2978. if (t - self._last_switch) >= self.interval then
  2979. self:next()
  2980. self._last_switch = t
  2981. self:invalidate()
  2982. end
  2983. end
  2984. -- 尝试使用 sys.timerLoopStart(如果可用)
  2985. if sys and sys.timerLoopStart then
  2986. -- 使用较短的检查间隔(100ms),确保及时响应
  2987. self._timer_id = sys.timerLoopStart(autoplay_tick, math.min(100, self.interval))
  2988. else
  2989. -- 如果没有定时器 API,回退到原来的方式(在 draw 中检查)
  2990. -- 这种情况下需要确保 ui.render() 被定期调用
  2991. self._timer_id = true -- 标记为已启用,但使用 draw() 中的逻辑
  2992. end
  2993. end
  2994. function picture:_stop_autoplay_timer()
  2995. if self._timer_id and sys and sys.timerStop then
  2996. sys.timerStop(self._timer_id)
  2997. end
  2998. self._timer_id = nil
  2999. end
  3000. function picture:play()
  3001. self.autoplay = true
  3002. if not self._timer_id then
  3003. self:_start_autoplay_timer()
  3004. end
  3005. end
  3006. function picture:pause()
  3007. self.autoplay = false
  3008. self:_stop_autoplay_timer()
  3009. end
  3010. function picture:draw()
  3011. if not self.visible then return end
  3012. local ax, ay = self:get_absolute_position()
  3013. local path = self.src
  3014. if self.sources and #self.sources > 0 then
  3015. path = self.sources[self.index]
  3016. end
  3017. if type(path) == "string" and path ~= "" then
  3018. -- 优先使用图片缓存(lcd.image2raw + lcd.draw)
  3019. if lcd and lcd.image2raw and lcd.draw then
  3020. local zbuff = ui.image_cache.get_zbuff(path)
  3021. if zbuff then
  3022. -- 使用 zbuff 绘制,lcd.draw 会自动使用 zbuff 内部的 width 和 height
  3023. lcd.draw(ax, ay, nil, nil, zbuff)
  3024. return
  3025. end
  3026. end
  3027. end
  3028. -- 绘制占位符
  3029. draw_image_placeholder(ax, ay, self.w, self.h, COLOR_GRAY, COLOR_WHITE)
  3030. end
  3031. function picture:handle_event()
  3032. return false
  3033. end
  3034. ui.picture = function(opts)
  3035. return picture:new(opts)
  3036. end
  3037. -- 5.7 ProgressBar
  3038. local progress_bar = setmetatable({}, { __index = BaseWidget })
  3039. progress_bar.__index = progress_bar
  3040. function progress_bar:new(opts)
  3041. opts = opts or {}
  3042. opts.w = opts.width or opts.w or 200
  3043. opts.h = opts.height or opts.h or 24
  3044. local o = BaseWidget.new(self, opts)
  3045. o.progress = math.max(0, math.min(100, opts.progress or 0))
  3046. o.show_percentage = opts.show_percentage ~= false
  3047. o.text = opts.text
  3048. o.text_style = { size = opts.text_size or opts.size or 12 }
  3049. local dark = (current_theme == "dark")
  3050. o.background_color = opts.background_color or (dark and COLOR_GRAY or 0xC618)
  3051. o.progress_color = opts.progress_color or (dark and COLOR_BLUE or COLOR_SKY_BLUE)
  3052. o.border_color = opts.border_color or (dark and COLOR_WHITE or 0x8410)
  3053. o.text_color = opts.text_color or (dark and COLOR_WHITE or COLOR_BLACK)
  3054. return o
  3055. end
  3056. function progress_bar:get_progress()
  3057. return self.progress
  3058. end
  3059. function progress_bar:set_progress(value)
  3060. self.progress = math.max(0, math.min(100, value))
  3061. self:invalidate()
  3062. end
  3063. function progress_bar:set_text(text)
  3064. self.text = tostring(text or "")
  3065. self:invalidate()
  3066. end
  3067. function progress_bar:draw(ctx)
  3068. if not self.visible then return end
  3069. local ax, ay = self:get_absolute_position()
  3070. ctx:fill_rect(ax + 1, ay + 1, self.w - 2, self.h - 2, self.background_color)
  3071. ctx:stroke_rect(ax, ay, self.w, self.h, self.border_color)
  3072. local innerWidth = math.max(0, self.w - 2)
  3073. local fillWidth = math.floor(innerWidth * (self.progress / 100))
  3074. if fillWidth > 0 then
  3075. ctx:fill_rect(ax + 1, ay + 1, fillWidth, self.h - 2, self.progress_color)
  3076. end
  3077. if self.show_percentage or self.text then
  3078. local label = self.text or (tostring(self.progress) .. "%")
  3079. draw_text_in_rect_centered(ax, ay, self.w, self.h, label, {
  3080. color = self.text_color,
  3081. style = self.text_style,
  3082. padding = 2
  3083. })
  3084. end
  3085. end
  3086. function progress_bar:handle_event()
  3087. return false
  3088. end
  3089. ui.progress_bar = function(opts)
  3090. return progress_bar:new(opts)
  3091. end
  3092. -- 5.8 Window
  3093. local function window_theme_color()
  3094. return (current_theme == "dark") and COLOR_BLACK or COLOR_WHITE
  3095. end
  3096. local function window_snap_axis(self, axis, mode)
  3097. local sc = self._scroll
  3098. if not sc then return false end
  3099. local pageSize, contentSize, offsetField
  3100. if axis == "x" then
  3101. pageSize = sc.page_width or self.w
  3102. contentSize = sc.content_width or self.w
  3103. offsetField = "offset_x"
  3104. if not (sc.direction == "horizontal" or sc.direction == "both") then
  3105. return false
  3106. end
  3107. else
  3108. pageSize = sc.page_height or self.h
  3109. contentSize = sc.content_height or self.h
  3110. offsetField = "offset_y"
  3111. if not (sc.direction == "vertical" or sc.direction == "both") then
  3112. return false
  3113. end
  3114. end
  3115. if pageSize <= 0 then return false end
  3116. local pages = math.max(1, math.floor((contentSize + pageSize - 1) / pageSize))
  3117. local current = sc[offsetField] or 0
  3118. local cur = math.floor((-(current) + pageSize / 2) / pageSize)
  3119. if mode == "increment" then
  3120. cur = cur + 1
  3121. elseif mode == "decrement" then
  3122. cur = cur - 1
  3123. elseif type(mode) == "number" then
  3124. cur = mode
  3125. end
  3126. if cur < 0 then cur = 0 end
  3127. if cur > pages - 1 then cur = pages - 1 end
  3128. local target = -cur * pageSize
  3129. if target ~= current then
  3130. sc[offsetField] = target
  3131. self:invalidate()
  3132. return true
  3133. end
  3134. return false
  3135. end
  3136. local window = setmetatable({}, { __index = BaseWidget })
  3137. window.__index = window
  3138. function window:new(opts)
  3139. opts = opts or {}
  3140. opts.x = opts.x or 0
  3141. opts.y = opts.y or 0
  3142. opts.w = opts.w or render_state.viewport_w
  3143. opts.h = opts.h or render_state.viewport_h
  3144. local o = BaseWidget.new(self, opts)
  3145. o.background_color = opts.background_color or window_theme_color()
  3146. o.background_image = opts.background_image
  3147. o._scroll = nil
  3148. if opts.scroll then
  3149. o:enable_scroll(opts.scroll)
  3150. end
  3151. return o
  3152. end
  3153. function window:add(child)
  3154. child = BaseWidget.add(self, child)
  3155. child._parentWindow = self
  3156. return child
  3157. end
  3158. function window:remove(child)
  3159. for i = #self.children, 1, -1 do
  3160. if self.children[i] == child then
  3161. table.remove(self.children, i)
  3162. if child then
  3163. if child.on_unmount then
  3164. pcall(child.on_unmount, child)
  3165. end
  3166. child.parent = nil
  3167. child._parentWindow = nil
  3168. end
  3169. self:invalidate()
  3170. return true
  3171. end
  3172. end
  3173. return false
  3174. end
  3175. function window:clear()
  3176. for i = #self.children, 1, -1 do
  3177. local child = self.children[i]
  3178. table.remove(self.children, i)
  3179. if child then
  3180. if child.on_unmount then
  3181. pcall(child.on_unmount, child)
  3182. end
  3183. child.parent = nil
  3184. child._parentWindow = nil
  3185. end
  3186. end
  3187. self:invalidate()
  3188. end
  3189. function window:set_background_color(color)
  3190. self.background_color = color
  3191. self.background_image = nil
  3192. self:invalidate()
  3193. end
  3194. function window:set_background_image(path)
  3195. self.background_image = path
  3196. self:invalidate()
  3197. end
  3198. function window:_scroll_bounds()
  3199. local sc = self._scroll
  3200. if not sc then return 0, 0, 0, 0 end
  3201. local cw = sc.content_width or self.w
  3202. local ch = sc.content_height or self.h
  3203. local minX = math.min(0, self.w - cw)
  3204. local maxX = 0
  3205. local minY = math.min(0, self.h - ch)
  3206. local maxY = 0
  3207. return minX, maxX, minY, maxY
  3208. end
  3209. function window:_handle_scroll_gesture(evt, x, y)
  3210. local sc = self._scroll
  3211. if not sc or not sc.enabled then
  3212. return false
  3213. end
  3214. if evt == "TOUCH_DOWN" then
  3215. sc.active = self:contains_point(x, y)
  3216. sc.dragging = false
  3217. sc.start_x = x
  3218. sc.start_y = y
  3219. sc.base_offset_x = sc.offset_x
  3220. sc.base_offset_y = sc.offset_y
  3221. sc.snapped = false
  3222. return false
  3223. elseif evt == "MOVE_X" or evt == "MOVE_Y" then
  3224. if not sc.active then return false end
  3225. sc.dragging = true
  3226. local dx = x - (sc.start_x or x)
  3227. local dy = y - (sc.start_y or y)
  3228. local minX, maxX, minY, maxY = self:_scroll_bounds()
  3229. local changed = false
  3230. local snap_horizontal = sc.snap_to_page and (sc.direction == "horizontal" or sc.direction == "both")
  3231. local snap_vertical = sc.snap_to_page and (sc.direction == "vertical" or sc.direction == "both")
  3232. if sc.direction == "horizontal" or sc.direction == "both" then
  3233. if not snap_horizontal then
  3234. local nx = clamp((sc.base_offset_x or 0) + dx, minX, maxX)
  3235. if nx ~= sc.offset_x then
  3236. sc.offset_x = nx
  3237. changed = true
  3238. end
  3239. end
  3240. end
  3241. if sc.direction == "vertical" or sc.direction == "both" then
  3242. if not snap_vertical then
  3243. local ny = clamp((sc.base_offset_y or 0) + dy, minY, maxY)
  3244. if ny ~= sc.offset_y then
  3245. sc.offset_y = ny
  3246. changed = true
  3247. end
  3248. end
  3249. end
  3250. if changed then
  3251. self:invalidate()
  3252. end
  3253. return true
  3254. elseif evt == "SINGLE_TAP" or evt == "LONG_PRESS" then
  3255. local was_dragging = sc.dragging
  3256. sc.active = false
  3257. sc.dragging = false
  3258. if was_dragging then
  3259. if sc.snap_to_page then
  3260. window_snap_axis(self, "x")
  3261. window_snap_axis(self, "y")
  3262. end
  3263. return true
  3264. end
  3265. elseif evt == "SWIPE_LEFT" or evt == "SWIPE_RIGHT" then
  3266. if sc.snap_to_page and (sc.direction == "horizontal" or sc.direction == "both") then
  3267. local mode = (evt == "SWIPE_LEFT") and "increment" or "decrement"
  3268. window_snap_axis(self, "x", mode)
  3269. sc.active = false
  3270. sc.dragging = false
  3271. sc.snapped = true
  3272. return true
  3273. end
  3274. elseif evt == "SWIPE_UP" or evt == "SWIPE_DOWN" then
  3275. if sc.snap_to_page and (sc.direction == "vertical" or sc.direction == "both") then
  3276. local mode = (evt == "SWIPE_DOWN") and "increment" or "decrement"
  3277. window_snap_axis(self, "y", mode)
  3278. sc.active = false
  3279. sc.dragging = false
  3280. sc.snapped = true
  3281. return true
  3282. end
  3283. end
  3284. return false
  3285. end
  3286. function window:enable_scroll(opts)
  3287. opts = opts or {}
  3288. self._scroll = {
  3289. enabled = true,
  3290. direction = opts.direction or "vertical",
  3291. content_width = opts.content_width or opts.contentWidth or self.w,
  3292. content_height = opts.content_height or opts.contentHeight or self.h,
  3293. offset_x = 0,
  3294. offset_y = 0,
  3295. start_x = 0,
  3296. start_y = 0,
  3297. base_offset_x = 0,
  3298. base_offset_y = 0,
  3299. active = false,
  3300. dragging = false,
  3301. page_width = opts.page_width or self.w,
  3302. page_height = opts.page_height or self.h,
  3303. snap_to_page = opts.snap_to_page or false,
  3304. snapped = false
  3305. }
  3306. end
  3307. function window:set_content_size(w, h)
  3308. if not self._scroll then
  3309. self:enable_scroll({})
  3310. end
  3311. if w then self._scroll.content_width = w end
  3312. if h then self._scroll.content_height = h end
  3313. end
  3314. -- 启用子页面管理
  3315. function window:enable_subpage_manager(opts)
  3316. opts = opts or {}
  3317. if not self._managed then
  3318. self._managed = {
  3319. pages = {},
  3320. back_event_name = opts.back_event_name or "NAV.BACK",
  3321. on_back = opts.on_back
  3322. }
  3323. if sys and sys.subscribe then
  3324. sys.subscribe(self._managed.back_event_name, function()
  3325. if self._managed.on_back then
  3326. pcall(self._managed.on_back)
  3327. end
  3328. local anyVisible = false
  3329. for _, pg in pairs(self._managed.pages) do
  3330. if pg and pg.visible ~= false then
  3331. anyVisible = true
  3332. break
  3333. end
  3334. end
  3335. if not anyVisible then
  3336. self.visible = true
  3337. self.enabled = true
  3338. self:invalidate()
  3339. end
  3340. end)
  3341. end
  3342. end
  3343. return self
  3344. end
  3345. -- 配置子页面工厂
  3346. function window:configure_subpages(factories)
  3347. if not self._managed then
  3348. self:enable_subpage_manager()
  3349. end
  3350. self._managed.factories = self._managed.factories or {}
  3351. for k, v in pairs(factories or {}) do
  3352. self._managed.factories[k] = v
  3353. end
  3354. return self
  3355. end
  3356. -- 显示子页面
  3357. function window:show_subpage(name, factory)
  3358. if not self._managed then
  3359. error("enable_subpage_manager must be called before show_subpage")
  3360. end
  3361. -- 隐藏所有其他子页面
  3362. for key, pg in pairs(self._managed.pages) do
  3363. if pg and pg.visible ~= false then
  3364. pg.visible = false
  3365. pg.enabled = false
  3366. pg:invalidate()
  3367. end
  3368. end
  3369. -- 如果子页面不存在,则创建
  3370. if not self._managed.pages[name] then
  3371. local f = factory
  3372. if not f and self._managed.factories then
  3373. f = self._managed.factories[name]
  3374. end
  3375. if not f then
  3376. error("no factory for subpage '" .. tostring(name) .. "'")
  3377. end
  3378. self._managed.pages[name] = f()
  3379. self._managed.pages[name]._parentWindow = self
  3380. runtime.add(self._managed.pages[name])
  3381. end
  3382. -- 隐藏当前窗口,显示子页面
  3383. self.visible = false
  3384. self.enabled = false
  3385. self._managed.pages[name].visible = true
  3386. self._managed.pages[name].enabled = true
  3387. self:invalidate()
  3388. self._managed.pages[name]:invalidate()
  3389. end
  3390. -- 返回上级页面
  3391. function window:back()
  3392. if self._parentWindow then
  3393. self.visible = false
  3394. self.enabled = false
  3395. self:invalidate()
  3396. local parent = self._parentWindow
  3397. local anyVisible = false
  3398. if parent._managed and parent._managed.pages then
  3399. for _, pg in pairs(parent._managed.pages) do
  3400. if pg and pg.visible ~= false then
  3401. anyVisible = true
  3402. break
  3403. end
  3404. end
  3405. end
  3406. if not anyVisible then
  3407. parent.visible = true
  3408. parent.enabled = true
  3409. parent:invalidate()
  3410. end
  3411. end
  3412. end
  3413. -- 关闭子页面
  3414. function window:close_subpage(name, opts)
  3415. if not self._managed or not self._managed.pages then
  3416. return false
  3417. end
  3418. opts = opts or {}
  3419. local pg = self._managed.pages[name]
  3420. if not pg then
  3421. return false
  3422. end
  3423. pg.visible = false
  3424. pg.enabled = false
  3425. pg:invalidate()
  3426. if opts.destroy == true then
  3427. runtime.remove(pg)
  3428. self._managed.pages[name] = nil
  3429. if collectgarbage then
  3430. collectgarbage("collect")
  3431. end
  3432. end
  3433. -- 检查是否还有其他可见的子页面
  3434. local anyVisible = false
  3435. for _, p in pairs(self._managed.pages) do
  3436. if p and p.visible ~= false then
  3437. anyVisible = true
  3438. break
  3439. end
  3440. end
  3441. if not anyVisible then
  3442. self.visible = true
  3443. self.enabled = true
  3444. self:invalidate()
  3445. end
  3446. return true
  3447. end
  3448. function window:draw(ctx)
  3449. local ax, ay = self:get_absolute_position()
  3450. if self.background_image and lcd then
  3451. if lcd.drawImage then
  3452. lcd.drawImage(ax, ay, self.background_image)
  3453. elseif lcd.showImage then
  3454. lcd.showImage(ax, ay, self.background_image)
  3455. else
  3456. ctx:fill_rect(ax, ay, self.w, self.h, self.background_color)
  3457. end
  3458. else
  3459. ctx:fill_rect(ax, ay, self.w, self.h, self.background_color)
  3460. end
  3461. for i = 1, #self.children do
  3462. local child = self.children[i]
  3463. if child and child.visible ~= false and child.draw then
  3464. child:draw(ctx)
  3465. end
  3466. end
  3467. end
  3468. function window:dispatch_pointer(evt, x, y)
  3469. if not self.visible or not self.enabled then return false end
  3470. local inside = self:contains_point(x, y) or (self._scroll and self._scroll.dragging)
  3471. if not inside and evt ~= "MOVE_X" and evt ~= "MOVE_Y" then
  3472. return false
  3473. end
  3474. if self:_handle_scroll_gesture(evt, x, y) then
  3475. return true
  3476. end
  3477. for i = #self.children, 1, -1 do
  3478. if self.children[i]:dispatch_pointer(evt, x, y) then
  3479. return true
  3480. end
  3481. end
  3482. return false
  3483. end
  3484. ui.window = function(opts)
  3485. return window:new(opts)
  3486. end
  3487. -- ================================
  3488. -- 6. 对外接口导出
  3489. -- ================================
  3490. function ui.sw_init(opts)
  3491. opts = opts or {}
  3492. if opts.theme == "light" or opts.theme == "dark" then
  3493. current_theme = opts.theme
  3494. end
  3495. runtime.bindInput()
  3496. end
  3497. function ui.theme()
  3498. return current_theme
  3499. end
  3500. function ui.add(widget)
  3501. return runtime.add(widget)
  3502. end
  3503. function ui.remove(widget)
  3504. return runtime.remove(widget)
  3505. end
  3506. function ui.clear(color)
  3507. ui.render.background(color or COLOR_BLACK)
  3508. end
  3509. -- 开始设计需要兼容的刷新接口
  3510. function ui.renderFrame()
  3511. return ui.render.present()
  3512. end
  3513. -- 后面更新的刷新页面接口
  3514. function ui.refresh()
  3515. return ui.render.present()
  3516. end
  3517. return ui