bastd.ui.onscreenkeyboard

Provides the built-in on screen keyboard UI.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Provides the built-in on screen keyboard UI."""
  4
  5from __future__ import annotations
  6
  7from typing import TYPE_CHECKING, cast
  8
  9import ba
 10import ba.internal
 11
 12if TYPE_CHECKING:
 13    pass
 14
 15
 16class OnScreenKeyboardWindow(ba.Window):
 17    """Simple built-in on-screen keyboard."""
 18
 19    def __init__(self, textwidget: ba.Widget, label: str, max_chars: int):
 20        self._target_text = textwidget
 21        self._width = 700
 22        self._height = 400
 23        uiscale = ba.app.ui.uiscale
 24        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
 25        super().__init__(
 26            root_widget=ba.containerwidget(
 27                parent=ba.internal.get_special_widget('overlay_stack'),
 28                size=(self._width, self._height + top_extra),
 29                transition='in_scale',
 30                scale_origin_stack_offset=(
 31                    self._target_text.get_screen_space_center()
 32                ),
 33                scale=(
 34                    2.0
 35                    if uiscale is ba.UIScale.SMALL
 36                    else 1.5
 37                    if uiscale is ba.UIScale.MEDIUM
 38                    else 1.0
 39                ),
 40                stack_offset=(0, 0)
 41                if uiscale is ba.UIScale.SMALL
 42                else (0, 0)
 43                if uiscale is ba.UIScale.MEDIUM
 44                else (0, 0),
 45            )
 46        )
 47        self._done_button = ba.buttonwidget(
 48            parent=self._root_widget,
 49            position=(self._width - 200, 44),
 50            size=(140, 60),
 51            autoselect=True,
 52            label=ba.Lstr(resource='doneText'),
 53            on_activate_call=self._done,
 54        )
 55        ba.containerwidget(
 56            edit=self._root_widget,
 57            on_cancel_call=self._cancel,
 58            start_button=self._done_button,
 59        )
 60
 61        ba.textwidget(
 62            parent=self._root_widget,
 63            position=(self._width * 0.5, self._height - 41),
 64            size=(0, 0),
 65            scale=0.95,
 66            text=label,
 67            maxwidth=self._width - 140,
 68            color=ba.app.ui.title_color,
 69            h_align='center',
 70            v_align='center',
 71        )
 72
 73        self._text_field = ba.textwidget(
 74            parent=self._root_widget,
 75            position=(70, self._height - 116),
 76            max_chars=max_chars,
 77            text=cast(str, ba.textwidget(query=self._target_text)),
 78            on_return_press_call=self._done,
 79            autoselect=True,
 80            size=(self._width - 140, 55),
 81            v_align='center',
 82            editable=True,
 83            maxwidth=self._width - 175,
 84            force_internal_editing=True,
 85            always_show_carat=True,
 86        )
 87
 88        self._key_color_lit = (1.4, 1.2, 1.4)
 89        self._key_color = (0.69, 0.6, 0.74)
 90        self._key_color_dark = (0.55, 0.55, 0.71)
 91
 92        self._shift_button: ba.Widget | None = None
 93        self._backspace_button: ba.Widget | None = None
 94        self._space_button: ba.Widget | None = None
 95        self._double_press_shift = False
 96        self._num_mode_button: ba.Widget | None = None
 97        self._emoji_button: ba.Widget | None = None
 98        self._char_keys: list[ba.Widget] = []
 99        self._keyboard_index = 0
100        self._last_space_press = 0.0
101        self._double_space_interval = 0.3
102
103        self._keyboard: ba.Keyboard
104        self._chars: list[str]
105        self._modes: list[str]
106        self._mode: str
107        self._mode_index: int
108        self._load_keyboard()
109
110    def _load_keyboard(self) -> None:
111        # pylint: disable=too-many-locals
112        self._keyboard = self._get_keyboard()
113        # We want to get just chars without column data, etc.
114        self._chars = [j for i in self._keyboard.chars for j in i]
115        self._modes = ['normal'] + list(self._keyboard.pages)
116        self._mode_index = 0
117        self._mode = self._modes[self._mode_index]
118
119        v = self._height - 180.0
120        key_width = 46 * 10 / len(self._keyboard.chars[0])
121        key_height = 46 * 3 / len(self._keyboard.chars)
122        key_textcolor = (1, 1, 1)
123        row_starts = (69.0, 95.0, 151.0)
124        key_color = self._key_color
125        key_color_dark = self._key_color_dark
126
127        self._click_sound = ba.getsound('click01')
128
129        # kill prev char keys
130        for key in self._char_keys:
131            key.delete()
132        self._char_keys = []
133
134        # dummy data just used for row/column lengths... we don't actually
135        # set things until refresh
136        chars: list[tuple[str, ...]] = self._keyboard.chars
137
138        for row_num, row in enumerate(chars):
139            h = row_starts[row_num]
140            # shift key before row 3
141            if row_num == 2 and self._shift_button is None:
142                self._shift_button = ba.buttonwidget(
143                    parent=self._root_widget,
144                    position=(h - key_width * 2.0, v),
145                    size=(key_width * 1.7, key_height),
146                    autoselect=True,
147                    textcolor=key_textcolor,
148                    color=key_color_dark,
149                    label=ba.charstr(ba.SpecialChar.SHIFT),
150                    enable_sound=False,
151                    extra_touch_border_scale=0.3,
152                    button_type='square',
153                )
154
155            for _ in row:
156                btn = ba.buttonwidget(
157                    parent=self._root_widget,
158                    position=(h, v),
159                    size=(key_width, key_height),
160                    autoselect=True,
161                    enable_sound=False,
162                    textcolor=key_textcolor,
163                    color=key_color,
164                    label='',
165                    button_type='square',
166                    extra_touch_border_scale=0.1,
167                )
168                self._char_keys.append(btn)
169                h += key_width + 10
170
171            # Add delete key at end of third row.
172            if row_num == 2:
173                if self._backspace_button is not None:
174                    self._backspace_button.delete()
175
176                self._backspace_button = ba.buttonwidget(
177                    parent=self._root_widget,
178                    position=(h + 4, v),
179                    size=(key_width * 1.8, key_height),
180                    autoselect=True,
181                    enable_sound=False,
182                    repeat=True,
183                    textcolor=key_textcolor,
184                    color=key_color_dark,
185                    label=ba.charstr(ba.SpecialChar.DELETE),
186                    button_type='square',
187                    on_activate_call=self._del,
188                )
189            v -= key_height + 9
190            # Do space bar and stuff.
191            if row_num == 2:
192                if self._num_mode_button is None:
193                    self._num_mode_button = ba.buttonwidget(
194                        parent=self._root_widget,
195                        position=(112, v - 8),
196                        size=(key_width * 2, key_height + 5),
197                        enable_sound=False,
198                        button_type='square',
199                        extra_touch_border_scale=0.3,
200                        autoselect=True,
201                        textcolor=key_textcolor,
202                        color=key_color_dark,
203                        label='',
204                    )
205                if self._emoji_button is None:
206                    self._emoji_button = ba.buttonwidget(
207                        parent=self._root_widget,
208                        position=(56, v - 8),
209                        size=(key_width, key_height + 5),
210                        autoselect=True,
211                        enable_sound=False,
212                        textcolor=key_textcolor,
213                        color=key_color_dark,
214                        label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
215                        extra_touch_border_scale=0.3,
216                        button_type='square',
217                    )
218                btn1 = self._num_mode_button
219                if self._space_button is None:
220                    self._space_button = ba.buttonwidget(
221                        parent=self._root_widget,
222                        position=(210, v - 12),
223                        size=(key_width * 6.1, key_height + 15),
224                        extra_touch_border_scale=0.3,
225                        enable_sound=False,
226                        autoselect=True,
227                        textcolor=key_textcolor,
228                        color=key_color_dark,
229                        label=ba.Lstr(resource='spaceKeyText'),
230                        on_activate_call=ba.Call(self._type_char, ' '),
231                    )
232
233                    # Show change instructions only if we have more than one
234                    # keyboard option.
235                    keyboards = (
236                        ba.app.meta.scanresults.exports_of_class(ba.Keyboard)
237                        if ba.app.meta.scanresults is not None
238                        else []
239                    )
240                    if len(keyboards) > 1:
241                        ba.textwidget(
242                            parent=self._root_widget,
243                            h_align='center',
244                            position=(210, v - 70),
245                            size=(key_width * 6.1, key_height + 15),
246                            text=ba.Lstr(
247                                resource='keyboardChangeInstructionsText'
248                            ),
249                            scale=0.75,
250                        )
251                btn2 = self._space_button
252                btn3 = self._emoji_button
253                ba.widget(edit=btn1, right_widget=btn2, left_widget=btn3)
254                ba.widget(
255                    edit=btn2, left_widget=btn1, right_widget=self._done_button
256                )
257                ba.widget(edit=btn3, left_widget=btn1)
258                ba.widget(edit=self._done_button, left_widget=btn2)
259
260        ba.containerwidget(
261            edit=self._root_widget, selected_child=self._char_keys[14]
262        )
263
264        self._refresh()
265
266    def _get_keyboard(self) -> ba.Keyboard:
267        assert ba.app.meta.scanresults is not None
268        classname = ba.app.meta.scanresults.exports_of_class(ba.Keyboard)[
269            self._keyboard_index
270        ]
271        kbclass = ba.getclass(classname, ba.Keyboard)
272        return kbclass()
273
274    def _refresh(self) -> None:
275        chars: list[str] | None = None
276        if self._mode in ['normal', 'caps']:
277            chars = list(self._chars)
278            if self._mode == 'caps':
279                chars = [c.upper() for c in chars]
280            ba.buttonwidget(
281                edit=self._shift_button,
282                color=self._key_color_lit
283                if self._mode == 'caps'
284                else self._key_color_dark,
285                label=ba.charstr(ba.SpecialChar.SHIFT),
286                on_activate_call=self._shift,
287            )
288            ba.buttonwidget(
289                edit=self._num_mode_button,
290                label='123#&*',
291                on_activate_call=self._num_mode,
292            )
293            ba.buttonwidget(
294                edit=self._emoji_button,
295                color=self._key_color_dark,
296                label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
297                on_activate_call=self._next_mode,
298            )
299        else:
300            if self._mode == 'num':
301                chars = list(self._keyboard.nums)
302            else:
303                chars = list(self._keyboard.pages[self._mode])
304            ba.buttonwidget(
305                edit=self._shift_button,
306                color=self._key_color_dark,
307                label='',
308                on_activate_call=self._null_press,
309            )
310            ba.buttonwidget(
311                edit=self._num_mode_button,
312                label='abc',
313                on_activate_call=self._abc_mode,
314            )
315            ba.buttonwidget(
316                edit=self._emoji_button,
317                color=self._key_color_dark,
318                label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
319                on_activate_call=self._next_mode,
320            )
321
322        for i, btn in enumerate(self._char_keys):
323            assert chars is not None
324            have_char = True
325            if i >= len(chars):
326                # No such char.
327                have_char = False
328                pagename = self._mode
329                ba.print_error(
330                    f'Size of page "{pagename}" of keyboard'
331                    f' "{self._keyboard.name}" is incorrect:'
332                    f' {len(chars)} != {len(self._chars)}'
333                    f' (size of default "normal" page)',
334                    once=True,
335                )
336            ba.buttonwidget(
337                edit=btn,
338                label=chars[i] if have_char else ' ',
339                on_activate_call=ba.Call(
340                    self._type_char, chars[i] if have_char else ' '
341                ),
342            )
343
344    def _null_press(self) -> None:
345        ba.playsound(self._click_sound)
346
347    def _abc_mode(self) -> None:
348        ba.playsound(self._click_sound)
349        self._mode = 'normal'
350        self._refresh()
351
352    def _num_mode(self) -> None:
353        ba.playsound(self._click_sound)
354        self._mode = 'num'
355        self._refresh()
356
357    def _next_mode(self) -> None:
358        ba.playsound(self._click_sound)
359        self._mode_index = (self._mode_index + 1) % len(self._modes)
360        self._mode = self._modes[self._mode_index]
361        self._refresh()
362
363    def _next_keyboard(self) -> None:
364        assert ba.app.meta.scanresults is not None
365        kbexports = ba.app.meta.scanresults.exports_of_class(ba.Keyboard)
366        self._keyboard_index = (self._keyboard_index + 1) % len(kbexports)
367
368        self._load_keyboard()
369        if len(kbexports) < 2:
370            ba.playsound(ba.getsound('error'))
371            ba.screenmessage(
372                ba.Lstr(resource='keyboardNoOthersAvailableText'),
373                color=(1, 0, 0),
374            )
375        else:
376            ba.screenmessage(
377                ba.Lstr(
378                    resource='keyboardSwitchText',
379                    subs=[('${NAME}', self._keyboard.name)],
380                ),
381                color=(0, 1, 0),
382            )
383
384    def _shift(self) -> None:
385        ba.playsound(self._click_sound)
386        if self._mode == 'normal':
387            self._mode = 'caps'
388            self._double_press_shift = False
389        elif self._mode == 'caps':
390            if not self._double_press_shift:
391                self._double_press_shift = True
392            else:
393                self._mode = 'normal'
394        self._refresh()
395
396    def _del(self) -> None:
397        ba.playsound(self._click_sound)
398        txt = cast(str, ba.textwidget(query=self._text_field))
399        # pylint: disable=unsubscriptable-object
400        txt = txt[:-1]
401        ba.textwidget(edit=self._text_field, text=txt)
402
403    def _type_char(self, char: str) -> None:
404        ba.playsound(self._click_sound)
405        if char.isspace():
406            if (
407                ba.time(ba.TimeType.REAL) - self._last_space_press
408                < self._double_space_interval
409            ):
410                self._last_space_press = 0
411                self._next_keyboard()
412                self._del()  # We typed unneeded space around 1s ago.
413                return
414            self._last_space_press = ba.time(ba.TimeType.REAL)
415
416        # Operate in unicode so we don't do anything funky like chop utf-8
417        # chars in half.
418        txt = cast(str, ba.textwidget(query=self._text_field))
419        txt += char
420        ba.textwidget(edit=self._text_field, text=txt)
421
422        # If we were caps, go back only if not Shift is pressed twice.
423        if self._mode == 'caps' and not self._double_press_shift:
424            self._mode = 'normal'
425        self._refresh()
426
427    def _cancel(self) -> None:
428        ba.playsound(ba.getsound('swish'))
429        ba.containerwidget(edit=self._root_widget, transition='out_scale')
430
431    def _done(self) -> None:
432        ba.containerwidget(edit=self._root_widget, transition='out_scale')
433        if self._target_text:
434            ba.textwidget(
435                edit=self._target_text,
436                text=cast(str, ba.textwidget(query=self._text_field)),
437            )
class OnScreenKeyboardWindow(ba.ui.Window):
 17class OnScreenKeyboardWindow(ba.Window):
 18    """Simple built-in on-screen keyboard."""
 19
 20    def __init__(self, textwidget: ba.Widget, label: str, max_chars: int):
 21        self._target_text = textwidget
 22        self._width = 700
 23        self._height = 400
 24        uiscale = ba.app.ui.uiscale
 25        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
 26        super().__init__(
 27            root_widget=ba.containerwidget(
 28                parent=ba.internal.get_special_widget('overlay_stack'),
 29                size=(self._width, self._height + top_extra),
 30                transition='in_scale',
 31                scale_origin_stack_offset=(
 32                    self._target_text.get_screen_space_center()
 33                ),
 34                scale=(
 35                    2.0
 36                    if uiscale is ba.UIScale.SMALL
 37                    else 1.5
 38                    if uiscale is ba.UIScale.MEDIUM
 39                    else 1.0
 40                ),
 41                stack_offset=(0, 0)
 42                if uiscale is ba.UIScale.SMALL
 43                else (0, 0)
 44                if uiscale is ba.UIScale.MEDIUM
 45                else (0, 0),
 46            )
 47        )
 48        self._done_button = ba.buttonwidget(
 49            parent=self._root_widget,
 50            position=(self._width - 200, 44),
 51            size=(140, 60),
 52            autoselect=True,
 53            label=ba.Lstr(resource='doneText'),
 54            on_activate_call=self._done,
 55        )
 56        ba.containerwidget(
 57            edit=self._root_widget,
 58            on_cancel_call=self._cancel,
 59            start_button=self._done_button,
 60        )
 61
 62        ba.textwidget(
 63            parent=self._root_widget,
 64            position=(self._width * 0.5, self._height - 41),
 65            size=(0, 0),
 66            scale=0.95,
 67            text=label,
 68            maxwidth=self._width - 140,
 69            color=ba.app.ui.title_color,
 70            h_align='center',
 71            v_align='center',
 72        )
 73
 74        self._text_field = ba.textwidget(
 75            parent=self._root_widget,
 76            position=(70, self._height - 116),
 77            max_chars=max_chars,
 78            text=cast(str, ba.textwidget(query=self._target_text)),
 79            on_return_press_call=self._done,
 80            autoselect=True,
 81            size=(self._width - 140, 55),
 82            v_align='center',
 83            editable=True,
 84            maxwidth=self._width - 175,
 85            force_internal_editing=True,
 86            always_show_carat=True,
 87        )
 88
 89        self._key_color_lit = (1.4, 1.2, 1.4)
 90        self._key_color = (0.69, 0.6, 0.74)
 91        self._key_color_dark = (0.55, 0.55, 0.71)
 92
 93        self._shift_button: ba.Widget | None = None
 94        self._backspace_button: ba.Widget | None = None
 95        self._space_button: ba.Widget | None = None
 96        self._double_press_shift = False
 97        self._num_mode_button: ba.Widget | None = None
 98        self._emoji_button: ba.Widget | None = None
 99        self._char_keys: list[ba.Widget] = []
100        self._keyboard_index = 0
101        self._last_space_press = 0.0
102        self._double_space_interval = 0.3
103
104        self._keyboard: ba.Keyboard
105        self._chars: list[str]
106        self._modes: list[str]
107        self._mode: str
108        self._mode_index: int
109        self._load_keyboard()
110
111    def _load_keyboard(self) -> None:
112        # pylint: disable=too-many-locals
113        self._keyboard = self._get_keyboard()
114        # We want to get just chars without column data, etc.
115        self._chars = [j for i in self._keyboard.chars for j in i]
116        self._modes = ['normal'] + list(self._keyboard.pages)
117        self._mode_index = 0
118        self._mode = self._modes[self._mode_index]
119
120        v = self._height - 180.0
121        key_width = 46 * 10 / len(self._keyboard.chars[0])
122        key_height = 46 * 3 / len(self._keyboard.chars)
123        key_textcolor = (1, 1, 1)
124        row_starts = (69.0, 95.0, 151.0)
125        key_color = self._key_color
126        key_color_dark = self._key_color_dark
127
128        self._click_sound = ba.getsound('click01')
129
130        # kill prev char keys
131        for key in self._char_keys:
132            key.delete()
133        self._char_keys = []
134
135        # dummy data just used for row/column lengths... we don't actually
136        # set things until refresh
137        chars: list[tuple[str, ...]] = self._keyboard.chars
138
139        for row_num, row in enumerate(chars):
140            h = row_starts[row_num]
141            # shift key before row 3
142            if row_num == 2 and self._shift_button is None:
143                self._shift_button = ba.buttonwidget(
144                    parent=self._root_widget,
145                    position=(h - key_width * 2.0, v),
146                    size=(key_width * 1.7, key_height),
147                    autoselect=True,
148                    textcolor=key_textcolor,
149                    color=key_color_dark,
150                    label=ba.charstr(ba.SpecialChar.SHIFT),
151                    enable_sound=False,
152                    extra_touch_border_scale=0.3,
153                    button_type='square',
154                )
155
156            for _ in row:
157                btn = ba.buttonwidget(
158                    parent=self._root_widget,
159                    position=(h, v),
160                    size=(key_width, key_height),
161                    autoselect=True,
162                    enable_sound=False,
163                    textcolor=key_textcolor,
164                    color=key_color,
165                    label='',
166                    button_type='square',
167                    extra_touch_border_scale=0.1,
168                )
169                self._char_keys.append(btn)
170                h += key_width + 10
171
172            # Add delete key at end of third row.
173            if row_num == 2:
174                if self._backspace_button is not None:
175                    self._backspace_button.delete()
176
177                self._backspace_button = ba.buttonwidget(
178                    parent=self._root_widget,
179                    position=(h + 4, v),
180                    size=(key_width * 1.8, key_height),
181                    autoselect=True,
182                    enable_sound=False,
183                    repeat=True,
184                    textcolor=key_textcolor,
185                    color=key_color_dark,
186                    label=ba.charstr(ba.SpecialChar.DELETE),
187                    button_type='square',
188                    on_activate_call=self._del,
189                )
190            v -= key_height + 9
191            # Do space bar and stuff.
192            if row_num == 2:
193                if self._num_mode_button is None:
194                    self._num_mode_button = ba.buttonwidget(
195                        parent=self._root_widget,
196                        position=(112, v - 8),
197                        size=(key_width * 2, key_height + 5),
198                        enable_sound=False,
199                        button_type='square',
200                        extra_touch_border_scale=0.3,
201                        autoselect=True,
202                        textcolor=key_textcolor,
203                        color=key_color_dark,
204                        label='',
205                    )
206                if self._emoji_button is None:
207                    self._emoji_button = ba.buttonwidget(
208                        parent=self._root_widget,
209                        position=(56, v - 8),
210                        size=(key_width, key_height + 5),
211                        autoselect=True,
212                        enable_sound=False,
213                        textcolor=key_textcolor,
214                        color=key_color_dark,
215                        label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
216                        extra_touch_border_scale=0.3,
217                        button_type='square',
218                    )
219                btn1 = self._num_mode_button
220                if self._space_button is None:
221                    self._space_button = ba.buttonwidget(
222                        parent=self._root_widget,
223                        position=(210, v - 12),
224                        size=(key_width * 6.1, key_height + 15),
225                        extra_touch_border_scale=0.3,
226                        enable_sound=False,
227                        autoselect=True,
228                        textcolor=key_textcolor,
229                        color=key_color_dark,
230                        label=ba.Lstr(resource='spaceKeyText'),
231                        on_activate_call=ba.Call(self._type_char, ' '),
232                    )
233
234                    # Show change instructions only if we have more than one
235                    # keyboard option.
236                    keyboards = (
237                        ba.app.meta.scanresults.exports_of_class(ba.Keyboard)
238                        if ba.app.meta.scanresults is not None
239                        else []
240                    )
241                    if len(keyboards) > 1:
242                        ba.textwidget(
243                            parent=self._root_widget,
244                            h_align='center',
245                            position=(210, v - 70),
246                            size=(key_width * 6.1, key_height + 15),
247                            text=ba.Lstr(
248                                resource='keyboardChangeInstructionsText'
249                            ),
250                            scale=0.75,
251                        )
252                btn2 = self._space_button
253                btn3 = self._emoji_button
254                ba.widget(edit=btn1, right_widget=btn2, left_widget=btn3)
255                ba.widget(
256                    edit=btn2, left_widget=btn1, right_widget=self._done_button
257                )
258                ba.widget(edit=btn3, left_widget=btn1)
259                ba.widget(edit=self._done_button, left_widget=btn2)
260
261        ba.containerwidget(
262            edit=self._root_widget, selected_child=self._char_keys[14]
263        )
264
265        self._refresh()
266
267    def _get_keyboard(self) -> ba.Keyboard:
268        assert ba.app.meta.scanresults is not None
269        classname = ba.app.meta.scanresults.exports_of_class(ba.Keyboard)[
270            self._keyboard_index
271        ]
272        kbclass = ba.getclass(classname, ba.Keyboard)
273        return kbclass()
274
275    def _refresh(self) -> None:
276        chars: list[str] | None = None
277        if self._mode in ['normal', 'caps']:
278            chars = list(self._chars)
279            if self._mode == 'caps':
280                chars = [c.upper() for c in chars]
281            ba.buttonwidget(
282                edit=self._shift_button,
283                color=self._key_color_lit
284                if self._mode == 'caps'
285                else self._key_color_dark,
286                label=ba.charstr(ba.SpecialChar.SHIFT),
287                on_activate_call=self._shift,
288            )
289            ba.buttonwidget(
290                edit=self._num_mode_button,
291                label='123#&*',
292                on_activate_call=self._num_mode,
293            )
294            ba.buttonwidget(
295                edit=self._emoji_button,
296                color=self._key_color_dark,
297                label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
298                on_activate_call=self._next_mode,
299            )
300        else:
301            if self._mode == 'num':
302                chars = list(self._keyboard.nums)
303            else:
304                chars = list(self._keyboard.pages[self._mode])
305            ba.buttonwidget(
306                edit=self._shift_button,
307                color=self._key_color_dark,
308                label='',
309                on_activate_call=self._null_press,
310            )
311            ba.buttonwidget(
312                edit=self._num_mode_button,
313                label='abc',
314                on_activate_call=self._abc_mode,
315            )
316            ba.buttonwidget(
317                edit=self._emoji_button,
318                color=self._key_color_dark,
319                label=ba.charstr(ba.SpecialChar.LOGO_FLAT),
320                on_activate_call=self._next_mode,
321            )
322
323        for i, btn in enumerate(self._char_keys):
324            assert chars is not None
325            have_char = True
326            if i >= len(chars):
327                # No such char.
328                have_char = False
329                pagename = self._mode
330                ba.print_error(
331                    f'Size of page "{pagename}" of keyboard'
332                    f' "{self._keyboard.name}" is incorrect:'
333                    f' {len(chars)} != {len(self._chars)}'
334                    f' (size of default "normal" page)',
335                    once=True,
336                )
337            ba.buttonwidget(
338                edit=btn,
339                label=chars[i] if have_char else ' ',
340                on_activate_call=ba.Call(
341                    self._type_char, chars[i] if have_char else ' '
342                ),
343            )
344
345    def _null_press(self) -> None:
346        ba.playsound(self._click_sound)
347
348    def _abc_mode(self) -> None:
349        ba.playsound(self._click_sound)
350        self._mode = 'normal'
351        self._refresh()
352
353    def _num_mode(self) -> None:
354        ba.playsound(self._click_sound)
355        self._mode = 'num'
356        self._refresh()
357
358    def _next_mode(self) -> None:
359        ba.playsound(self._click_sound)
360        self._mode_index = (self._mode_index + 1) % len(self._modes)
361        self._mode = self._modes[self._mode_index]
362        self._refresh()
363
364    def _next_keyboard(self) -> None:
365        assert ba.app.meta.scanresults is not None
366        kbexports = ba.app.meta.scanresults.exports_of_class(ba.Keyboard)
367        self._keyboard_index = (self._keyboard_index + 1) % len(kbexports)
368
369        self._load_keyboard()
370        if len(kbexports) < 2:
371            ba.playsound(ba.getsound('error'))
372            ba.screenmessage(
373                ba.Lstr(resource='keyboardNoOthersAvailableText'),
374                color=(1, 0, 0),
375            )
376        else:
377            ba.screenmessage(
378                ba.Lstr(
379                    resource='keyboardSwitchText',
380                    subs=[('${NAME}', self._keyboard.name)],
381                ),
382                color=(0, 1, 0),
383            )
384
385    def _shift(self) -> None:
386        ba.playsound(self._click_sound)
387        if self._mode == 'normal':
388            self._mode = 'caps'
389            self._double_press_shift = False
390        elif self._mode == 'caps':
391            if not self._double_press_shift:
392                self._double_press_shift = True
393            else:
394                self._mode = 'normal'
395        self._refresh()
396
397    def _del(self) -> None:
398        ba.playsound(self._click_sound)
399        txt = cast(str, ba.textwidget(query=self._text_field))
400        # pylint: disable=unsubscriptable-object
401        txt = txt[:-1]
402        ba.textwidget(edit=self._text_field, text=txt)
403
404    def _type_char(self, char: str) -> None:
405        ba.playsound(self._click_sound)
406        if char.isspace():
407            if (
408                ba.time(ba.TimeType.REAL) - self._last_space_press
409                < self._double_space_interval
410            ):
411                self._last_space_press = 0
412                self._next_keyboard()
413                self._del()  # We typed unneeded space around 1s ago.
414                return
415            self._last_space_press = ba.time(ba.TimeType.REAL)
416
417        # Operate in unicode so we don't do anything funky like chop utf-8
418        # chars in half.
419        txt = cast(str, ba.textwidget(query=self._text_field))
420        txt += char
421        ba.textwidget(edit=self._text_field, text=txt)
422
423        # If we were caps, go back only if not Shift is pressed twice.
424        if self._mode == 'caps' and not self._double_press_shift:
425            self._mode = 'normal'
426        self._refresh()
427
428    def _cancel(self) -> None:
429        ba.playsound(ba.getsound('swish'))
430        ba.containerwidget(edit=self._root_widget, transition='out_scale')
431
432    def _done(self) -> None:
433        ba.containerwidget(edit=self._root_widget, transition='out_scale')
434        if self._target_text:
435            ba.textwidget(
436                edit=self._target_text,
437                text=cast(str, ba.textwidget(query=self._text_field)),
438            )

Simple built-in on-screen keyboard.

OnScreenKeyboardWindow(textwidget: _ba.Widget, label: str, max_chars: int)
 20    def __init__(self, textwidget: ba.Widget, label: str, max_chars: int):
 21        self._target_text = textwidget
 22        self._width = 700
 23        self._height = 400
 24        uiscale = ba.app.ui.uiscale
 25        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
 26        super().__init__(
 27            root_widget=ba.containerwidget(
 28                parent=ba.internal.get_special_widget('overlay_stack'),
 29                size=(self._width, self._height + top_extra),
 30                transition='in_scale',
 31                scale_origin_stack_offset=(
 32                    self._target_text.get_screen_space_center()
 33                ),
 34                scale=(
 35                    2.0
 36                    if uiscale is ba.UIScale.SMALL
 37                    else 1.5
 38                    if uiscale is ba.UIScale.MEDIUM
 39                    else 1.0
 40                ),
 41                stack_offset=(0, 0)
 42                if uiscale is ba.UIScale.SMALL
 43                else (0, 0)
 44                if uiscale is ba.UIScale.MEDIUM
 45                else (0, 0),
 46            )
 47        )
 48        self._done_button = ba.buttonwidget(
 49            parent=self._root_widget,
 50            position=(self._width - 200, 44),
 51            size=(140, 60),
 52            autoselect=True,
 53            label=ba.Lstr(resource='doneText'),
 54            on_activate_call=self._done,
 55        )
 56        ba.containerwidget(
 57            edit=self._root_widget,
 58            on_cancel_call=self._cancel,
 59            start_button=self._done_button,
 60        )
 61
 62        ba.textwidget(
 63            parent=self._root_widget,
 64            position=(self._width * 0.5, self._height - 41),
 65            size=(0, 0),
 66            scale=0.95,
 67            text=label,
 68            maxwidth=self._width - 140,
 69            color=ba.app.ui.title_color,
 70            h_align='center',
 71            v_align='center',
 72        )
 73
 74        self._text_field = ba.textwidget(
 75            parent=self._root_widget,
 76            position=(70, self._height - 116),
 77            max_chars=max_chars,
 78            text=cast(str, ba.textwidget(query=self._target_text)),
 79            on_return_press_call=self._done,
 80            autoselect=True,
 81            size=(self._width - 140, 55),
 82            v_align='center',
 83            editable=True,
 84            maxwidth=self._width - 175,
 85            force_internal_editing=True,
 86            always_show_carat=True,
 87        )
 88
 89        self._key_color_lit = (1.4, 1.2, 1.4)
 90        self._key_color = (0.69, 0.6, 0.74)
 91        self._key_color_dark = (0.55, 0.55, 0.71)
 92
 93        self._shift_button: ba.Widget | None = None
 94        self._backspace_button: ba.Widget | None = None
 95        self._space_button: ba.Widget | None = None
 96        self._double_press_shift = False
 97        self._num_mode_button: ba.Widget | None = None
 98        self._emoji_button: ba.Widget | None = None
 99        self._char_keys: list[ba.Widget] = []
100        self._keyboard_index = 0
101        self._last_space_press = 0.0
102        self._double_space_interval = 0.3
103
104        self._keyboard: ba.Keyboard
105        self._chars: list[str]
106        self._modes: list[str]
107        self._mode: str
108        self._mode_index: int
109        self._load_keyboard()
Inherited Members
ba.ui.Window
get_root_widget