exeasyui.lua 132 KB

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