From ec59f570ddc04349b7df500d364c0b8a36739c27 Mon Sep 17 00:00:00 2001 From: Michael McAllister Date: Sat, 25 Jul 2026 23:47:58 +0100 Subject: [PATCH] keyboard: point-mode virtual keyboard for touchscreens On large touchscreens the software keyboard can now run in "point mode". Keys are laid out finger-sized and tapped directly instead of being navigated with a cursor. Targets opt in with HAVE_KBD_POINT_MODE. Key size is derived from LCD_DPI so a key is about 5mm across whatever the panel density, falling back to a fixed size where DPI is unknown. A phone-style default layout in the UI font, across three flip pages of eight lines. Page two is page one shifted (i.e caps). Page three completes Latin-1 and adds additional accented letters common to the other European locales. Space is drawn as an icon, since the UI font has no glyph for it. Change-Id: Ibe2e305b3c22b1f9152358cf3864b9445b67b463 Co-Author: Claude Opus 4.8 --- apps/features.txt | 4 + apps/recorder/keyboard.c | 173 ++++++++++++++++-- firmware/export/config/hibyr1.h | 2 + firmware/export/config/hibyr3proii.h | 2 + .../browsing_and_playing.tex | 10 + .../images/ss-virtual-keyboard-480x720x16.png | Bin 0 -> 4028 bytes .../images/ss-virtual-keyboard-480x800x16.png | Bin 0 -> 4042 bytes 7 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 manual/rockbox_interface/images/ss-virtual-keyboard-480x720x16.png create mode 100644 manual/rockbox_interface/images/ss-virtual-keyboard-480x800x16.png diff --git a/apps/features.txt b/apps/features.txt index 5453ec86bc..0c4719ca11 100644 --- a/apps/features.txt +++ b/apps/features.txt @@ -82,6 +82,10 @@ lcd_sleep morse_input #endif +#if defined(HAVE_KBD_POINT_MODE) +kbd_point_mode +#endif + #if defined(HAVE_PITCHCONTROL) pitchscreen #endif diff --git a/apps/recorder/keyboard.c b/apps/recorder/keyboard.c index ee47470bb1..cb00fef74f 100644 --- a/apps/recorder/keyboard.c +++ b/apps/recorder/keyboard.c @@ -47,8 +47,23 @@ #define DEFAULT_MARGIN 6 #define KBD_BUF_SIZE 500 +/* Sentinel picker cell, drawn as an icon (the UI font has no glyph for it): + * KBD_SPACE inserts a space. */ +#define KBD_SPACE 0x2423 + #ifdef HAVE_TOUCHSCREEN +/* Increase grid size so it responds better to taps */ +#ifdef HAVE_KBD_POINT_MODE +#ifndef MIN_GRID_SIZE +#if defined(LCD_DPI) && (LCD_DPI > 0) +#define MIN_GRID_SIZE ((LCD_DPI * 50 + 127) / 254) +#else +#define MIN_GRID_SIZE 44 +#endif +#endif +#else #define MIN_GRID_SIZE 16 +#endif #define GRID_SIZE(s, x) \ ((s) == SCREEN_MAIN && MIN_GRID_SIZE > (x) ? MIN_GRID_SIZE: (x)) #endif @@ -389,6 +404,29 @@ static ucschar_t get_kbd_ch(struct keyboard_parameters *pm, int x, int y) k = k * pm->max_chars + x; return (*pbuf != 0xFEFF && k < *pbuf)? pbuf[k+1]: ' '; } + +#ifdef HAVE_TOUCHSCREEN +/* Number of real characters in the grid row drawn at picker row y. */ +static int get_kbd_row_len(struct keyboard_parameters *pm, int y) +{ + unsigned int n, i, k = pm->page*pm->lines + y; + ucschar_t *pbuf; + for (pbuf = pm->kbd_buf_ptr; (i = *pbuf) != 0xFEFF; pbuf += i + 1) + { + n = i ? (i + pm->max_chars - 1) / pm->max_chars : 1; + if (k < n) + { + int len = (int)i - (int)(k * pm->max_chars); + if (len > (int)pm->max_chars) + len = pm->max_chars; + return len < 0 ? 0 : len; + } + k -= n; + } + return 0; +} +#endif /* HAVE_TOUCHSCREEN */ + static void kbd_calc_pm_params(struct keyboard_parameters *pm, struct screen *sc, struct edit_state *state); static void kbd_calc_vp_params(struct keyboard_parameters *pm, @@ -430,10 +468,14 @@ int kbd_input(char* text, int buflen, ucschar_t *kbd) } #ifdef HAVE_TOUCHSCREEN + enum touchscreen_mode old_mode = touchscreen_get_mode(); +#ifdef HAVE_KBD_POINT_MODE + touchscreen_set_mode(TOUCHSCREEN_POINT); +#else /* keyboard is unusuable in pointing mode so force 3x3 for now. * TODO - fix properly by using a bigger font and changing the layout */ - enum touchscreen_mode old_mode = touchscreen_get_mode(); touchscreen_set_mode(TOUCHSCREEN_BUTTON); +#endif #endif /* initialize state */ state.text = text; @@ -458,6 +500,48 @@ int kbd_input(char* text, int buflen, ucschar_t *kbd) const unsigned char *p; int len = 0; +#ifdef HAVE_KBD_POINT_MODE + if (touchscreen_get_mode() == TOUCHSCREEN_POINT) + { +#if (LCD_WIDTH >= 480) && (LCD_HEIGHT >= 640) + /* Three pages of eight lines. Page two is page one shifted, cell + * for cell; page three completes Latin-1 and adds the accented + * letters common to the other European locales. The space bar + * occupies the same cells on every page. Ten columns need the + * width, and all eight lines have to be visible at once. */ + p = "1234567890\n" + "qwertyuiop\n" + "asdfghjkl;\n" + "zxcvbnm,./\n" + "àáâäåçèéêë\n" + "ìíîïñòóôöù\n" + "úûüÿ'æøðþý\n" + "`-=␣␣␣␣[]\\\n" + "!@#$%^&*()\n" + "QWERTYUIOP\n" + "ASDFGHJKL:\n" + "ZXCVBNM<>?\n" + "ÀÁÂÄÅÇÈÉÊË\n" + "ÌÍÎÏÑÒÓÔÖÙ\n" + "ÚÛÜŸ\"ÆØÐÞÝ\n" + "~_+␣␣␣␣{}|\n" + "¡¢£¤¥¦§¨©ª\n" + "«¬®¯°±²³´µ\n" + "¶·¸¹º»¼½¾¿\n" + "×÷ßÃãÕõ€Œœ\n" + "ĐđČčĎďĚěŇň\n" + "ŘřŠšŤťŮůŽž\n" + "ĄąĆćĘꣳŃń\n" + "ŚśŹ␣␣␣␣źŻż"; + pm->default_lines = 8; + pm->max_line_len = 10; +#else +# error "HAVE_KBD_POINT_MODE: no point-mode layout for this screen size" +#endif + } + else +#endif + { #if LCD_WIDTH >= 160 && LCD_HEIGHT >= 96 struct screen *sc = &screens[l]; @@ -528,6 +612,7 @@ int kbd_input(char* text, int buflen, ucschar_t *kbd) pm->max_line_len = 18; } #endif + } pbuf = pm->kbd_buf; while (*p) { @@ -849,6 +934,11 @@ static void kbd_calc_pm_params(struct keyboard_parameters *pm, (touchscreen_get_mode() == TOUCHSCREEN_POINT)); #endif +#ifdef HAVE_TOUCHSCREEN + if (touchscreen_get_mode() == TOUCHSCREEN_POINT) + pm->curfont = sc->getuifont(); + else +#endif pm->curfont = pm->default_lines ? FONT_SYSFIXED : sc->getuifont(); font = font_get(pm->curfont); pm->font_h = font->height; @@ -907,6 +997,11 @@ static void kbd_calc_vp_params(struct keyboard_parameters *pm, icon_w = get_icon_width(sc->screen_type); sc_w = vp->width; /**sc->getwidth();**/ +#ifdef HAVE_TOUCHSCREEN + if (touchscreen_get_mode() == TOUCHSCREEN_POINT) + pm->font_w = sc_w / pm->max_line_len; /* fill width, allow >10 cols */ + else +#endif if (pm->font_w < sc_w / pm->max_line_len) pm->font_w = sc_w / pm->max_line_len; pm->max_chars = sc_w / pm->font_w; @@ -1075,22 +1170,47 @@ static void kbd_draw_picker(struct keyboard_parameters *pm, { for (i = 0; i < pm->max_chars; i++) { + int cx = i*pm->font_w, cy = j*pm->font_h; ch = get_kbd_ch(pm, i, j); + if (ch == KBD_SPACE) + { + /* space bar: a bar with short upturned ends. Cells join; + * the run's first/last cell adds the vertical end tick. */ + int th = MAX(2, pm->font_h/14); + int by = cy + (pm->font_h*2)/3; + int tk = pm->font_h/6; + int rx = cx + pm->font_w - th; + sc->fillrect(cx, by, pm->font_w, th); + if (i == 0 || get_kbd_ch(pm, i-1, j) != KBD_SPACE) + sc->fillrect(cx, by - tk, th, tk + th); + if (get_kbd_ch(pm, i+1, j) != KBD_SPACE) + sc->fillrect(rx, by - tk, th, tk + th); + continue; + } utf8 = utf8encode(ch, outline); *utf8 = 0; sc->getstringsize(outline, &w, &h); - sc->putsxy(i*pm->font_w + (pm->font_w-w) / 2, - j*pm->font_h + (pm->font_h-h) / 2, outline); + sc->putsxy(cx + (pm->font_w-w) / 2, + cy + (pm->font_h-h) / 2, outline); } } if (!pm->line_edit) { - /* highlight the key that has focus */ + /* highlight the focused key; the space bar spans several cells and + * highlights as one */ + int hx = pm->x, hw = 1; + if (get_kbd_ch(pm, hx, pm->y) == KBD_SPACE) + { + while (hx > 0 && get_kbd_ch(pm, hx - 1, pm->y) == KBD_SPACE) + hx--; + while (get_kbd_ch(pm, hx + hw, pm->y) == KBD_SPACE) + hw++; + } sc->set_drawmode(DRMODE_COMPLEMENT); - sc->fillrect(pm->font_w*pm->x, pm->font_h*pm->y, - pm->font_w, pm->font_h); + sc->fillrect(pm->font_w*hx, pm->font_h*pm->y, + pm->font_w*hw, pm->font_h); sc->set_drawmode(DRMODE_SOLID); } } @@ -1337,8 +1457,8 @@ static void kbd_draw_buttons(struct keyboard_parameters *pm, struct screen *sc) /* draw buttons */ if (pm->pages > 1) { - /* button to flip page. */ - vp.y = pm->lines*pm->font_h; + /* button to flip page */ + vp.y = pm->kbd_viewports[eKBD_VP_PICKER].y + pm->lines*pm->font_h; sc->hline(0, sc_w - 1, 0); sc->putsxy(0, text_y, ">"); } @@ -1378,22 +1498,32 @@ static int keyboard_touchscreen(struct keyboard_parameters *pm, #endif if (x < 0 || y < 0) return ACTION_NONE; - if (y < pm->lines*pm->font_h) + /* The picker grid is drawn in its own viewport, below the edit line, so + * translate the absolute touch into that viewport (see kbd_draw_picker). */ + struct viewport *picker_vp = &pm->kbd_viewports[eKBD_VP_PICKER]; + int picker_x = x - picker_vp->x; + int picker_y = y - picker_vp->y; + if (picker_y >= 0 && picker_y < pm->lines*pm->font_h) { - if (x/pm->font_w < pm->max_chars) + int row = picker_y/pm->font_h; + /* ignore taps past the end of a short row */ + if (picker_x >= 0 && picker_x/pm->font_w < get_kbd_row_len(pm, row)) { /* picker area */ state->changed = CHANGED_PICKER; - pm->x = x/pm->font_w; - pm->y = y/pm->font_h; + pm->x = picker_x/pm->font_w; + pm->y = row; pm->line_edit = false; if (button == BUTTON_REL) return ACTION_KBD_SELECT; } } - else if (y < pm->main_y - pm->keyboard_margin) + else if (pm->pages > 1 && picker_y >= pm->lines*pm->font_h && + picker_y < pm->lines*pm->font_h + + GRID_SIZE(sc->screen_type, sc->getcharheight())) { - /* button to flip page */ + /* flip-page button, drawn just below the grid; test the same band in + * picker-relative coords so ">" is tappable. */ if (button == BUTTON_REL) return ACTION_KBD_PAGE_FLIP; } @@ -1432,6 +1562,9 @@ static void kbd_insert_selected(struct keyboard_parameters *pm, /* find input char */ ucschar_t ch = get_kbd_ch(pm, pm->x, pm->y); + if (ch == KBD_SPACE) + ch = ' '; + /* check for hangul input */ if (ch >= 0x3131 && ch <= 0x3163) { @@ -1552,6 +1685,18 @@ static void kbd_move_picker_horizontal(struct keyboard_parameters *pm, state->changed = CHANGED_PICKER; pm->x += dir; + /* The space bar spans several cells but highlights and inserts as one key, + * so step over the whole run instead of stopping inside it, which looks + * like the cursor is stuck. */ + if (pm->x >= 0 && pm->x < pm->max_chars && + get_kbd_ch(pm, pm->x, pm->y) == KBD_SPACE && + get_kbd_ch(pm, pm->x - dir, pm->y) == KBD_SPACE) + { + while (pm->x >= 0 && pm->x < pm->max_chars && + get_kbd_ch(pm, pm->x, pm->y) == KBD_SPACE) + pm->x += dir; + } + if (pm->x < 0) { if (!global_settings.list_wraparound && pm->page == 0) diff --git a/firmware/export/config/hibyr1.h b/firmware/export/config/hibyr1.h index bb20b0b1a2..6a24de6e2e 100644 --- a/firmware/export/config/hibyr1.h +++ b/firmware/export/config/hibyr1.h @@ -37,6 +37,8 @@ #ifndef BOOTLOADER #define HAVE_BUTTON_DATA #define HAVE_TOUCHSCREEN +/* Finger-sized keys, tapped directly, instead of a navigated cursor */ +#define HAVE_KBD_POINT_MODE #endif #ifndef CONFIG_BACKLIGHT_FADING diff --git a/firmware/export/config/hibyr3proii.h b/firmware/export/config/hibyr3proii.h index 92d338aee2..b4e2baa6dd 100644 --- a/firmware/export/config/hibyr3proii.h +++ b/firmware/export/config/hibyr3proii.h @@ -37,6 +37,8 @@ #ifndef BOOTLOADER #define HAVE_BUTTON_DATA #define HAVE_TOUCHSCREEN +/* Finger-sized keys, tapped directly, instead of a navigated cursor */ +#define HAVE_KBD_POINT_MODE #endif #ifndef CONFIG_BACKLIGHT_FADING diff --git a/manual/rockbox_interface/browsing_and_playing.tex b/manual/rockbox_interface/browsing_and_playing.tex index ddf632d379..4ea4d65000 100644 --- a/manual/rockbox_interface/browsing_and_playing.tex +++ b/manual/rockbox_interface/browsing_and_playing.tex @@ -211,6 +211,16 @@ The virtual keyboard can be easily changed by making a text file with the required layout. More information on how to achieve this can be found on the Rockbox website at \wikilink{LoadableKeyboardLayouts}. +\opt{kbd_point_mode}{ + On this player the keyboard is laid out for direct touch where the keys are sized + for a fingertip and are tapped individually, rather than being selected + with the 3\,\texttimes\,3 grid used on other touchscreen targets. By default the + characters are spread over three pages with lower case and accented + letters, their upper-case forms in the same positions, and a third page + of punctuation and additional European characters. Tap + \textbf{\textgreater} below the keys to change page. +} + \opt{morse_input}{ Also you can switch to Morse code input mode by changing the \setting{Use Morse Code Input} setting% diff --git a/manual/rockbox_interface/images/ss-virtual-keyboard-480x720x16.png b/manual/rockbox_interface/images/ss-virtual-keyboard-480x720x16.png new file mode 100644 index 0000000000000000000000000000000000000000..9dec07971ac8d3e960dc7ef5e3cf6bea2ad3c0c9 GIT binary patch literal 4028 zcmcInXIPWVwhn^QRLZ8v27(F_g+N3Fsfh}R7$MRjlo%E1#n1v$L<1iU72gH6C z1A#yXEH9bafp@0g0tufZXl#8Zzp003lO0Qel7fzfkF@r}diNQQ z=`<;&XT{B}w3N)y5z~&*Mt5OAq)XcuA7!_pz)icTmwbnxlayMQ0QE&8mst?k=fOu0 zLa=diU7oBZ=o|*(zm>?vGK`pnpv{$-jJ(EaVJvNy?8L{Y8n3<`Nl2w8^}2h8 z6kt4br=MQ7GAs;|3r7Wss!)47BytDBjQowY)+3jUU27m zTn%IQ{U%}~9sZl$I33-uQ~gxL*fMux+o3I_Y`Tg&v+wPw?tSL+Uh2%A6m?@TLF(e6 z9WwsMz_YnH@l7bHtg>)PMPQe*UEVfaSlhA*R{K#`BwR16{D@DPZADg{`OG(dTCTM1 zDx+tE>J`)#7tMN97+eicWy2Es6T_9wr=_r(10OR5WWg?cV^lUP%Z0omxT_8ujB&X9hzug6+plued|0mBprtI1Mb8 z8=r-7%8VupgHyy{HiZY2T?*X-)J~A>8iE?j3FbvLMHOzT(%OBC#T%Zm%{M0SICIKh zY|{~(eLcfxS=O~LE4DyfRWzfZHO&#iw6LaxF)O{~%!u5Qw*%?e1bF9WJoQ2Vpv*M-XapGd6^?YvT zL-L#R^*9H$>?iPk5$tCZcHfvS`C2^~aBPg+T-w_oZ)XSNmzdMpuM%FB!fzn*?y;e<6vj_h! zhtxO+I>%N8vxUk&5K2sh_GJ<|0d9!X692{UziHmTbIAa8wJjLJy1d~?$(t-3h?VN{#NNIse8c>g zD5MhnRZv!fgVcL#yY`~^c)AwgD=b7X-;`%f;=h~ME5$p?bsfPj!U4*_x&olfqJ7g* zc+zh=w5O=_Gf*p1bQ@T|3dDXDEbI*yi=39QKK`nu`l47?<8pHFLa)!VbV^?4m5uoA zgvKZqRud6aqWI)l>StI1}fc)p*jB))!6Vj5!`7 z&^+*S5n$9%H-u!ZPFj`Ow-a5^-R`TA*0i*aZ)3)_8<5x0eW$-pNgs4)Z4rVm;xmFn zBMDNm&SRP(;MVeR;$-_s7VQe64icTFaQQnB^~Rd~^EfKp95M@i`dhc#)DL>W#Uo!J zC3A11468DalGWxKEFSb!U6IJ2#c=mT-%xcK~aM+e5>l)!#3NUFx z=rp%=shnER{WHATg#NQ^+R^Ps3En2qpoDPhQl(ghIn9Ll0SAOKrv?a zh+C(H*7o6;({fA7%D2RKgAHn%2$i@puQ9jtg4qF$jJ^x_2&|NpQ;uAJBzpLU2AxfR zh06bh#|5_c`>_QdBC9L1Nl;IvKQS$HF$->q1c!9E$sgJ8qj6!wHO>l(8YDAuoP<@Y z6QPw?$UE`kQgdUaYrbcE#J$q154^LBaO?9RFw~7PPxJ}8Em^wckf3hA6H|481qc*E z4VnF536l;P)&>gX+}p1&X#wErHxnu4O+HoWMZ9Z(LD*0WQ`Id}dCp0k4o4;)xdT0L zLo48W8o|m!aapR%C%-qH?VKg6gm04U%9l?mY@|LdjR>JXTgLeWrjpBOF^7}(`v#S) ztfXbc-h8{1Q`M}i%TED*Hf7En+Rmv2j%=P!$_BuGKC4}S)B}Ezikn!`8$D>2NG|J@^ z(Sem&ZDE?_o&icOfkfkHs`MgBQ;zLICA)uLhp$Hil5$W%$<^Zz#%UEtY7RG0k-swS zMR!PW6Y5;jU|Q9~8%Oi9wb9fi6%`m0<-E6ezjisH<)P$bINQ!cN~!_zCfb5!ymaPy z$KR1ocZhWVOD*(}VNcUO8or#DmYdn(XVAcSpP{6IuW_P$+I7sJQ^JXvU;8qA-tl^( z(z|$!`RgAhe7;=%`RNATGp|g-4V<}%PSGBWh!^yaxBxq-{Qlf`MzF_m4^?+FZz@5$!fVUoIJL2Qxjk* zLl?~#YLadMzY@^l>ytE4-L(vBj25Y*_^95kaph;OkmBeQy*Y_hYcEODYFRTul|3yn zgrb+XHEFpEL2}TnX!?=FcRekyf$_&4C7b@Pc$;XbUe$_R%YFigi zx-oqX?+KpI^X0lMNlAA^Ah}1Wq=N>| z;EudAkVdHCgM#a!-X~`5;+~W=8>>efcsWmh!V;nFX^)c71P=ps9W+=W+|4)t(ZN96 zo#ffEAB*ij8|&WX#9!A$mGP&8aO?ybN_6PjM^~Tz(ai8`Z8`W~4C~T~mmftU0zbD& zgWdC+ZJ9G|UxWvz2*C8!C%t=ixqE&VbGk!s^I+DuC>}~Ir;iV2^$4TaT!%x?uwD-E z)7=@UHRQRqR^b)p1M*lx4njYdTepff5^c=GS>U}`@a6%Im}|~}^Ky+i07D%5Vzu4G z{jRek;0g1gfa***ob~Rok|Y@CVVKLE8ali&dU!`dFtr(8E0S2yUBPk4eUpQ`8v%j1 z3RHNLE_6dHHDl>F_(*Q%qC$Q!dgAGab${Vgz?o?#&tzoFWRUfUlPW%Cx1Pu0N&d6& z|Hr~p^B*rLo>YmB`QOs#pYr72HOhYs<3M9q{rb`Q#$2DK>Yo-lR>8HWtTP!E_c0=qRM)VnN zwP8^Ld$(_`F1-$pLJkf8K75PiV6nWqNLOYYji#kn1ix3)X!37E$`+BIj>%hdM7rbr z0s;)7BqzkiuX2{(9ssWL#g&jH=}?`Q8qUXdP*Pc0v(<;w-_b^k-5m`8FFy z$mv?02Mjtl0au{5-eXXAoh+2fD9ovKYl#R0m!R79$Sp_uBZnTu?C1)!=cs@5yoRpMMO@ik_y_|q(6E@6=Ol1{X0s*Bk&M9bHOL9 z-jCu>(UO;#Rg4d(kSM-Ze+9W4zX06nPIVb*LJ)1DEJCY1tje#YIk~pnO#!i-$`NI% zz;YIln7EJA)@z-LAlHw=7?uAnhyJf*`46_Owk>>a-~HHoebTGD8Ek28ZT9-YwR`^p D5ErG5I{hb9;FC`8U?9BCV&J;K*t0Wq>K(hnt)0Z zLazb|zNjdXBE84gAp$}mgq{HTaPD2-`hML1-}$lkI?p-ltabLY_u0>Rbj{|fgxC== z2m~TwZg$xg0ulNOf$aJ9mp!|PXkw|}uKUHu$l3@3sZ18%!tC9h|9Z>J)*1qdRD(e7 zksuJ>E_81O0tq_}f&B1`ixlXIH0SM{wr!8G%4Bqj?gw z3klt{yn6YUWZ#kNyW2F)FB{q8N7>mz8LxF^uRK+>zk|6gS}kblydz}VsciR5q(g_f zKFLnHFm*}fzMQGW?g4b`wE=jLh{|KmwebQmUcSE|y34>}*in^IAaILbcb?+5ZLv1H zr{SMtc#G9^Whr%}mRPsuayOhA-9X`O<+EO%-^OiLO;~X6$fM|1M`!Tu6&`#_ zKF?3a{WWb1J`o=*%rV8YDQVvco*-c^r`KelJpN*LROa064w~$a-b(VQrNuk+y(D;9zP(zFu3iSuP0`=+GyAdltn z)TX%ql^yRkW_^Xi0rfPaW@^gDFn{}fN+zN$7@h+6jmEwXhBM!0V~w53`CVA)DJSxB zWB-LT(j6uAq_~G6T5)se_hA|GqK$FLscfy31#+;wR5&n{5a+EBc}F6y>8{h=x2rcf z8c4VO`eM_mBfJ>K`+l$B5@JmA9JtAJXg@d9-&@W3v9EPP`vG%xFYU*_Bh!OYYyrrOS8FWsQGcqE8Kbb$yQa?dAMlW zTu9;pO_*UCS)fj0+_6t(JTMo_MBDhb_;a`Y;;2MRKtrzZ_`D3*f!j%SSzd1mYj2jT zE8@sHjQ0HL&@pi32|(#3juBO1x>S8K3-=c`?>UMEkD5(mFGU<=#Z#o%f!9G?;eewP zJC@sU$=*II*t&gF?&9*Ta=pt=i75b(AOAzh>N`opLmNAv$Z9 zC=H!>n!iRlySHssy6j4?J~dYt>;J>lqt82c7Ia5OFSo{E9HnBXy>pJ3Y<43jeXRhS+j&fv>%kT1zQAY z1}7*y-Y&A*_R^Me77;5JEg31%S>_aytI^RnR$d?Xo*GEi^p>t8iwH_oY43;$SS?X&Do}j2i<$eQ+pY}P4<$TtN z_e!W36%xW+7nnBaOyXsU`*HH>`v-E8`ZJ|_I{B{1zu;0OU`rnF1koXHZ{QRw z4}UwCCs&;R5GyIF4wv9uP&hI`>TVh*AAZw2&!CiZ+h`GOy{oy@TRUWK6zJ;^{T&~Z;jNgrW`LgG5^N=Au#+Z z>vQg@jCdy#oeOs5PDBrslO-4#U!GDSHu8~uOtx6*$u;S^A31Vjx89b3c0|p%&;^;$ zCw~Bq`i8D1?E}#tGw)WnuKx{8S|ds>`SZ;|PZB=~7>N?`kvI2#|D1^$e$i4RTZcy1 z?Gbe}<^kIdK{3*|9Wjf?0*i6zC+4AJH>_;8GjjAJHrAObylOM}5;l%jI@cwEq@fdJN?N0v1e8CfuI zg>;X$dE$a=EXm=Cfqr7fI|43jwALvdX^(gWol?q(FBeHzOzS92Ilj8^A&7WGviX23 z+4yoFGL^C0Gd#g>LFHCa2@jm|%QepDaksGAqF6(W&e9#~QS}zH&*|YZlC?7y(3in7 zYi4Px;RKd-W-2mO_&{AhMk*#5%L=RT(1h_}hhyg}&p)oMNxUlCr8`=bT#-+-u^hSS zb_4r$$#~cRu3#BXJn6XjopNT5WgC!OK&2B~jrY}Fbco`AG&ek(vYJ`lqOHwK0+yO7 zKbompQo`bM2A1Ah)_F%EXvE&hWsVRS{7qQScndHvY4|YP%geqa{8KVOAOvV~SCy&q z?_()GDl6tS2F`}qK*e7QE_$sE3r(upS&A8As2*i1N^hhV{!qv|`|+$q`g67Y)Jr7e z&;x@vPuk|hW5r$-l)%iyTylPii`1Ku3-FW2qeFc(%ArTm&u_t#=We(&MiEs=N3LfQ zEXR0%i)CXwt^7htam1_^OV`W^DDR$r0}txdX%9{na0#{-vYp6x$bdSYWeefKnyzq_#=w5pFu!0T%mu|?(h za_*nml#T$MH^>Ba9ea*{IWBz&7a9Sr%u+stM&Cdc>}8lR%yk(d^$F=P)YC?}Ze*S7 z_Yqzz3!_ucn!yp}@tj1`Ho`4;WRq(+Jp0(M&#)zi9T@d&EA^kiF-=*z&32{UzF;b< z^l4fY2L+T*oHCF!DUs|-UCc%$M>3-SSU~nwV#yE zt-R0VE(|n6JvAsTt+FXeiEU93^!86pccgoQ6j>B z?BDtjqECHVk$iBiwWvJ+c>YEtN=$!S2HGVAZsx!92I)pug+Ziy!?yI@Z1EB5a7&O( z7mxeeWc0(c3RNPyN^2Ti0?ziJ#9d^kK_$Sv@FBd=M3NF$rQZ2TD?##R(IG{22d`iG zl|MXL)T8TgW9avfsjyFfCd8ph?mB8`&`O8zy7~v33U}k8;^!u3mOm{u)xXWWfAbWI z%9{-?<7Cvc}~E~xoXEBF56DLue_{l2t_d* z`XBaIz6V?xai+JaHYn^q;ILZSRm94D=nmzAqBY`mEzL`Q{lJc_bo7$ZPEu^bX`oX% z%VuLNikeMwLp7>2S7USB=fcS$nS)NNwc-HW@KCR{pqcacna7;_q~%%C)qXYscwmJ1 z6*<)$sta~k8f`Qg9m0_4ZVlb@$97eO@H^DLD20FL(bsbXB0j)r!$nDbR-I6%r#XGj z0Id0VGLao9d=@xA%jD{h?dT7I35&_%W5tB-@CcFrB;fy_lYWc3vM%8(?4k4@>HClV z{ck2*#lvTufL>1!ZSdb~c>u4|+#Hio@qILSDZ2zgRi!&QKd7Lq6P!sBvOCLeYfCjx z9M6?%0&7Xwq|eK-(0n#Ja;WchV>Nnt)kNaD1@mf_S;^ zYkH2y!dEEBCzHr3YS%$;J@?lL?fexQHu5TdXKZ}1pbeXL8!7NzDU9!Sk z#&k^|@7}db+$XORvruQ1!MJE=1(`7NeTbGDxOZJ~>`S1Qywv&~RgJ(?7)y(WJ7D)gf`TR89qkWpQcxw_J3oQ_(}kzkzjRd6IH@9JBfFO7uG%DUhy2Q@Vwk1_#M$qc!2QeN}vo_Be_iN}IV>F;|2m+QZuz5+;mYc`g~QnWkCK`i3E@oc7T2DMLS