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