From 70fc2e64cd71aa70708c93f1f34fd063b7b75790 Mon Sep 17 00:00:00 2001 From: Michael Hudson-Doyle Date: Mon, 21 Sep 2020 14:56:47 +1200 Subject: [PATCH] make keyboard view/controller interface more client/server friendly --- subiquity/common/keyboard.py | 83 +++++++ .../{models => common}/tests/test_keyboard.py | 14 +- subiquity/common/types.py | 9 +- subiquity/controllers/keyboard.py | 54 +++-- subiquity/keyboard.py | 154 ++++++++++++ subiquity/models/keyboard.py | 223 ++---------------- subiquity/ui/views/keyboard.py | 35 +-- 7 files changed, 327 insertions(+), 245 deletions(-) create mode 100644 subiquity/common/keyboard.py rename subiquity/{models => common}/tests/test_keyboard.py (81%) create mode 100644 subiquity/keyboard.py diff --git a/subiquity/common/keyboard.py b/subiquity/common/keyboard.py new file mode 100644 index 00000000..3a934699 --- /dev/null +++ b/subiquity/common/keyboard.py @@ -0,0 +1,83 @@ +# Copyright 2020 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os +import re + +from subiquitycore.utils import arun_command + +from subiquity.common.types import KeyboardSetting + + +etc_default_keyboard_template = """\ +# KEYBOARD CONFIGURATION FILE + +# Consult the keyboard(5) manual page. + +XKBMODEL="pc105" +XKBLAYOUT="{layout}" +XKBVARIANT="{variant}" +XKBOPTIONS="{options}" + +BACKSPACE="guess" +""" + + +def from_config_file(config_file): + with open(config_file) as fp: + content = fp.read() + + def optval(opt, default): + match = re.search(r'(?m)^\s*%s=(.*)$' % (opt,), content) + if match: + r = match.group(1).strip('"') + if r != '': + return r + return default + + XKBLAYOUT = optval("XKBLAYOUT", "us") + XKBVARIANT = optval("XKBVARIANT", "") + XKBOPTIONS = optval("XKBOPTIONS", "") + toggle = None + for option in XKBOPTIONS.split(','): + if option.startswith('grp:'): + toggle = option[4:] + return KeyboardSetting(layout=XKBLAYOUT, variant=XKBVARIANT, toggle=toggle) + + +def render(setting): + options = "" + if setting.toggle: + options = "grp:" + setting.toggle + return etc_default_keyboard_template.format( + layout=setting.layout, + variant=setting.variant, + options=options) + + +async def set_keyboard(root, setting, dry_run): + path = os.path.join(root, 'etc', 'default', 'keyboard') + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as fp: + fp.write(render(setting)) + cmds = [ + ['setupcon', '--save', '--force', '--keyboard-only'], + ['/snap/bin/subiquity.subiquity-loadkeys'], + ] + if dry_run: + scale = os.environ.get('SUBIQUITY_REPLAY_TIMESCALE', "1") + cmds = [['sleep', str(1/float(scale))]] + for cmd in cmds: + await arun_command(cmd) diff --git a/subiquity/models/tests/test_keyboard.py b/subiquity/common/tests/test_keyboard.py similarity index 81% rename from subiquity/models/tests/test_keyboard.py rename to subiquity/common/tests/test_keyboard.py index a63b3889..e312ab39 100644 --- a/subiquity/models/tests/test_keyboard.py +++ b/subiquity/common/tests/test_keyboard.py @@ -18,10 +18,11 @@ import os import tempfile import unittest -from subiquity.models.keyboard import ( - KeyboardModel, - KeyboardSetting, +from subiquity.common.keyboard import ( + from_config_file, + set_keyboard, ) +from subiquity.common.types import KeyboardSetting class TestSubiquityModel(unittest.TestCase): @@ -36,11 +37,10 @@ class TestSubiquityModel(unittest.TestCase): async def t(): os.environ['SUBIQUITY_REPLAY_TIMESCALE'] = '100' with tempfile.TemporaryDirectory() as tmpdir: - model = KeyboardModel(tmpdir) new_setting = KeyboardSetting('fr', 'azerty') - await model.set_keyboard(new_setting) - read_setting = KeyboardSetting.from_config_file( - model.config_path) + await set_keyboard(tmpdir, new_setting, True) + read_setting = from_config_file( + os.path.join(tmpdir, 'etc', 'default', 'keyboard')) self.assertEqual(new_setting, read_setting) loop.run_until_complete(t()) loop.close() diff --git a/subiquity/common/types.py b/subiquity/common/types.py index 0d40fe96..1d7462c9 100644 --- a/subiquity/common/types.py +++ b/subiquity/common/types.py @@ -19,11 +19,18 @@ import datetime import enum -from typing import List +from typing import List, Optional import attr +@attr.s(auto_attribs=True) +class KeyboardSetting: + layout: str + variant: str = '' + toggle: Optional[str] = None + + @attr.s(auto_attribs=True) class IdentityData: realname: str = '' diff --git a/subiquity/controllers/keyboard.py b/subiquity/controllers/keyboard.py index eda3bc21..17e6ec6d 100644 --- a/subiquity/controllers/keyboard.py +++ b/subiquity/controllers/keyboard.py @@ -17,10 +17,11 @@ import logging import attr -from subiquitycore.async_helpers import schedule_task from subiquitycore.context import with_context +from subiquity.common.keyboard import set_keyboard from subiquity.controller import SubiquityTuiController +from subiquity.keyboard import KeyboardList from subiquity.models.keyboard import KeyboardSetting from subiquity.ui.views import KeyboardView @@ -44,42 +45,57 @@ class KeyboardController(SubiquityTuiController): ('l10n:language-selected', 'language_selected'), ] + def __init__(self, app): + self.needs_set_keyboard = False + super().__init__(app) + self.keyboard_list = KeyboardList() + def load_autoinstall_data(self, data): - if data is not None: - self.model.setting = KeyboardSetting(**data) + if data is None: + return + setting = KeyboardSetting(**data) + if self.model.setting != setting: + self.needs_set_keyboard = True + self.model.setting = setting @with_context() async def apply_autoinstall_config(self, context): - await self.model.set_keyboard(self.model.setting) + if self.needs_set_keyboard: + await set_keyboard( + self.app.root, self.model.setting, self.opts.dry_run) def language_selected(self, code): log.debug("language_selected %s", code) - if not self.model.has_language(code): + if not self.keyboard_list.has_language(code): code = code.split('_')[0] - if not self.model.has_language(code): + if not self.keyboard_list.has_language(code): code = 'C' - log.debug("loading launguage %s", code) - self.model.load_language(code) + log.debug("loading language %s", code) + self.keyboard_list.load_language(code) def make_ui(self): - if self.model.current_lang is None: - self.model.load_language('C') - return KeyboardView(self.model, self, self.opts) + if self.keyboard_list.current_lang is None: + self.keyboard_list.load_language('C') + return KeyboardView(self, self.model.setting) def run_answers(self): if 'layout' in self.answers: layout = self.answers['layout'] variant = self.answers.get('variant', '') - self.done(KeyboardSetting(layout=layout, variant=variant)) + self.done(KeyboardSetting(layout=layout, variant=variant), True) - async def apply_settings(self, setting): - await self.model.set_keyboard(setting) - log.debug("KeyboardController next_screen") - self.configured() - self.app.next_screen() + async def set_keyboard(self, setting): + await set_keyboard(self.app.root, setting, self.opts.dry_run) + self.done(setting, False) - def done(self, setting): - schedule_task(self.apply_settings(setting)) + def done(self, setting, apply): + log.debug("KeyboardController.done %s next_screen", setting) + if apply: + self.app.aio_loop.create_task(self.set_keyboard(setting)) + else: + self.model.setting = setting + self.configured() + self.app.next_screen() def cancel(self): self.app.prev_screen() diff --git a/subiquity/keyboard.py b/subiquity/keyboard.py new file mode 100644 index 00000000..12c9ba06 --- /dev/null +++ b/subiquity/keyboard.py @@ -0,0 +1,154 @@ +# Copyright 2020 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from collections import defaultdict +import os + +from subiquity.common.types import KeyboardSetting + + +# Non-latin keyboard layouts that are handled in a uniform way +standard_non_latin_layouts = set( + ('af', 'am', 'ara', 'ben', 'bd', 'bg', 'bt', 'by', 'et', 'ge', + 'gh', 'gr', 'guj', 'guru', 'il', 'in', 'iq', 'ir', 'iku', 'kan', + 'kh', 'kz', 'la', 'lao', 'lk', 'kg', 'ma', 'mk', 'mm', 'mn', 'mv', + 'mal', 'np', 'ori', 'pk', 'ru', 'scc', 'sy', 'syr', 'tel', 'th', + 'tj', 'tam', 'tib', 'ua', 'ug', 'uz') +) + + +def latinizable(setting): + """ + If this setting does not allow the typing of latin characters, + return a setting that can be switched to one that can. + """ + if setting.layout == 'rs': + if setting.variant.startswith('latin'): + return setting + else: + if setting.variant == 'yz': + new_variant = 'latinyz' + elif setting.variant == 'alternatequotes': + new_variant = 'latinalternatequotes' + else: + new_variant = 'latin' + return KeyboardSetting(layout='rs,rs', + variant=(new_variant + + ',' + setting.variant)) + elif setting.layout == 'jp': + if setting.variant in ('106', 'common', 'OADG109A', + 'nicola_f_bs', ''): + return setting + else: + return KeyboardSetting(layout='jp,jp', + variant=',' + setting.variant) + elif setting.layout == 'lt': + if setting.variant == 'us': + return KeyboardSetting(layout='lt,lt', variant='us,') + else: + return KeyboardSetting(layout='lt,lt', + variant=setting.variant + ',us') + elif setting.layout == 'me': + if setting.variant == 'basic' or setting.variant.startswith('latin'): + return setting + else: + return KeyboardSetting(layout='me,me', + variant=setting.variant + ',us') + elif setting.layout in standard_non_latin_layouts: + return KeyboardSetting(layout='us,' + setting.layout, + variant=',' + setting.variant) + else: + return setting + + +def for_ui(setting): + """ + Attempt to guess a setting the user chose which resulted in the + current config. Basically the inverse of latinizable(). + """ + if ',' in setting.layout: + layout1, layout2 = setting.layout.split(',', 1) + else: + layout1, layout2 = setting.layout, '' + if ',' in setting.variant: + variant1, variant2 = setting.variant.split(',', 1) + else: + variant1, variant2 = setting.variant, '' + if setting.layout == 'lt,lt': + layout = layout1 + variant = variant1 + elif setting.layout in ('rs,rs', 'us,rs', 'jp,jp', 'us,jp'): + layout = layout2 + variant = variant2 + elif layout1 == 'us' and layout2 in standard_non_latin_layouts: + layout = layout2 + variant = variant2 + elif ',' in setting.layout: + # Something unrecognized + layout = 'us' + variant = '' + else: + return setting + return KeyboardSetting(layout=layout, variant=variant) + + +class KeyboardList: + + def __init__(self): + self._kbnames_file = os.path.join( + os.environ.get("SNAP", '.'), + 'kbdnames.txt') + self._clear() + + def has_language(self, code): + self.load_language(code) + return bool(self.layouts) + + def load_language(self, code): + if code == self.current_lang: + return + + self._clear() + + with open(self._kbnames_file, encoding='utf-8') as kbdnames: + self._load_file(code, kbdnames) + self.current_lang = code + + def _clear(self): + self.current_lang = None + self.layouts = {} + self.variants = defaultdict(dict) + + def _load_file(self, code, kbdnames): + for line in kbdnames: + line = line.rstrip('\n') + got_lang, element, name, value = line.split("*", 3) + if got_lang != code: + continue + + if element == "layout": + self.layouts[name] = value + elif element == "variant": + variantname, variantdesc = value.split("*", 1) + self.variants[name][variantname] = variantdesc + + def lookup(self, code): + if ':' in code: + layout_code, variant_code = code.split(":", 1) + layout = self.layouts.get(layout_code, '?') + variant = self.variants.get(layout_code, {}).get(variant_code, '?') + return (layout, variant) + else: + return self.layouts.get(code, '?'), None diff --git a/subiquity/models/keyboard.py b/subiquity/models/keyboard.py index 288c2359..8330bc3a 100644 --- a/subiquity/models/keyboard.py +++ b/subiquity/models/keyboard.py @@ -1,224 +1,43 @@ +# Copyright 2020 Canonical, Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . -from collections import defaultdict import logging import os -import re -import attr - -from subiquitycore.utils import arun_command +from subiquity.common.keyboard import from_config_file, render +from subiquity.common.types import KeyboardSetting log = logging.getLogger("subiquity.models.keyboard") -etc_default_keyboard_template = """\ -# KEYBOARD CONFIGURATION FILE - -# Consult the keyboard(5) manual page. - -XKBMODEL="pc105" -XKBLAYOUT="{layout}" -XKBVARIANT="{variant}" -XKBOPTIONS="{options}" - -BACKSPACE="guess" -""" - - -@attr.s -class KeyboardSetting: - layout = attr.ib() - variant = attr.ib(default='') - toggle = attr.ib(default=None) - - def render(self): - options = "" - if self.toggle: - options = "grp:" + self.toggle - return etc_default_keyboard_template.format( - layout=self.layout, variant=self.variant, options=options) - - def latinizable(self): - """ - If this setting does not allow the typing of latin characters, - return a setting that can be switched to one that can. - """ - if self.layout == 'rs': - if self.variant.startswith('latin'): - return self - else: - if self.variant == 'yz': - new_variant = 'latinyz' - elif self.variant == 'alternatequotes': - new_variant = 'latinalternatequotes' - else: - new_variant = 'latin' - return KeyboardSetting(layout='rs,rs', - variant=(new_variant + - ',' + self.variant)) - elif self.layout == 'jp': - if self.variant in ('106', 'common', 'OADG109A', - 'nicola_f_bs', ''): - return self - else: - return KeyboardSetting(layout='jp,jp', - variant=',' + self.variant) - elif self.layout == 'lt': - if self.variant == 'us': - return KeyboardSetting(layout='lt,lt', variant='us,') - else: - return KeyboardSetting(layout='lt,lt', - variant=self.variant + ',us') - elif self.layout == 'me': - if self.variant == 'basic' or self.variant.startswith('latin'): - return self - else: - return KeyboardSetting(layout='me,me', - variant=self.variant + ',us') - elif self.layout in standard_non_latin_layouts: - return KeyboardSetting(layout='us,' + self.layout, - variant=',' + self.variant) - else: - return self - - @classmethod - def from_config_file(cls, config_file): - with open(config_file) as fp: - content = fp.read() - - def optval(opt, default): - match = re.search(r'(?m)^\s*%s=(.*)$' % (opt,), content) - if match: - r = match.group(1).strip('"') - if r != '': - return r - return default - XKBLAYOUT = optval("XKBLAYOUT", "us") - XKBVARIANT = optval("XKBVARIANT", "") - XKBOPTIONS = optval("XKBOPTIONS", "") - toggle = None - for option in XKBOPTIONS.split(','): - if option.startswith('grp:'): - toggle = option[4:] - return cls(layout=XKBLAYOUT, variant=XKBVARIANT, toggle=toggle) - - def for_ui(self): - """ - Attempt to guess a setting the user chose which resulted in the - current config. Basically the inverse of latinizable(). - """ - if ',' in self.layout: - layout1, layout2 = self.layout.split(',', 1) - else: - layout1, layout2 = self.layout, '' - if ',' in self.variant: - variant1, variant2 = self.variant.split(',', 1) - else: - variant1, variant2 = self.variant, '' - if self.layout == 'lt,lt': - layout = layout1 - variant = variant1 - elif self.layout in ('rs,rs', 'us,rs', 'jp,jp', 'us,jp'): - layout = layout2 - variant = variant2 - elif layout1 == 'us' and layout2 in standard_non_latin_layouts: - layout = layout2 - variant = variant2 - elif ',' in self.layout: - # Something unrecognized - layout = 'us' - variant = '' - else: - return self - return KeyboardSetting(layout=layout, variant=variant) - - -# Non-latin keyboard layouts that are handled in a uniform way -standard_non_latin_layouts = set( - ('af', 'am', 'ara', 'ben', 'bd', 'bg', 'bt', 'by', 'et', 'ge', - 'gh', 'gr', 'guj', 'guru', 'il', 'in', 'iq', 'ir', 'iku', 'kan', - 'kh', 'kz', 'la', 'lao', 'lk', 'kg', 'ma', 'mk', 'mm', 'mn', 'mv', - 'mal', 'np', 'ori', 'pk', 'ru', 'scc', 'sy', 'syr', 'tel', 'th', - 'tj', 'tam', 'tib', 'ua', 'ug', 'uz') -) - class KeyboardModel: + def __init__(self, root): self.root = root - self._kbnames_file = os.path.join(os.environ.get("SNAP", '.'), - 'kbdnames.txt') - self._clear() - if os.path.exists(self.config_path): - self.setting = KeyboardSetting.from_config_file(self.config_path) + config_path = os.path.join(self.root, 'etc', 'default', 'keyboard') + if os.path.exists(config_path): + self.setting = from_config_file(config_path) else: self.setting = KeyboardSetting(layout='us') - @property - def config_path(self): - return os.path.join(self.root, 'etc', 'default', 'keyboard') - - def has_language(self, code): - self.load_language(code) - return bool(self.layouts) - - def load_language(self, code): - if code == self.current_lang: - return - - self._clear() - - with open(self._kbnames_file, encoding='utf-8') as kbdnames: - self._load_file(code, kbdnames) - self.current_lang = code - - def _clear(self): - self.current_lang = None - self.layouts = {} - self.variants = defaultdict(dict) - - def _load_file(self, code, kbdnames): - for line in kbdnames: - line = line.rstrip('\n') - got_lang, element, name, value = line.split("*", 3) - if got_lang != code: - continue - - if element == "layout": - self.layouts[name] = value - elif element == "variant": - variantname, variantdesc = value.split("*", 1) - self.variants[name][variantname] = variantdesc - - def lookup(self, code): - if ':' in code: - layout_code, variant_code = code.split(":", 1) - layout = self.layouts.get(layout_code, '?') - variant = self.variants.get(layout_code, {}).get(variant_code, '?') - return (layout, variant) - else: - return self.layouts.get(code, '?'), None - - async def set_keyboard(self, setting): - path = self.config_path - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w') as fp: - fp.write(setting.render()) - if setting != self.setting: - self.setting = setting - if self.root == '/': - await arun_command([ - 'setupcon', '--save', '--force', '--keyboard-only']) - await arun_command(['/snap/bin/subiquity.subiquity-loadkeys']) - else: - scale = os.environ.get('SUBIQUITY_REPLAY_TIMESCALE', "1") - await arun_command(['sleep', str(1/float(scale))]) - def render(self): return { 'write_files': { 'etc_default_keyboard': { 'path': 'etc/default/keyboard', - 'content': self.setting.render(), + 'content': render(self.setting), 'permissions': 0o644, }, }, diff --git a/subiquity/ui/views/keyboard.py b/subiquity/ui/views/keyboard.py index 3a2fbfb7..1514ee28 100644 --- a/subiquity/ui/views/keyboard.py +++ b/subiquity/ui/views/keyboard.py @@ -44,7 +44,8 @@ from subiquitycore.ui.stretchy import ( from subiquitycore.ui.utils import button_pile, Color, Padding, screen from subiquitycore.view import BaseView -from subiquity.models.keyboard import KeyboardSetting +from subiquity.common.types import KeyboardSetting +from subiquity.keyboard import latinizable, for_ui from subiquity.ui.views import pc105 log = logging.getLogger("subiquity.ui.views.keyboard") @@ -129,8 +130,8 @@ another layout or run the automated detection again. self.keyboard_detector.keyboard_view.found_layout(self.step.result) def make_body(self): - model = self.keyboard_detector.keyboard_view.model - layout, variant = model.lookup(self.step.result) + kl = self.keyboard_detector.keyboard_view.keyboard_list + layout, variant = kl.lookup(self.step.result) var_desc = [] layout_text = _("Layout") var_text = _("Variant") @@ -340,9 +341,9 @@ class ToggleQuestion(Stretchy): self.setting = setting self.selector = Selector(toggle_options) self.selector.value = 'alt_shift_toggle' - if self.parent.model.setting.toggle: + if self.parent.initial_setting.toggle: try: - self.selector.value = self.parent.model.setting.toggle + self.selector.value = self.parent.initial_setting.toggle except AttributeError: pass @@ -386,21 +387,21 @@ class KeyboardView(BaseView): title = _("Keyboard configuration") - def __init__(self, model, controller, opts): - self.model = model + def __init__(self, controller, initial_setting): self.controller = controller - self.opts = opts + self.keyboard_list = controller.keyboard_list + self.initial_setting = initial_setting self.form = KeyboardForm() opts = [] - for layout, desc in model.layouts.items(): + for layout, desc in self.keyboard_list.layouts.items(): opts.append(Option((desc, True, layout))) opts.sort(key=lambda o: o.label.text) connect_signal(self.form, 'submit', self.done) connect_signal(self.form, 'cancel', self.cancel) connect_signal(self.form.layout.widget, "select", self.select_layout) self.form.layout.widget.options = opts - setting = model.setting.for_ui() + setting = for_ui(initial_setting) try: self.form.layout.widget.value = setting.layout except AttributeError: @@ -413,7 +414,7 @@ class KeyboardView(BaseView): # Don't crash on pre-existing invalid config. pass - if self.opts.run_on_serial: + if self.controller.opts.run_on_serial: excerpt = _('Please select the layout of the keyboard directly ' 'attached to the system, if any.') else: @@ -422,7 +423,7 @@ class KeyboardView(BaseView): 'automatically.') lb_contents = self.form.as_rows() - if not self.opts.run_on_serial: + if not self.controller.opts.run_on_serial: lb_contents.extend([ Text(""), button_pile([ @@ -457,17 +458,19 @@ class KeyboardView(BaseView): if self.form.variant.widget.value is not None: variant = self.form.variant.widget.value setting = KeyboardSetting(layout=layout, variant=variant) - new_setting = setting.latinizable() + new_setting = latinizable(setting) if new_setting != setting: self.show_stretchy_overlay(ToggleQuestion(self, new_setting)) return self.really_done(setting) def really_done(self, setting): - if setting != self.model.setting: + apply = False + if setting != self.initial_setting: + apply = True ac = ApplyingConfig(self.controller.app.aio_loop) self.show_overlay(ac, width=ac.width, min_width=None) - self.controller.done(setting) + self.controller.done(setting, apply=apply) def cancel(self, result=None): self.controller.cancel() @@ -477,7 +480,7 @@ class KeyboardView(BaseView): log.debug("select_layout %s", layout) opts = [] default_i = -1 - layout_items = enumerate(self.model.variants[layout].items()) + layout_items = enumerate(self.keyboard_list.variants[layout].items()) for i, (variant, variant_desc) in layout_items: if variant == "": default_i = i