Esempio n. 1
0
class EngineHiragana(IBus.Engine):
    __gtype_name__ = 'EngineHiragana'

    def __init__(self):
        super().__init__()
        self._mode = 'A'  # _mode must be one of _input_mode_names
        self._override = False

        self._layout = dict()
        self._to_kana = self._handle_default_layout

        self._preedit_string = ''
        self._previous_text = ''
        self._ignore_surrounding_text = False

        self._lookup_table = IBus.LookupTable.new(10, 0, True, False)
        self._lookup_table.set_orientation(IBus.Orientation.VERTICAL)

        self._init_props()

        self._config = IBus.Bus().get_config()
        self._config.connect('value-changed', self._config_value_changed_cb)

        self._logging_level = self._load_logging_level(self._config)
        self._dict = self._load_dictionary(self._config)
        self._layout = self._load_layout(self._config)
        self._delay = self._load_delay(self._config)
        self._event = Event(self, self._delay, self._layout)

        self.set_mode(self._load_input_mode(self._config))
        self._set_x4063_mode(self._load_x4063_mode(self._config))

        self._shrunk = ''

        self._committed = ''
        self._acked = True
        self.connect('set-surrounding-text', self.set_surrounding_text_cb)
        self.connect('set-cursor-location', self.set_cursor_location_cb)

        self._about_dialog = None
        self._setup_pid = 0

    def _init_props(self):
        self._prop_list = IBus.PropList()
        self._input_mode_prop = IBus.Property(
            key='InputMode',
            prop_type=IBus.PropType.NORMAL,
            symbol=IBus.Text.new_from_string(self._mode),
            label=IBus.Text.new_from_string(_("Input mode (%s)") % self._mode),
            icon=None,
            tooltip=None,
            sensitive=False,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self._prop_list.append(self._input_mode_prop)
        prop = IBus.Property(key='Setup',
                             prop_type=IBus.PropType.NORMAL,
                             label=IBus.Text.new_from_string(_("Setup")),
                             icon=None,
                             tooltip=None,
                             sensitive=True,
                             visible=True,
                             state=IBus.PropState.UNCHECKED,
                             sub_props=None)
        self._prop_list.append(prop)
        prop = IBus.Property(key='About',
                             prop_type=IBus.PropType.NORMAL,
                             label=IBus.Text.new_from_string(
                                 _("About Hiragana IME...")),
                             icon=None,
                             tooltip=None,
                             sensitive=True,
                             visible=True,
                             state=IBus.PropState.UNCHECKED,
                             sub_props=None)
        self._prop_list.append(prop)

    def _update_input_mode(self):
        self._input_mode_prop.set_symbol(IBus.Text.new_from_string(self._mode))
        self._input_mode_prop.set_label(
            IBus.Text.new_from_string(_("Input mode (%s)") % self._mode))
        self.update_property(self._input_mode_prop)

    def _load_input_mode(self, config):
        var = config.get_value('engine/hiragana', 'mode')
        if var is None or var.get_type_string() != 's' or not var.get_string(
        ) in INPUT_MODE_NAMES:
            mode = 'A'
            if var:
                config.unset('engine/hiragana', 'mode')
        else:
            mode = var.get_string()
        logger.info("input mode: %s", mode)
        return mode

    def _load_logging_level(self, config):
        var = config.get_value('engine/hiragana', 'logging_level')
        if var is None or var.get_type_string() != 's' or not var.get_string(
        ) in NAME_TO_LOGGING_LEVEL:
            level = 'WARNING'
            if var:
                config.unset('engine/hiragana', 'logging_level')
        else:
            level = var.get_string()
        logger.info("logging_level: %s", level)
        logging.getLogger().setLevel(NAME_TO_LOGGING_LEVEL[level])
        return level

    def _load_dictionary(self, config):
        var = config.get_value('engine/hiragana', 'dictionary')
        if var is None or var.get_type_string() != 's':
            path = os.path.join(package.get_datadir(), 'restrained.dic')
            if var:
                config.unset('engine/hiragana', 'dictionary')
        else:
            path = var.get_string()
        return Dictionary(path)

    def _load_layout(self, config):
        default_layout = os.path.join(package.get_datadir(), 'layouts')
        default_layout = os.path.join(default_layout, 'roomazi.json')
        var = config.get_value('engine/hiragana', 'layout')
        if var is None:
            path = default_layout
        elif var.get_type_string() != 's':
            config.unset('engine/hiragana', 'layout')
            path = default_layout
        else:
            path = var.get_string()
        logger.info("layout: %s", path)
        layout = dict()
        try:
            with open(path) as f:
                layout = json.load(f)
        except Exception as error:
            logger.error(error)
        if not layout:
            try:
                with open(default_layout) as f:
                    layout = json.load(f)
            except Exception as error:
                logger.error(error)
        if layout.get('Type') == 'Kana':
            self._to_kana = self._handle_kana_layout
        elif 'Roomazi' in layout:
            self._to_kana = self._handle_roomazi_layout
        else:
            self._to_kana = self._handle_default_layout
        return layout

    def _load_delay(self, config):
        var = config.get_value('engine/hiragana', 'delay')
        if var is None or var.get_type_string() != 'i':
            delay = 0
            if var:
                config.unset('engine/hiragana', 'delay')
        else:
            delay = var.get_int32()
        logger.info("delay: %d", delay)
        return delay

    def _load_x4063_mode(self, config):
        var = config.get_value('engine/hiragana', 'nn_as_jis_x_4063')
        if var is None or var.get_type_string() != 'b':
            mode = True
            if var:
                config.unset('engine/hiragana', 'nn_as_jis_x_4063')
        else:
            mode = var.get_boolean()
        logger.info("nn_as_jis_x_4063 mode: {}".format(mode))
        return mode

    def _config_value_changed_cb(self, config, section, name, value):
        section = section.replace('_', '-')
        if section != 'engine/hiragana':
            return
        logger.debug("config_value_changed({}, {}, {})".format(
            section, name, value))
        if name == "logging_level":
            self._logging_level = self._load_logging_level(config)
        elif name == "delay":
            self._reset()
            self._delay = self._load_delay(config)
            self._event = Event(self, self._delay, self._layout)
        elif name == "layout":
            self._reset()
            self._layout = self._load_layout(config)
            self._event = Event(self, self._delay, self._layout)
        elif name == "dictionary":
            self._reset()
            self._dict = self._load_dictionary(config)
        elif name == "mode":
            self.set_mode(self._load_input_mode(self._config))
            self._override = True
        elif name == "nn_as_jis_x_4063":
            self._set_x4063_mode(self._load_x4063_mode(self._config))

    def _handle_default_layout(self, preedit, keyval, state=0, modifiers=0):
        return self._event.chr(), ''

    def _handle_kana_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self._event.chr().lower()
        if preedit == '\\':
            preedit = ''
            if self._event.is_shift():
                if 'Shift' in self._layout:
                    yomi = self._layout['\\Shift'][c]
                elif modifiers & event.SHIFT_L_BIT:
                    yomi = self._layout['\\ShiftL'][c]
                elif modifiers & event.SHIFT_R_BIT:
                    yomi = self._layout['\\ShiftR'][c]
            else:
                yomi = self._layout['\\Normal'][c]
        else:
            if self._event.is_shift():
                if 'Shift' in self._layout:
                    yomi = self._layout['Shift'][c]
                elif modifiers & event.SHIFT_L_BIT:
                    yomi = self._layout['ShiftL'][c]
                elif modifiers & event.SHIFT_R_BIT:
                    yomi = self._layout['ShiftR'][c]
            else:
                yomi = self._layout['Normal'][c]
            if yomi == '\\':
                preedit += yomi
                yomi = ''
        return yomi, preedit

    def _set_x4063_mode(self, on):
        if on:
            self.character_after_n = "aiueo\'wyn"
        else:
            self.character_after_n = "aiueo\'wy"
        logger.debug("set_x4063_mode({})".format(on))

    def _handle_roomazi_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self._event.chr().lower()
        if preedit == 'n' and self.character_after_n.find(c) < 0:
            yomi = 'ん'
            preedit = preedit[1:]
        preedit += c
        if preedit in self._layout['Roomazi']:
            yomi += self._layout['Roomazi'][preedit]
            preedit = ''
            if yomi == '\\':
                preedit = yomi
                yomi = ''
        elif 2 <= len(
                preedit) and preedit[0] == preedit[1] and RE_SOKUON.search(
                    preedit[1]):
            yomi += 'っ'
            preedit = preedit[1:]
        return yomi, preedit

    def _get_surrounding_text(self):
        if not (self.client_capabilities & IBus.Capabilite.SURROUNDING_TEXT):
            self._ignore_surrounding_text = True
        if self._ignore_surrounding_text or not self._acked:
            logger.debug("surrounding text: [%s]" % (self._previous_text))
            return self._previous_text, len(self._previous_text)

        tuple = self.get_surrounding_text()
        text = tuple[0].get_text()
        pos = tuple[1]

        # Qt reports pos as if text is in UTF-16 while GTK reports pos in sane manner.
        # If you're using primarily Qt, use the following code to amend the issue
        # when a character in Unicode supplementary planes is included in text.
        #
        # Deal with surrogate pair manually. (Qt bug?)
        # for i in range(len(text)):
        #     if pos <= i:
        #         break
        #     if 0x10000 <= ord(text[i]):
        #         pos -= 1

        # Qt seems to insert self._preedit_string to the text, while GTK doesn't.
        # We mimic GTK's behavior here.
        preedit_len = len(self._preedit_string)
        if 0 < preedit_len and preedit_len <= pos and text[
                pos - preedit_len:pos] == self._preedit_string:
            text = text[:-preedit_len]
            pos -= preedit_len
        logger.debug("surrounding text: '%s', %d, [%s]", text, pos,
                     self._previous_text)
        return text, pos

    def _delete_surrounding_text(self, size):
        self._previous_text = self._previous_text[:-size]
        if not self._ignore_surrounding_text and self._acked:
            self.delete_surrounding_text(-size, size)
        else:
            # Note a short delay after each BackSpace is necessary for the target application to catch up.
            for i in range(size):
                self.forward_key_event(IBus.BackSpace, 14, 0)
                time.sleep(0.02)
            self.forward_key_event(IBus.BackSpace, 14,
                                   IBus.ModifierType.RELEASE_MASK)

    def is_overridden(self):
        return self._override

    def is_enabled(self):
        return self.get_mode() != 'A'

    def enable_ime(self):
        if not self.is_enabled():
            self.set_mode('あ')
            return True
        return False

    def disable_ime(self):
        if self.is_enabled():
            self.set_mode('A')
            return True
        return False

    def switch_zenkaku_hankaku(self):
        mode = self.get_mode()
        mode = {
            'A': 'A',
            'A': 'A',
            'ア': 'ア',
            'ア': 'ア',
            'あ': 'あ'
        }.get(mode, 'A')
        return self.set_mode(mode)

    def switch_katakana(self):
        mode = self.get_mode()
        mode = {
            'A': 'ア',
            'A': 'ア',
            'ア': 'あ',
            'ア': 'あ',
            'あ': 'ア'
        }.get(mode, 'ア')
        return self.set_mode(mode)

    def get_mode(self):
        return self._mode

    def set_mode(self, mode):
        self._override = False
        if self._mode == mode:
            return False
        logger.debug("set_mode(%s)" % (mode))
        self._preedit_string = ''
        self._commit()
        self._mode = mode
        self._update()
        self._update_input_mode()
        return True

    def _is_roomazi_mode(self):
        return self._to_kana == self._handle_roomazi_layout

    def do_process_key_event(self, keyval, keycode, state):
        return self._event.process_key_event(keyval, keycode, state)

    def handle_key_event(self, keyval, keycode, state, modifiers):
        logger.debug("handle_key_event(%s, %04x, %04x, %04x)" %
                     (IBus.keyval_name(keyval), keycode, state, modifiers))

        if self._event.is_dual_role():
            pass
        elif self._event.is_modifier():
            # Ignore modifier keys
            return False
        elif state & (IBus.ModifierType.CONTROL_MASK
                      | IBus.ModifierType.MOD1_MASK):
            self._commit()
            return False

        # Handle Candidate window
        if 0 < self._lookup_table.get_number_of_candidates():
            if keyval == keysyms.Page_Up or keyval == keysyms.KP_Page_Up:
                return self.do_page_up()
            elif keyval == keysyms.Page_Down or keyval == keysyms.KP_Page_Down:
                return self.do_page_down()
            elif keyval == keysyms.Up or self._event.is_muhenkan():
                return self.do_cursor_up()
            elif keyval == keysyms.Down or self._event.is_henkan():
                return self.do_cursor_down()
            elif keyval == keysyms.Escape:
                return self.handle_escape(state)
            elif keyval == keysyms.Return:
                self._commit()
                return True

        if self._preedit_string:
            if keyval == keysyms.Return:
                if self._preedit_string == 'n':
                    self._preedit_string = 'ん'
                self._commit_string(self._preedit_string)
                self._preedit_string = ''
                self._update()
                return True
            if keyval == keysyms.Escape:
                self._preedit_string = ''
                self._update()
                return True

        # Handle Japanese text
        if self._event.is_henkan():
            return self.handle_replace(keyval, state)
        if self._event.is_shrink():
            return self.handle_shrink(keyval, state)
        self._commit()
        yomi = ''
        if self._event.is_katakana():
            if self._event.is_shift():
                self.switch_katakana()
            else:
                self.handle_katakana()
            return True
        if self._event.is_backspace():
            if 1 <= len(self._preedit_string):
                self._preedit_string = self._preedit_string[:-1]
                self._update()
                return True
            elif 0 < len(self._previous_text):
                self._previous_text = self._previous_text[:-1]
            return False
        if self._event.is_ascii():
            if self.get_mode() == 'A':
                yomi = to_zenkaku(self._event.chr())
            else:
                yomi, self._preedit_string = self._to_kana(
                    self._preedit_string, keyval, state, modifiers)
        elif keyval == keysyms.hyphen:
            yomi = '―'
        else:
            self._previous_text = ''
            return False
        if yomi:
            if self.get_mode() == 'ア':
                yomi = to_katakana(yomi)
            elif self.get_mode() == 'ア':
                yomi = to_hankaku(to_katakana(yomi))
            self._commit_string(yomi)
        self._update()
        return True

    def lookup_dictionary(self, yomi, pos):
        if self._preedit_string == 'n':
            yomi = yomi[:pos] + 'ん'
            pos += 1
        self._lookup_table.clear()
        cand = self._dict.lookup(yomi, pos)
        size = len(self._dict.reading())
        if 0 < size:
            if self._preedit_string == 'n':
                if self._acked:
                    # For furiganapad, yomi has to be committed anyway.
                    # However, 'ん' will be acked by set_cursor_location_cb()
                    # only after the converted text is committed later.
                    # So we pretend that 'ん' is acked here.
                    self._commit_string('ん')
                    self._acked = True
                    self._committed = ''
                else:
                    size = size - 1
            self._preedit_string = ''
            if 1 < len(self._dict.cand()):
                for c in self._dict.cand():
                    self._lookup_table.append_candidate(
                        IBus.Text.new_from_string(c))
        return (cand, size)

    def handle_katakana(self):
        text, pos = self._get_surrounding_text()
        for i in reversed(range(pos)):
            if 0 <= KATAKANA.find(text[i]):
                continue
            found = HIRAGANA.find(text[i])
            if 0 <= found:
                self._delete_surrounding_text(pos - i)
                self._commit_string(KATAKANA[found] + text[i + 1:pos])
            break
        return True

    def handle_replace(self, keyval, state):
        if not self._dict.current():
            text, pos = self._get_surrounding_text()
            (cand, size) = self.lookup_dictionary(text, pos)
            self._shrunk = ''
        else:
            size = len(self._shrunk) + len(self._dict.current())
            if not self._event.is_shift():
                cand = self._dict.next()
            else:
                cand = self._dict.previous()
        if self._dict.current():
            self._update()
            self._delete_surrounding_text(size)
            self._commit_string(self._shrunk + cand)
        return True

    def handle_shrink(self, keyval, state):
        logger.debug("handle_shrink: '%s'", self._dict.current())
        if not self._dict.current():
            return False
        yomi = self._dict.reading()
        if len(yomi) <= 1:
            self.handle_escape(state)
            return True
        current_size = len(self._shrunk) + len(self._dict.current())
        text, pos = self._get_surrounding_text()
        (cand, size) = self.lookup_dictionary(yomi[1:] + text[pos:],
                                              len(yomi) - 1)
        kana = yomi
        if 0 < size:
            kana = kana[:-size]
        self._shrunk += kana
        self._delete_surrounding_text(current_size)
        self._commit_string(self._shrunk + cand)
        # Update preedit *after* committing the string to append preedit.
        self._update()
        return True

    def handle_escape(self, state):
        if not self._dict.current():
            return False
        size = len(self._dict.current())
        yomi = self._dict.reading()
        self._delete_surrounding_text(size)
        self._commit_string(yomi)
        self._reset(False)
        self._update()
        return True

    def _commit(self):
        if self._dict.current():
            self._dict.confirm(self._shrunk)
            self._dict.reset()
            self._lookup_table.clear()
            visible = 0 < self._lookup_table.get_number_of_candidates()
            self.update_lookup_table(self._lookup_table, visible)
            self._previous_text = ''

    def _commit_string(self, text):
        if text == '゛':
            prev, pos = self._get_surrounding_text()
            if 0 < pos:
                found = NON_DAKU.find(prev[pos - 1])
                if 0 <= found:
                    self._delete_surrounding_text(1)
                    text = DAKU[found]
        elif text == '゜':
            prev, pos = self._get_surrounding_text()
            if 0 < pos:
                found = NON_HANDAKU.find(prev[pos - 1])
                if 0 <= found:
                    self._delete_surrounding_text(1)
                    text = HANDAKU[found]
        self._committed = text
        self._acked = False
        self._previous_text += text
        self.commit_text(IBus.Text.new_from_string(text))

    def _reset(self, full=True):
        self._dict.reset()
        self._lookup_table.clear()
        self._update_lookup_table()
        if full:
            self._committed = ''
            self._acked = True
            self._previous_text = ''
            self._preedit_string = ''
            self._ignore_surrounding_text = False

    def _update_candidate(self):
        index = self._lookup_table.get_cursor_pos()
        size = len(self._shrunk) + len(self._dict.current())
        self._dict.set_current(index)
        self._delete_surrounding_text(size)
        self._commit_string(self._shrunk + self._dict.current())

    def do_page_up(self):
        if self._lookup_table.page_up():
            self._update_lookup_table()
            self._update_candidate()
        return True

    def do_page_down(self):
        if self._lookup_table.page_down():
            self._update_lookup_table()
            self._update_candidate()
        return True

    def do_cursor_up(self):
        if self._lookup_table.cursor_up():
            self._update_lookup_table()
            self._update_candidate()
        return True

    def do_cursor_down(self):
        if self._lookup_table.cursor_down():
            self._update_lookup_table()
            self._update_candidate()
        return True

    def _update(self):
        preedit_len = len(self._preedit_string)
        text = IBus.Text.new_from_string(self._preedit_string)
        if 0 < preedit_len:
            attrs = IBus.AttrList()
            attrs.append(
                IBus.Attribute.new(IBus.AttrType.UNDERLINE,
                                   IBus.AttrUnderline.SINGLE, 0, preedit_len))
            text.set_attributes(attrs)
        # Note self.hide_preedit_text() does not seem to work as expected with Kate.
        # cf. "Qt5 IBus input context does not implement hide_preedit_text()",
        #     https://bugreports.qt.io/browse/QTBUG-48412
        self.update_preedit_text(text, preedit_len, 0 < preedit_len)
        self._update_lookup_table()

    def _update_lookup_table(self):
        if self.is_enabled():
            visible = 0 < self._lookup_table.get_number_of_candidates()
            self.update_lookup_table(self._lookup_table, visible)

    def do_focus_in(self):
        logger.info("focus_in")
        self._event.reset()
        self.register_properties(self._prop_list)
        # Request the initial surrounding-text in addition to the "enable" handler.
        self.get_surrounding_text()

    def do_focus_out(self):
        logger.info("focus_out")
        self._reset()
        self._dict.save_orders()

    def do_enable(self):
        logger.info("enable")
        # Request the initial surrounding-text when enabled as documented.
        self.get_surrounding_text()

    def do_disable(self):
        logger.info("disable")
        self._reset()
        self._mode = 'A'
        self._dict.save_orders()

    def do_reset(self):
        logger.info("reset")
        self._reset()
        # Do not switch back to the Alphabet mode here; 'reset' should be
        # called when the text cursor is moved by a mouse click, etc.

    def _start_setup(self):
        if self._setup_pid != 0:
            pid, status = os.waitpid(self._setup_pid, os.WNOHANG)
            if pid != self._setup_pid:
                return
            self._setup_pid = 0
        filename = os.path.join(package.get_libexecdir(),
                                'ibus-setup-hiragana')
        self._setup_pid = os.spawnl(os.P_NOWAIT, filename,
                                    'ibus-setup-hiragana')

    def do_property_activate(self, prop_name, state):
        logger.info("property_activate(%s, %d)" % (prop_name, state))
        if prop_name == "Setup":
            self._start_setup()
        if prop_name == "About":
            if self._about_dialog:
                self._about_dialog.present()
                return
            dialog = Gtk.AboutDialog()
            dialog.set_program_name(_("Hiragana IME"))
            dialog.set_copyright("Copyright 2017-2020 Esrille Inc.")
            dialog.set_authors(["Esrille Inc."])
            dialog.set_documenters(["Esrille Inc."])
            dialog.set_website(
                "file://" +
                os.path.join(package.get_datadir(), "help/index.html"))
            dialog.set_website_label(_("Introduction to Hiragana IME"))
            dialog.set_logo_icon_name(package.get_name())
            dialog.set_version(package.get_version())
            # To close the dialog when "close" is clicked, e.g. on RPi,
            # we connect the "response" signal to about_response_callback
            dialog.connect("response", self.about_response_callback)
            self._about_dialog = dialog
            dialog.show()

    def about_response_callback(self, dialog, response):
        dialog.destroy()
        self._about_dialog = None

    def set_surrounding_text_cb(self, engine, text, cursor_pos, anchor_pos):
        text = self.get_plain_text(text.get_text()[:cursor_pos])
        if self._committed:
            pos = text.rfind(self._committed)
            if 0 <= pos and pos + len(self._committed) == len(text):
                self._acked = True
                self._committed = ''
        logger.debug("set_surrounding_text_cb(%s, %d, %d) => %d" %
                     (text, cursor_pos, anchor_pos, self._acked))

    def get_plain_text(self, text):
        plain = ''
        in_ruby = False
        for c in text:
            if c == IAA:
                in_ruby = False
            elif c == IAS:
                in_ruby = True
            elif c == IAT:
                in_ruby = False
            elif not in_ruby:
                plain += c
        return plain

    def set_cursor_location_cb(self, engine, x, y, w, h):
        # On Raspbian, at least till Buster, the candidate window does not
        # always follow the cursor position. The following code is not
        # necessary on Ubuntu 18.04 or Fedora 30.
        logger.debug("set_cursor_location_cb(%d, %d, %d, %d)" % (x, y, w, h))
        self._update_lookup_table()
Esempio n. 2
0
class EngineHiragana(EngineModeless):
    __gtype_name__ = 'EngineHiragana'

    def __init__(self):
        super().__init__()
        self._mode = 'A'  # _mode must be one of _input_mode_names
        self._override = False
        self._layout = dict()
        self._to_kana = self._handle_default_layout
        self._shrunk = []
        self._lookup_table = IBus.LookupTable.new(10, 0, True, False)
        self._lookup_table.set_orientation(IBus.Orientation.VERTICAL)

        self._init_props()

        self._settings = Gio.Settings.new('org.freedesktop.ibus.engine.hiragana')
        self._settings.connect('changed', self._config_value_changed_cb)

        self._logging_level = self._load_logging_level(self._settings)
        self._dict = self._load_dictionary(self._settings)
        self._layout = self._load_layout(self._settings)
        self._delay = self._load_delay(self._settings)
        self._event = Event(self, self._delay, self._layout)

        self.set_mode(self._load_input_mode(self._settings))
        self._set_x4063_mode(self._load_x4063_mode(self._settings))

        self._keymap = Gdk.Keymap.get_for_display(Gdk.Display.get_default())
        self._keymap.connect('state-changed', self._keymap_state_changed_cb)

        self.connect('set-cursor-location', self._set_cursor_location_cb)

        self._about_dialog = None
        self._setup_proc = None
        self._q = queue.Queue()

    def _confirm_candidate(self):
        current = self._dict.current()
        if current:
            self._dict.confirm(''.join(self._shrunk))
            self._dict.reset()
            self._lookup_table.clear()
        return current

    def _handle_default_layout(self, preedit, keyval, state=0, modifiers=0):
        return self._event.chr(), ''

    def _handle_kana_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self._event.chr().lower()
        if self._event.is_shift():
            if 'Shift' in self._layout:
                yomi = self._layout['Shift'].get(c, '')
            elif modifiers & event.SHIFT_L_BIT:
                yomi = self._layout['ShiftL'].get(c, '')
            elif modifiers & event.SHIFT_R_BIT:
                yomi = self._layout['ShiftR'].get(c, '')
        else:
            yomi = self._layout['Normal'].get(c, '')
        return yomi, preedit

    def _handle_roomazi_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self._event.chr().lower()
        if preedit == 'n' and self.character_after_n.find(c) < 0:
            yomi = 'ん'
            preedit = preedit[1:]
        preedit += c
        if preedit in self._layout['Roomazi']:
            yomi += self._layout['Roomazi'][preedit]
            preedit = ''
        elif 2 <= len(preedit) and preedit[0] == preedit[1] and RE_SOKUON.search(preedit[1]):
            yomi += 'っ'
            preedit = preedit[1:]
        return yomi, preedit

    def _init_input_mode_props(self):
        props = IBus.PropList()
        props.append(IBus.Property(key='InputMode.Alphanumeric',
                                   prop_type=IBus.PropType.RADIO,
                                   label=IBus.Text.new_from_string(_("Alphanumeric (A)")),
                                   icon=None,
                                   tooltip=None,
                                   sensitive=True,
                                   visible=True,
                                   state=IBus.PropState.CHECKED,
                                   sub_props=None))
        props.append(IBus.Property(key='InputMode.Hiragana',
                                   prop_type=IBus.PropType.RADIO,
                                   label=IBus.Text.new_from_string(_("Hiragana (あ)")),
                                   icon=None,
                                   tooltip=None,
                                   sensitive=True,
                                   visible=True,
                                   state=IBus.PropState.UNCHECKED,
                                   sub_props=None))
        props.append(IBus.Property(key='InputMode.Katakana',
                                   prop_type=IBus.PropType.RADIO,
                                   label=IBus.Text.new_from_string(_("Katakana (ア)")),
                                   icon=None,
                                   tooltip=None,
                                   sensitive=True,
                                   visible=True,
                                   state=IBus.PropState.UNCHECKED,
                                   sub_props=None))
        props.append(IBus.Property(key='InputMode.WideAlphanumeric',
                                   prop_type=IBus.PropType.RADIO,
                                   label=IBus.Text.new_from_string(_("Wide Alphanumeric (A)")),
                                   icon=None,
                                   tooltip=None,
                                   sensitive=True,
                                   visible=True,
                                   state=IBus.PropState.UNCHECKED,
                                   sub_props=None))
        props.append(IBus.Property(key='InputMode.HalfWidthKatakana',
                                   prop_type=IBus.PropType.RADIO,
                                   label=IBus.Text.new_from_string(_("Halfwidth Katakana (ア)")),
                                   icon=None,
                                   tooltip=None,
                                   sensitive=True,
                                   visible=True,
                                   state=IBus.PropState.UNCHECKED,
                                   sub_props=None))
        return props

    def _init_props(self):
        self._prop_list = IBus.PropList()
        self._input_mode_prop = IBus.Property(
            key='InputMode',
            prop_type=IBus.PropType.MENU,
            symbol=IBus.Text.new_from_string(self._mode),
            label=IBus.Text.new_from_string(_("Input mode (%s)") % self._mode),
            icon=None,
            tooltip=None,
            sensitive=True,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self._input_mode_prop.set_sub_props(self._init_input_mode_props())
        self._prop_list.append(self._input_mode_prop)
        prop = IBus.Property(
            key='Setup',
            prop_type=IBus.PropType.NORMAL,
            label=IBus.Text.new_from_string(_("Setup")),
            icon=None,
            tooltip=None,
            sensitive=True,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self._prop_list.append(prop)
        prop = IBus.Property(
            key='Help',
            prop_type=IBus.PropType.NORMAL,
            label=IBus.Text.new_from_string(_("Help")),
            icon=None,
            tooltip=None,
            sensitive=True,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self._prop_list.append(prop)
        prop = IBus.Property(
            key='About',
            prop_type=IBus.PropType.NORMAL,
            label=IBus.Text.new_from_string(_("About Hiragana IME...")),
            icon=None,
            tooltip=None,
            sensitive=True,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self._prop_list.append(prop)

    def _load_delay(self, settings):
        delay = settings.get_int('delay')
        logger.info(f'delay: {delay}')
        return delay

    def _load_dictionary(self, settings, clear_history=False):
        path = settings.get_string('dictionary')
        user = settings.get_string('user-dictionary')
        return Dictionary(path, user, clear_history)

    def _load_input_mode(self, settings):
        mode = settings.get_string('mode')
        if mode not in INPUT_MODE_NAMES:
            mode = 'A'
            settings.reset('mode')
        logger.info(f'input mode: {mode}')
        return mode

    def _load_layout(self, settings):
        default_layout = os.path.join(package.get_datadir(), 'layouts')
        default_layout = os.path.join(default_layout, 'roomazi.json')
        path = settings.get_string('layout')
        logger.info(f'layout: {path}')
        layout = dict()
        try:
            with open(path) as f:
                layout = json.load(f)
        except Exception as error:
            logger.error(error)
        if not layout:
            try:
                with open(default_layout) as f:
                    layout = json.load(f)
            except Exception as error:
                logger.error(error)
        if layout.get('Type') == 'Kana':
            self._to_kana = self._handle_kana_layout
            self._dict.use_romazi(False)
        elif 'Roomazi' in layout:
            self._to_kana = self._handle_roomazi_layout
            self._dict.use_romazi(True)
        else:
            self._to_kana = self._handle_default_layout
            self._dict.use_romazi(True)
        return layout

    def _load_logging_level(self, settings):
        level = settings.get_string('logging-level')
        if level not in NAME_TO_LOGGING_LEVEL:
            level = 'WARNING'
            settings.reset('logging-level')
        logger.info(f'logging_level: {level}')
        logging.getLogger().setLevel(NAME_TO_LOGGING_LEVEL[level])
        return level

    def _load_x4063_mode(self, settings):
        mode = settings.get_boolean('nn-as-jis-x-4063')
        logger.info(f'nn_as_jis_x_4063 mode: {mode}')
        return mode

    def _lookup_dictionary(self, yomi, pos, process_n=True):
        if process_n and self.roman_text == 'n':
            yomi = yomi[:pos] + 'ん'
            pos += 1
        self._lookup_table.clear()
        cand = self._dict.lookup(yomi, pos)
        size = len(self._dict.reading())
        if 0 < size:
            if process_n and self.roman_text == 'n':
                # For FuriganaPad, yomi has to be committed anyway.
                self.clear_roman()
                self.commit_string('ん')
            if 1 < len(self._dict.cand()):
                for c in self._dict.cand():
                    self._lookup_table.append_candidate(IBus.Text.new_from_string(c))
        return cand, size

    def _process_dakuten(self, c):
        if c not in '゛゜':
            return c
        text, pos = self.get_surrounding_string()
        if pos <= 0:
            return c
        if c == '゛':
            found = NON_DAKU.find(text[pos - 1])
            if 0 <= found:
                self.delete_surrounding_string(1)
                c = DAKU[found]
        elif c == '゜':
            found = NON_HANDAKU.find(text[pos - 1])
            if 0 <= found:
                self.delete_surrounding_string(1)
                c = HANDAKU[found]
        return c

    def _process_escape(self):
        self.clear_roman()
        assert self._dict.current()
        yomi = self._dict.reading()
        self._reset(False)
        self.commit_string(yomi)

    def _process_expand(self):
        assert self._dict.current()
        if not self._shrunk:
            return True
        kana = self._shrunk[-1]
        yomi = self._dict.reading()
        text, pos = self.get_surrounding_string()
        (cand, size) = self._lookup_dictionary(kana + yomi + text[pos:], len(kana + yomi))
        assert 0 < size
        self.delete_surrounding_string(len(kana))
        self._shrunk.pop(-1)
        return True

    def _process_katakana(self):
        text, pos = self.get_surrounding_string()
        if self.roman_text == 'n':
            self.clear_roman()
            text = text[:pos] + 'ん'
            pos += 1
            self.commit_string('ん')
        for i in reversed(range(pos)):
            if 0 <= KATAKANA.find(text[i]):
                continue
            found = HIRAGANA.find(text[i])
            if 0 <= found:
                self.delete_surrounding_string(pos - i)
                self.commit_string(KATAKANA[found] + text[i + 1:pos])
            break
        return True

    def _process_okurigana(self, pos_yougen):
        text, pos = self.get_surrounding_string()
        assert pos_yougen < pos
        text = text[pos_yougen:pos]
        pos = len(text)
        logger.debug(f"_process_okurigana: '{text}', '{self.roman_text}'")
        cand, size = self._lookup_dictionary(text, pos, False)
        if not self._dict.current():
            self._dict.create_pseudo_candidate(text)
            cand = text
            size = len(text)
        if self._dict.current():
            self._shrunk = []
            self.delete_surrounding_string(size)
        return True

    def _process_replace(self):
        if self._dict.current():
            return True
        text, pos = self.get_surrounding_string()
        # Check Return for yôgen conversion
        if self._event.is_henkan() or self._event.is_key(keysyms.Return):
            cand, size = self._lookup_dictionary(text, pos)
        elif 1 <= pos:
            assert self._event.is_muhenkan()
            suffix = text[:pos].rfind('―')
            if 0 < suffix:
                cand, size = self._lookup_dictionary(text, pos)
            else:
                self.commit_string('―')
                text, pos = self.get_surrounding_string()
                cand, size = self._lookup_dictionary(text, pos)
                if not cand:
                    self.delete_surrounding_string(1)
        if self._dict.current():
            self._shrunk = []
            self.delete_surrounding_string(size)
        return True

    def _process_shrink(self):
        logger.debug(f'_process_shrink: "{self._dict.current()}"')
        assert self._dict.current()
        yomi = self._dict.reading()
        if len(yomi) <= 1 or yomi[1] == '―':
            return True
        text, pos = self.get_surrounding_string()
        (cand, size) = self._lookup_dictionary(yomi[1:] + text[pos:], len(yomi) - 1)
        if 0 < size:
            kana = yomi[:-size]
            self._shrunk.append(kana)
            self.commit_string(kana)
        else:
            (cand, size) = self._lookup_dictionary(yomi + text[pos:], len(yomi))
        return True

    def _process_surrounding_text(self, keyval, keycode, state, modifiers):
        if self._dict.current():
            if keyval == keysyms.Tab:
                if not self._event.is_shift():
                    return self._process_shrink()
                else:
                    return self._process_expand()
            if keyval == keysyms.Escape:
                self._process_escape()
                return True
            if keyval == keysyms.Return:
                current = self._confirm_candidate()
                self.commit_string(current)
                if current[-1] == '―':
                    return self._process_replace()
                else:
                    self.commit_roman()
                    self.flush()
                    return True

        if keyval == keysyms.Return and self.commit_roman():
            return True
        if keyval == keysyms.Escape and self.clear_roman():
            return True

        if (self._event.is_henkan() or self._event.is_muhenkan()) and not(modifiers & event.ALT_R_BIT):
            return self._process_replace()

        text, pos = self.get_surrounding_string()
        pos_yougen = -1
        to_revert = False
        current = self._dict.current()
        if current:
            # Commit the current candidate
            yomi = self._dict.reading()
            self._confirm_candidate()
            logger.debug(f"_process_text: '{text}', current: '{current}', yomi: '{yomi}'")
            if current[-1] == '―':
                pos_yougen = pos
                self.commit_string(current)
            elif self._dict.not_selected() and (current[-1] in OKURIGANA or yomi[-1] == '―' or self.roman_text):
                pos_yougen = pos
                to_revert = True
                self.commit_string(current)
                current = yomi
            else:
                self.flush(current)
            self._update_preedit()

        if self._event.is_katakana():
            self._process_katakana()
            return True
        if self._event.is_backspace():
            if to_revert:
                if self.roman_text:
                    self.roman_text = self.roman_text[:-1]
                else:
                    current = current[:-1]
                text, pos = self.get_surrounding_string()
                self.delete_surrounding_string(pos - pos_yougen)
                self.commit_string(current)
                return self._process_okurigana(pos_yougen)
            if self.backspace():
                return True
            return False

        yomi = ''
        if self._event.is_ascii():
            if modifiers & event.ALT_R_BIT:
                yomi = self.process_alt_graph(keyval, keycode, state, modifiers)
                if yomi:
                    if self.get_mode() != 'ア':
                        yomi = to_zenkaku(yomi)
                    self.clear_roman()
            elif self.get_mode() == 'A':
                yomi = to_zenkaku(self._event.chr())
            else:
                yomi, self.roman_text = self._to_kana(self.roman_text, keyval, state, modifiers)
                if yomi:
                    if self.get_mode() == 'ア':
                        yomi = to_katakana(yomi)
                    elif self.get_mode() == 'ア':
                        yomi = to_hankaku(to_katakana(yomi))
        elif keyval == keysyms.hyphen:
            yomi = '―'
        elif self.has_preedit() and self.should_draw_preedit():
            if keyval == keysyms.Escape:
                self.clear_preedit()
            else:
                self.clear_roman()
                self.flush()
            return True
        else:
            # Let the IBus client process the key
            self.clear_roman()
            return False

        if to_revert and (yomi and yomi[-1] in OKURIGANA or self.roman_text):
            text, pos = self.get_surrounding_string()
            self.delete_surrounding_string(pos - pos_yougen)
            self.commit_string(current)
        yomi = self._process_dakuten(yomi)
        self.commit_string(yomi)
        if 0 <= pos_yougen and (yomi and yomi[-1] in OKURIGANA or self.roman_text):
            self._process_okurigana(pos_yougen)
            current = self._dict.current()
            if current and self._dict.is_complete():
                self._confirm_candidate()
                if 1 < len(current):
                    self.flush(current[:-1])
                    self.commit_string(current[-1])
                else:
                    self.flush(current)
            return True
        return True

    def _reset(self, full=True):
        self._dict.reset()
        self._lookup_table.clear()
        self._update_lookup_table()
        if full:
            self.clear()
        self._update_preedit()
        assert not self._dict.current()
        self._setup_sync()

    def _set_x4063_mode(self, on):
        if on:
            self.character_after_n = "aiueo'wyn"
        else:
            self.character_after_n = "aiueo'wy"
        logger.debug(f'set_x4063_mode({on})')

    def _update_candidate(self):
        index = self._lookup_table.get_cursor_pos()
        self._dict.set_current(index)
        self._update_preedit(self._dict.current())

    def _update_input_mode(self):
        self._input_mode_prop.set_symbol(IBus.Text.new_from_string(self._mode))
        self._input_mode_prop.set_label(IBus.Text.new_from_string(_("Input mode (%s)") % self._mode))
        self.update_property(self._input_mode_prop)

    def _update_lookup_table(self):
        if self.is_enabled():
            visible = 0 < self._lookup_table.get_number_of_candidates()
            self.update_lookup_table(self._lookup_table, visible)

    def _update_preedit(self, cand=''):
        preedit_text = self._preedit_text if self.should_draw_preedit() else ''
        text = IBus.Text.new_from_string(preedit_text + cand + self.roman_text)
        preedit_len = len(preedit_text)
        cand_len = len(cand)
        roman_len = len(self.roman_text)
        text_len = preedit_len + cand_len + roman_len
        attrs = IBus.AttrList() if 0 < text_len else None
        pos = 0
        if 0 < preedit_len:
            attrs.append(IBus.Attribute.new(IBus.AttrType.UNDERLINE, IBus.AttrUnderline.SINGLE, pos, pos + preedit_len))
            pos += preedit_len
        if 0 < cand_len:
            attrs.append(IBus.Attribute.new(IBus.AttrType.FOREGROUND, CANDIDATE_FOREGROUND_COLOR, pos, pos + cand_len))
            attrs.append(IBus.Attribute.new(IBus.AttrType.BACKGROUND, CANDIDATE_BACKGROUND_COLOR, pos, pos + cand_len))
            pos += cand_len
        if 0 < roman_len:
            attrs.append(IBus.Attribute.new(IBus.AttrType.UNDERLINE, IBus.AttrUnderline.SINGLE, pos, pos + roman_len))
            pos += preedit_len
        if attrs:
            text.set_attributes(attrs)

        # A delay is necessary for textareas of Firefox 102.0.1.
        if text:
            time.sleep(EVENT_DELAY)

        # Note self.hide_preedit_text() does not seem to work as expected with Kate.
        # cf. "Qt5 IBus input context does not implement hide_preedit_text()",
        #     https://bugreports.qt.io/browse/QTBUG-48412
        self.update_preedit_text(text, text_len, 0 < text_len)
        self._update_lookup_table()

    #
    # setup process methods
    #
    def _setup_readline(self, process: subprocess.Popen):
        for line in iter(process.stdout.readline, ''):
            self._q.put(line.strip())
            if process.poll() is not None:
                return

    def _setup_start(self):
        if self._setup_proc:
            if self._setup_proc.poll() is None:
                return
            self._setup_proc = None
        try:
            filename = os.path.join(package.get_libexecdir(), 'ibus-setup-hiragana')
            self._setup_proc = subprocess.Popen([filename], text=True, stdout=subprocess.PIPE)
            t = threading.Thread(target=self._setup_readline, args=(self._setup_proc,), daemon=True)
            t.start()
        except OSError as e:
            logger.error(e)
        except ValueError as e:
            logger.error(e)

    def _setup_sync(self):
        last = ''
        while True:
            try:
                line = self._q.get_nowait()
                if line == last:
                    continue
                last = line
                logger.info(line)
                if line == 'reload_dictionaries':
                    self._dict = self._load_dictionary(self._settings)
                elif line == 'clear_input_history':
                    self._dict = self._load_dictionary(self._settings, clear_history=True)
            except queue.Empty:
                break

    #
    # callback methods
    #
    def _about_response_cb(self, dialog, response):
        dialog.destroy()
        self._about_dialog = None

    def _config_value_changed_cb(self, settings, key):
        logger.debug(f'config_value_changed("{key}")')
        if key == 'logging-level':
            self._logging_level = self._load_logging_level(settings)
        elif key == 'delay':
            self._reset()
            self._delay = self._load_delay(settings)
            self._event = Event(self, self._delay, self._layout)
        elif key == 'layout':
            self._reset()
            self._layout = self._load_layout(settings)
            self._event = Event(self, self._delay, self._layout)
        elif key == 'dictionary' or key == 'user-dictionary':
            self._reset()
            self._dict = self._load_dictionary(settings)
        elif key == 'mode':
            self.set_mode(self._load_input_mode(settings), True)
        elif key == 'nn-as-jis-x-4063':
            self._set_x4063_mode(self._load_x4063_mode(settings))

    def _keymap_state_changed_cb(self, keymap):
        if self._event.is_onoff_by_caps():
            logger.debug(f'caps lock: {keymap.get_caps_lock_state()}')
            if keymap.get_caps_lock_state():
                self.enable_ime()
            else:
                self.disable_ime()
        return True

    def _set_cursor_location_cb(self, engine, x, y, w, h):
        # On Raspbian, at least till Buster, the candidate window does not
        # always follow the cursor position. The following code is not
        # necessary on Ubuntu 18.04 or Fedora 30.
        logger.debug(f'_set_cursor_location_cb({x}, {y}, {w}, {h})')
        self._update_lookup_table()

    #
    # public methods
    #
    def disable_ime(self, override=False):
        if self.is_enabled():
            self.set_mode('A', override)
            return True
        return False

    def enable_ime(self, override=False):
        if not self.is_enabled():
            self.set_mode('あ', override)
            return True
        return False

    def get_mode(self):
        return self._mode

    def is_enabled(self):
        return self.get_mode() != 'A'

    def is_lookup_table_visible(self):
        return 0 < self._lookup_table.get_number_of_candidates()

    def is_overridden(self):
        return self._override

    def process_alt_graph(self, keyval, keycode, state, modifiers):
        logger.debug(f'process_alt_graph("{self._event.chr()}")')
        c = self._event.chr().lower()
        if c == '_' and self._event._keycode == 0x0b:
            c = '0'
        if not c:
            return c
        if not self._event.is_shift():
            return self._layout['\\Normal'].get(c, '')
        if '\\Shift' in self._layout:
            return self._layout['\\Shift'].get(c, '')
        if modifiers & event.SHIFT_L_BIT:
            return self._layout['\\ShiftL'].get(c, '')
        if modifiers & event.SHIFT_R_BIT:
            return self._layout['\\ShiftR'].get(c, '')

    def process_key_event(self, keyval, keycode, state, modifiers):
        logger.debug(f'process_key_event("{IBus.keyval_name(keyval)}", {keyval:#04x}, {keycode:#04x}, {state:#010x}, {modifiers:#07x})')

        if self._event.is_dual_role():
            pass
        elif self._event.is_modifier():
            # Ignore modifier keys
            return False
        elif state & (IBus.ModifierType.CONTROL_MASK | IBus.ModifierType.MOD1_MASK):
            self.clear_roman()
            self.flush(self._confirm_candidate())
            self._update_preedit()
            return False

        self.check_surrounding_support()

        # Handle candidate window
        if 0 < self._lookup_table.get_number_of_candidates():
            if keyval in (keysyms.Page_Up, keysyms.KP_Page_Up):
                return self.do_page_up()
            elif keyval in (keysyms.Page_Down, keysyms.KP_Page_Down):
                return self.do_page_down()
            elif keyval == keysyms.Up or self._event.is_muhenkan():
                return self.do_cursor_up()
            elif keyval == keysyms.Down or self._event.is_henkan():
                return self.do_cursor_down()

        # Cache the current surrounding text into the EngineModless's local buffer.
        self.get_surrounding_string()
        # Edit the local surrounding text buffer as we need.
        result = self._process_surrounding_text(keyval, keycode, state, modifiers)
        # Flush the local surrounding text buffer into the IBus client.
        if self._surrounding in (SURROUNDING_COMMITTED, SURROUNDING_SUPPORTED):
            self.flush()
        # Lastly, update the preedit text. To support LibreOffice, the
        # surrounding text needs to be updated before updating the preedit text.
        current = self._dict.current()
        self._update_preedit(current)
        return result

    def set_mode(self, mode, override=False):
        self._override = override
        if self._mode == mode:
            return False
        logger.debug(f'set_mode({mode})')
        self.clear_roman()
        self.flush(self._confirm_candidate())
        self._update_preedit()
        self._mode = mode
        self._update_lookup_table()
        self._update_input_mode()
        return True

    #
    # virtual methods of IBus.Engine
    #
    def do_cursor_down(self):
        if self._lookup_table.cursor_down():
            self._update_candidate()
        return True

    def do_cursor_up(self):
        if self._lookup_table.cursor_up():
            self._update_candidate()
        return True

    def do_disable(self):
        logger.info('disable')
        self._reset()
        self._mode = 'A'
        self._dict.save_orders()

    def do_focus_in(self):
        logger.info(f'focus_in: {self._surrounding}')
        self._event.reset()
        self.register_properties(self._prop_list)
        self._update_preedit()
        super().do_focus_in()

    def do_focus_out(self):
        logger.info(f'focus_out: {self._surrounding}')
        if self._surrounding != SURROUNDING_BROKEN:
            self._reset()
            self._dict.save_orders()

    def do_page_down(self):
        if self._lookup_table.page_down():
            self._update_candidate()
        return True

    def do_page_up(self):
        if self._lookup_table.page_up():
            self._update_candidate()
        return True

    def do_process_key_event(self, keyval, keycode, state):
        return self._event.process_key_event(keyval, keycode, state)

    def do_property_activate(self, prop_name, state):
        logger.info(f'property_activate({prop_name}, {state})')
        if prop_name == 'Setup':
            self._setup_start()
        elif prop_name == 'Help':
            url = 'file://' + os.path.join(package.get_datadir(), 'help/index.html')
            # Use yelp to open local HTML help files.
            subprocess.Popen(['yelp', url])
        elif prop_name == 'About':
            if self._about_dialog:
                self._about_dialog.present()
                return
            dialog = Gtk.AboutDialog()
            dialog.set_program_name(_("Hiragana IME"))
            dialog.set_copyright("Copyright 2017-2022 Esrille Inc.")
            dialog.set_authors(["Esrille Inc."])
            dialog.set_documenters(["Esrille Inc."])
            dialog.set_website("https://www.esrille.com/")
            dialog.set_website_label("Esrille Inc.")
            dialog.set_logo_icon_name(package.get_name())
            dialog.set_default_icon_name(package.get_name())
            dialog.set_version(package.get_version())
            # To close the dialog when "close" is clicked on Raspberry Pi OS,
            # we connect the "response" signal to _about_response_cb
            dialog.connect("response", self._about_response_cb)
            self._about_dialog = dialog
            dialog.show()
        elif prop_name.startswith('InputMode.'):
            if state == IBus.PropState.CHECKED:
                mode = {
                    'InputMode.Alphanumeric': 'A',
                    'InputMode.Hiragana': 'あ',
                    'InputMode.Katakana': 'ア',
                    'InputMode.WideAlphanumeric': 'A',
                    'InputMode.HalfWidthKatakana': 'ア',
                }.get(prop_name, 'A')
                self.set_mode(mode, True)

    def do_reset(self):
        logger.info(f'reset: {self._surrounding}')
        if self._surrounding != SURROUNDING_BROKEN:
            self._reset()
        else:
            self._update_preedit()
Esempio n. 3
0
class EngineReplaceWithKanji(IBus.Engine):
    __gtype_name__ = 'EngineReplaceWithKanji'

    def __init__(self):
        super(EngineReplaceWithKanji, self).__init__()
        self.__mode = 'A'     # __mode must be one of _input_mode_names
        self.__override = False

        self.__layout = roomazi.layout
        self.__to_kana = self.__handle_roomazi_layout

        self.__preedit_string = ''
        self.__previous_text = ''
        self.__ignore_surrounding_text = False

        self.__lookup_table = IBus.LookupTable.new(10, 0, True, False)
        self.__lookup_table.set_orientation(IBus.Orientation.VERTICAL)

        self.__init_props()

        self.__config = IBus.Bus().get_config()
        self.__config.connect('value-changed', self.__config_value_changed_cb)

        self.__logging_level = self.__load_logging_level(self.__config)
        self.__dict = self.__load_dictionary(self.__config)
        self.__layout = self.__load_layout(self.__config)
        self.__delay = self.__load_delay(self.__config)
        self.__event = Event(self, self.__delay, self.__layout)

        self.set_mode(self.__load_input_mode(self.__config))
        self.__set_x4063_mode(self.__load_x4063_mode(self.__config))

        self.__shrunk = ''

        self.__committed = ''
        self.__acked = True
        self.connect('set-surrounding-text', self.set_surrounding_text_cb)

        self.connect('set-cursor-location', self.set_cursor_location_cb)

    def __init_props(self):
        self.__prop_list = IBus.PropList()
        self.__input_mode_prop = IBus.Property(
            key='InputMode',
            prop_type=IBus.PropType.NORMAL,
            symbol=IBus.Text.new_from_string(self.__mode),
            label=IBus.Text.new_from_string('Input mode (%s)' % self.__mode),
            icon=None,
            tooltip=None,
            sensitive=False,
            visible=True,
            state=IBus.PropState.UNCHECKED,
            sub_props=None)
        self.__prop_list.append(self.__input_mode_prop)

    def __update_input_mode(self):
        self.__input_mode_prop.set_symbol(IBus.Text.new_from_string(self.__mode))
        self.__input_mode_prop.set_label(IBus.Text.new_from_string('Input mode (%s)' % self.__mode))
        self.update_property(self.__input_mode_prop)

    def __load_input_mode(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'mode')
        if var is None or var.get_type_string() != 's' or not var.get_string() in _input_mode_names:
            mode = 'A'
            if var:
                config.unset('engine/replace-with-kanji-python', 'mode')
        else:
            mode = var.get_string()
        logger.info("input mode: %s", mode)
        return mode

    def __load_logging_level(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'logging_level')
        if var is None or var.get_type_string() != 's' or not var.get_string() in _name_to_logging_level:
            level = 'WARNING'
            if var:
                config.unset('engine/replace-with-kanji-python', 'logging_level')
        else:
            level = var.get_string()
        logger.info("logging_level: %s", level)
        logging.getLogger().setLevel(_name_to_logging_level[level])
        return level

    def __load_dictionary(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'dictionary')
        if var is None or var.get_type_string() != 's':
            path = os.path.join(os.getenv('IBUS_REPLACE_WITH_KANJI_LOCATION'), 'restrained.dic')
            if var:
                config.unset('engine/replace-with-kanji-python', 'dictionary')
        else:
            path = var.get_string()
        return Dictionary(path)

    def __load_layout(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'layout')
        if var is None or var.get_type_string() != 's':
            path = os.path.join(os.getenv('IBUS_REPLACE_WITH_KANJI_LOCATION'), 'layouts')
            path = os.path.join(path, 'roomazi.json')
            if var:
                config.unset('engine/replace-with-kanji-python', 'layout')
        else:
            path = var.get_string()
        logger.info("layout: %s", path)
        layout = roomazi.layout     # Use 'roomazi' as default
        try:
            with open(path) as f:
                layout = json.load(f)
        except ValueError as error:
            logger.error("JSON error: %s", error)
        except OSError as error:
            logger.error("Error: %s", error)
        except:
            logger.error("Unexpected error: %s %s", sys.exc_info()[0], sys.exc_info()[1])
        self.__to_kana = self.__handle_roomazi_layout
        if 'Type' in layout:
            if layout['Type'] == 'Kana':
                self.__to_kana = self.__handle_kana_layout
        return layout

    def __load_delay(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'delay')
        if var is None or var.get_type_string() != 'i':
            delay = 0
            if var:
                config.unset('engine/replace-with-kanji-python', 'delay')
        else:
            delay = var.get_int32()
        logger.info("delay: %d", delay)
        return delay

    def __load_x4063_mode(self, config):
        var = config.get_value('engine/replace-with-kanji-python', 'nn_as_jis_x_4063')
        if var is None or var.get_type_string() != 'b':
            mode = True
            if var:
                config.unset('engine/replace-with-kanji-python', 'nn_as_jis_x_4063')
        else:
            mode = var.get_boolean()
        logger.info("nn_as_jis_x_4063 mode: {}".format(mode))
        return mode

    def __config_value_changed_cb(self, config, section, name, value):
        section = section.replace('_', '-')
        if section != 'engine/replace-with-kanji-python':
            return
        logger.debug("config_value_changed({}, {}, {})".format(section, name, value))
        if name == "logging_level":
            self.__logging_level = self.__load_logging_level(config)
        elif name == "delay":
            self.__reset()
            self.__delay = self.__load_delay(config)
            self.__event = Event(self, self.__delay, self.__layout)
        elif name == "layout":
            self.__reset()
            self.__layout = self.__load_layout(config)
            self.__event = Event(self, self.__delay, self.__layout)
        elif name == "dictionary":
            self.__reset()
            self.__dict = self.__load_dictionary(config)
        elif name == "mode":
            self.set_mode(self.__load_input_mode(self.__config))
            self.__override = True
        elif name == "nn_as_jis_x_4063":
            self.__set_x4063_mode(self.__load_x4063_mode(self.__config))

    def __handle_kana_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self.__event.chr().lower()
        if preedit == '\\':
            preedit = ''
            if self.__event.is_shift():
                if 'Shift' in self.__layout:
                    yomi = self.__layout['\\Shift'][c]
                elif modifiers & bits.ShiftL_Bit:
                    yomi = self.__layout['\\ShiftL'][c]
                elif modifiers & bits.ShiftR_Bit:
                    yomi = self.__layout['\\ShiftR'][c]
            else:
                yomi = self.__layout['\\Normal'][c]
        else:
            if self.__event.is_shift():
                if 'Shift' in self.__layout:
                    yomi = self.__layout['Shift'][c]
                elif modifiers & bits.ShiftL_Bit:
                    yomi = self.__layout['ShiftL'][c]
                elif modifiers & bits.ShiftR_Bit:
                    yomi = self.__layout['ShiftR'][c]
            else:
                yomi = self.__layout['Normal'][c]
            if yomi == '\\':
                preedit += yomi
                yomi = ''
        return yomi, preedit

    def __set_x4063_mode(self, on):
        if on:
            self.character_after_n = "aiueo\'wyn"
        else:
            self.character_after_n = "aiueo\'wy"
        logger.debug("set_x4063_mode({})".format(on))

    def __handle_roomazi_layout(self, preedit, keyval, state=0, modifiers=0):
        yomi = ''
        c = self.__event.chr().lower()
        if preedit == 'n' and self.character_after_n.find(c) < 0:
            yomi = 'ん'
            preedit = preedit[1:]
        preedit += c
        if preedit in self.__layout['Roomazi']:
            yomi += self.__layout['Roomazi'][preedit]
            preedit = ''
            if yomi == '\\':
                preedit = yomi
                yomi = ''
        elif 2 <= len(preedit) and preedit[0] == preedit[1] and _re_tu.search(preedit[1]):
            yomi += 'っ'
            preedit = preedit[1:]
        return yomi, preedit

    def __get_surrounding_text(self):
        if not (self.client_capabilities & IBus.Capabilite.SURROUNDING_TEXT):
            self.__ignore_surrounding_text = True
        if self.__ignore_surrounding_text or not self.__acked:
            logger.debug("surrounding text: [%s]" % (self.__previous_text))
            return self.__previous_text, len(self.__previous_text)

        tuple = self.get_surrounding_text()
        text = tuple[0].get_text()
        pos = tuple[1]

        # Qt reports pos as if text is in UTF-16 while GTK reports pos in sane manner.
        # If you're using primarily Qt, use the following code to amend the issue
        # when a character in Unicode supplementary planes is included in text.
        #
        # Deal with surrogate pair manually. (Qt bug?)
        # for i in range(len(text)):
        #     if pos <= i:
        #         break
        #     if 0x10000 <= ord(text[i]):
        #         pos -= 1

        # Qt seems to insert self.__preedit_string to the text, while GTK doesn't.
        # We mimic GTK's behavior here.
        preedit_len = len(self.__preedit_string)
        if 0 < preedit_len and preedit_len <= pos and text[pos - preedit_len:pos] == self.__preedit_string:
            text = text[:-preedit_len]
            pos -= preedit_len
        logger.debug("surrounding text: '%s', %d, [%s]", text, pos, self.__previous_text)
        return text, pos

    def __delete_surrounding_text(self, size):
        self.__previous_text = self.__previous_text[:-size]
        if not self.__ignore_surrounding_text and self.__acked:
            self.delete_surrounding_text(-size, size)
        else:
            # Note a short delay after each BackSpace is necessary for the target application to catch up.
            for i in range(size):
                self.forward_key_event(IBus.BackSpace, 14, 0)
                time.sleep(0.02)
            self.forward_key_event(IBus.BackSpace, 14, IBus.ModifierType.RELEASE_MASK)

    def is_overridden(self):
        return self.__override

    def is_enabled(self):
        return self.get_mode() != 'A'

    def enable_ime(self):
        if not self.is_enabled():
            self.set_mode('あ')
            return True
        return False

    def disable_ime(self):
        if self.is_enabled():
            self.set_mode('A')
            return True
        return False

    def get_mode(self):
        return self.__mode

    def set_mode(self, mode):
        self.__override = False
        if self.__mode == mode:
            return False
        logger.debug("set_mode(%s)" % (mode))
        self.__preedit_string = ''
        self.__commit()
        self.__mode = mode
        self.__update()
        self.__update_input_mode()
        return True

    def __is_roomazi_mode(self):
        return self.__to_kana == self.__handle_roomazi_layout

    def do_process_key_event(self, keyval, keycode, state):
        return self.__event.process_key_event(keyval, keycode, state)

    def handle_key_event(self, keyval, keycode, state, modifiers):
        logger.debug("handle_key_event(%s, %04x, %04x, %04x)" % (IBus.keyval_name(keyval), keycode, state, modifiers))

        if self.__event.is_katakana() or self.__event.is_space() or self.__event.is_suffix() or self.__event.is_henkan():
            pass
        elif self.__event.is_modifier():
            # Ignore modifier keys
            return False
        elif state & (IBus.ModifierType.CONTROL_MASK | IBus.ModifierType.MOD1_MASK):
            self.__commit()
            return False

        # Handle Candidate window
        if 0 < self.__lookup_table.get_number_of_candidates():
            if keyval == keysyms.Page_Up or keyval == keysyms.KP_Page_Up:
                return self.do_page_up()
            elif keyval == keysyms.Page_Down or keyval == keysyms.KP_Page_Down:
                return self.do_page_down()
            elif keyval == keysyms.Up or self.__event.is_muhenkan():
                return self.do_cursor_up()
            elif keyval == keysyms.Down or self.__event.is_henkan():
                return self.do_cursor_down()
            elif keyval == keysyms.Escape:
                self.handle_escape(state)
                return True
            elif keyval == keysyms.Return:
                self.__commit()
                return True

        if self.__preedit_string and keyval == keysyms.Escape:
            self.__preedit_string = ''
            self.__update()
            return True

        # Handle Japanese text
        if self.__event.is_henkan():
            return self.handle_replace(keyval, state)
        if self.__event.is_shrink():
            return self.handle_shrink(keyval, state)
        self.__commit()
        yomi = ''
        if self.__event.is_katakana():
            if self.__event.is_shift():
                self.set_mode('あ' if self.get_mode() == 'ア' else 'ア')
            else:
                self.handle_katakana()
            return True
        if self.__event.is_backspace():
            if 1 <= len(self.__preedit_string):
                self.__preedit_string = self.__preedit_string[:-1]
                self.__update()
                return True
            elif 0 < len(self.__previous_text):
                self.__previous_text = self.__previous_text[:-1]
            return False
        if self.__event.is_ascii():
            yomi, self.__preedit_string = self.__to_kana(self.__preedit_string, keyval, state, modifiers)
        elif keyval == keysyms.hyphen:
            yomi = '―'
        else:
            self.__previous_text = ''
            return False
        if yomi:
            if self.get_mode() == 'ア':
                yomi = to_katakana(yomi)
            self.__commit_string(yomi)
        self.__update()
        return True

    def lookup_dictionary(self, yomi, pos):
        if self.__preedit_string == 'n':
            yomi = yomi[:pos] + 'ん'
            pos += 1
        self.__lookup_table.clear()
        cand = self.__dict.lookup(yomi, pos)
        size = len(self.__dict.reading())
        if 0 < size:
            if self.__preedit_string == 'n':
                if self.__acked:
                    # For furiganapad, yomi has to be committed anyway.
                    # However, 'ん' will be acked by set_cursor_location_cb()
                    # only after the converted text is committed later.
                    # So we pretend that 'ん' is acked here.
                    self.__commit_string('ん')
                    self.__acked = True
                    self.__committed = ''
                else:
                    size = size - 1
            self.__preedit_string = ''
            if 1 < len(self.__dict.cand()):
                for c in self.__dict.cand():
                    self.__lookup_table.append_candidate(IBus.Text.new_from_string(c))
        return (cand, size)

    def handle_katakana(self):
        text, pos = self.__get_surrounding_text()
        for i in reversed(range(pos)):
            if 0 <= _katakana.find(text[i]):
                continue
            found = _hiragana.find(text[i])
            if 0 <= found:
                self.__delete_surrounding_text(pos - i)
                self.__commit_string(_katakana[found] + text[i + 1:pos])
            break
        return True

    def handle_replace(self, keyval, state):
        if not self.__dict.current():
            text, pos = self.__get_surrounding_text()
            (cand, size) = self.lookup_dictionary(text, pos)
            self.__shrunk = ''
        else:
            size = len(self.__dict.current())
            if not self.__event.is_shift():
                cand = self.__dict.next()
            else:
                cand = self.__dict.previous()
        if self.__dict.current():
            self.__update()
            self.__delete_surrounding_text(size)
            self.__commit_string(cand)
        return True

    def handle_shrink(self, keyval, state):
        logger.debug("handle_shrink: '%s'", self.__dict.current())
        if not self.__dict.current():
            return False
        yomi = self.__dict.reading()
        if yomi == 1:
            self.handle_escape(state)
            return True
        current_size = len(self.__dict.current())
        text, pos = self.__get_surrounding_text()
        (cand, size) = self.lookup_dictionary(yomi[1:] + text[pos:], len(yomi) - 1)
        kana = yomi
        if 0 < size:
            kana = kana[:-size]
            self.__shrunk += kana
        elif kana[-1] == '―':
            kana = kana[:-1]
        self.__delete_surrounding_text(current_size)
        self.__commit_string(kana + cand)
        # Update preedit *after* committing the string to append preedit.
        self.__update()
        return True

    def handle_escape(self, state):
        if not self.__dict.current():
            return
        size = len(self.__dict.current())
        yomi = self.__dict.reading()
        self.__delete_surrounding_text(size)
        self.__commit_string(yomi)
        self.__reset(False)
        self.__update()

    def __commit(self):
        if self.__dict.current():
            self.__dict.confirm(self.__shrunk)
            self.__dict.reset()
            self.__lookup_table.clear()
            visible = 0 < self.__lookup_table.get_number_of_candidates()
            self.update_lookup_table(self.__lookup_table, visible)
            self.__previous_text = ''

    def __commit_string(self, text):
        if text == '゛':
            prev, pos = self.__get_surrounding_text()
            if 0 < pos:
                found = _non_daku.find(prev[pos - 1])
                if 0 <= found:
                    self.__delete_surrounding_text(1)
                    text = _daku[found]
        elif text == '゜':
            prev, pos = self.__get_surrounding_text()
            if 0 < pos:
                found = _non_handaku.find(prev[pos - 1])
                if 0 <= found:
                    self.__delete_surrounding_text(1)
                    text = _handaku[found]
        self.__committed = text
        self.__acked = False
        self.__previous_text += text
        self.commit_text(IBus.Text.new_from_string(text))

    def __reset(self, full=True):
        self.__dict.reset()
        self.__lookup_table.clear()
        self.__update_lookup_table()
        if full:
            self.__committed = ''
            self.__acked = True
            self.__previous_text = ''
            self.__preedit_string = ''
            self.__ignore_surrounding_text = False

    def __update_candidate(self):
        index = self.__lookup_table.get_cursor_pos()
        size = len(self.__dict.current())
        self.__dict.set_current(index)
        self.__delete_surrounding_text(size)
        self.__commit_string(self.__dict.current())

    def do_page_up(self):
        if self.__lookup_table.page_up():
            self.__update_lookup_table()
            self.__update_candidate()
        return True

    def do_page_down(self):
        if self.__lookup_table.page_down():
            self.__update_lookup_table()
            self.__update_candidate()
        return True

    def do_cursor_up(self):
        if self.__lookup_table.cursor_up():
            self.__update_lookup_table()
            self.__update_candidate()
        return True

    def do_cursor_down(self):
        if self.__lookup_table.cursor_down():
            self.__update_lookup_table()
            self.__update_candidate()
        return True

    def __update(self):
        preedit_len = len(self.__preedit_string)
        text = IBus.Text.new_from_string(self.__preedit_string)
        if 0 < preedit_len:
            attrs = IBus.AttrList()
            attrs.append(IBus.Attribute.new(IBus.AttrType.UNDERLINE, IBus.AttrUnderline.SINGLE, 0, preedit_len))
            text.set_attributes(attrs)
        # Note self.hide_preedit_text() does not seem to work as expected with Kate.
        # cf. "Qt5 IBus input context does not implement hide_preedit_text()",
        #     https://bugreports.qt.io/browse/QTBUG-48412
        self.update_preedit_text(text, preedit_len, 0 < preedit_len)
        self.__update_lookup_table()

    def __update_lookup_table(self):
        if self.is_enabled():
            visible = 0 < self.__lookup_table.get_number_of_candidates()
            self.update_lookup_table(self.__lookup_table, visible)

    def do_focus_in(self):
        logger.info("focus_in")
        self.__event.reset()
        self.register_properties(self.__prop_list)
        # Request the initial surrounding-text in addition to the "enable" handler.
        self.get_surrounding_text()

    def do_focus_out(self):
        logger.info("focus_out")
        self.__reset()
        self.__dict.save_orders()

    def do_enable(self):
        logger.info("enable")
        # Request the initial surrounding-text when enabled as documented.
        self.get_surrounding_text()

    def do_disable(self):
        logger.info("disable")
        self.__reset()
        self.__mode = 'A'
        self.__dict.save_orders()

    def do_reset(self):
        logger.info("reset")
        self.__reset()
        # Do not switch back to the Alphabet mode here; 'reset' should be
        # called when the text cursor is moved by a mouse click, etc.

    def do_property_activate(self, prop_name, state):
        logger.info("property_activate(%s, %d)" % (prop_name, state))

    def set_surrounding_text_cb(self, engine, text, cursor_pos, anchor_pos):
        text = self.get_plain_text(text.get_text()[:cursor_pos])
        if self.__committed:
            pos = text.rfind(self.__committed)
            if 0 <= pos and pos + len(self.__committed) == len(text):
                self.__acked = True
                self.__committed = ''
        logger.debug("set_surrounding_text_cb(%s, %d, %d) => %d" % (text, cursor_pos, anchor_pos, self.__acked))

    def get_plain_text(self, text):
        plain = ''
        in_ruby = False
        for c in text:
            if c == IAA:
                in_ruby = False
            elif c == IAS:
                in_ruby = True
            elif c == IAT:
                in_ruby = False
            elif not in_ruby:
                plain += c
        return plain

    def set_cursor_location_cb(self, engine, x, y, w, h):
        # On Raspbian, at least till Buster, the candidate window does not
        # always follow the cursor position. The following code is not
        # necessary on Ubuntu 18.04 or Fedora 30.
        logger.debug("set_cursor_location_cb(%d, %d, %d, %d)" % (x, y, w, h))
        self.__update_lookup_table()