Commit 1fbf62a3 authored by Jacek Sowiński's avatar Jacek Sowiński

Add completion box and full completion support

- completion box pops out for multiple candidates
- <Tab> and <Shift-Tab> will navigate through the list of candidates
- any other key than <Tab> and <Shift-Tab> will close the box
- completion box is glued to cursor and is trimmed to max 4 lines
- +projects, @contexts, dates and commands (with aliases) are supported
parent cfd66d5f
...@@ -16,17 +16,9 @@ ...@@ -16,17 +16,9 @@
import urwid import urwid
from os.path import commonprefix
def _get_word_before_pos(p_text, p_pos): from topydo.ui.columns.CompletionBoxWidget import CompletionBoxWidget
is_first_word = False
text = p_text
pos = p_pos
start = text.rfind(' ', 0, pos) + 1
if pos == 0 or text.lstrip().rfind(' ', 0, pos) == -1:
is_first_word = True
return (text[start:pos], is_first_word)
class CommandLineWidget(urwid.Edit): class CommandLineWidget(urwid.Edit):
...@@ -36,10 +28,16 @@ class CommandLineWidget(urwid.Edit): ...@@ -36,10 +28,16 @@ class CommandLineWidget(urwid.Edit):
self.history_pos = None self.history_pos = None
# temporary history storage for edits before cmd execution # temporary history storage for edits before cmd execution
self.history_tmp = [] self.history_tmp = []
self.completer = p_completer self.completer = p_completer
self.completion_box = CompletionBoxWidget()
self._surrounding_text = None # text before insertion of completion
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
urwid.register_signal(CommandLineWidget, ['blur', 'execute_command']) urwid.register_signal(CommandLineWidget, ['blur',
'execute_command',
'show_completions',
'hide_completions'])
def clear(self): def clear(self):
self.set_edit_text("") self.set_edit_text("")
...@@ -85,35 +83,124 @@ class CommandLineWidget(urwid.Edit): ...@@ -85,35 +83,124 @@ class CommandLineWidget(urwid.Edit):
if self.history_pos != 0: if self.history_pos != 0:
self._history_move(-1) self._history_move(-1)
def insert_completion(self, p_insert):
"""
Inserts currently chosen completion (p_insert parameter) into proper
place in edit_text and adjusts cursor position accordingly.
"""
start, end = self._surrounding_text
final_text = start + p_insert + end
self.set_edit_text(final_text)
self.set_edit_pos(len(start) + len(p_insert))
@property
def completion_mode(self):
return len(self.completion_box) > 1
@completion_mode.setter
def completion_mode(self, p_enable):
if p_enable is True:
urwid.emit_signal(self, 'show_completions')
elif p_enable is False:
self._surrounding_text = None
if self.completion_mode:
self.completion_box.clear()
urwid.emit_signal(self, 'hide_completions')
def _complete(self): def _complete(self):
"""
Main completion function.
Gets list of potential completion candidates for currently edited word,
completes it to the longest common part, and shows convenient completion
widget (if multiple completions are returned) with currently selected
candidate highlighted.
"""
def find_word_start(p_text, p_pos):
""" Returns position of the beginning of a word ending in p_pos. """
return p_text.lstrip().rfind(' ', 0, p_pos) + 1
def get_word_before_pos(p_text, p_pos):
start = find_word_start(p_text, p_pos)
return (p_text[start:p_pos], start)
pos = self.edit_pos pos = self.edit_pos
text = self.edit_text text = self.edit_text
completer = self.completer
word_before_cursor, is_first = _get_word_before_pos(text, pos) word_before_cursor, start = get_word_before_pos(text, pos)
completions = self.completer.get_completions(word_before_cursor, completions = completer.get_completions(word_before_cursor, start == 0)
is_first) # store slices before and after place for completion
self._surrounding_text = (text[:start], text[pos:])
if not completions: single_completion = len(completions) == 1
return completion_done = single_completion and completions[0] == word_before_cursor
elif len(completions) > 1: # TODO multiple completions
if completion_done or not completions:
self.completion_mode = False
return return
else: elif single_completion:
replacement = completions[0] replacement = completions[0]
if replacement == word_before_cursor: else:
return # Don't complete what is already completed replacement = commonprefix(completions)
zero_candidate = replacement if replacement else word_before_cursor
offset = len(replacement) - len(word_before_cursor) if zero_candidate != completions[0]:
final_text = text[:pos] + replacement[-offset:] + text[pos:] completions.insert(0, zero_candidate)
self.set_edit_text(final_text)
self.set_edit_pos(pos + offset) self.completion_box.add_completions(completions)
self.insert_completion(replacement)
self.completion_mode = not single_completion
def _completion_move(self, p_step, p_size):
"""
Visually selects completion specified by p_step (positive numbers
forwards, negative numbers backwards) and inserts it into edit_text.
If p_step results in value out of range of currently evaluated
completion candidates, list is rewinded to the start (if cycling
forwards) or to the end (if cycling backwards).
"""
current_position = self.completion_box.focus_position
try:
self.completion_box.set_focus(current_position + p_step)
except IndexError:
position = 0 if p_step > 0 else len(self.completion_box) - 1
self.completion_box.set_focus(position)
maxcols, = p_size
size = (maxcols, self.completion_box.height)
self.completion_box.calculate_visible(size)
candidate = self.completion_box.focus.original_widget.text
self.insert_completion(candidate)
def _prev_completion(self, p_size):
if self.completion_mode:
self._completion_move(-1, p_size)
def _next_completion(self, p_size):
self._completion_move(1, p_size)
def keypress(self, p_size, p_key): def keypress(self, p_size, p_key):
tab_handler = self._complete
if self.completion_mode:
tab_handler = lambda: self._next_completion(p_size)
if p_key not in {'tab', 'shift tab'}:
self.completion_mode = False
dispatch = { dispatch = {
'enter': self._emit_command, 'enter': self._emit_command,
'esc': self._blur, 'esc': self._blur,
'up': self._history_back, 'up': self._history_back,
'down': self._history_next, 'down': self._history_next,
'tab': self._complete 'tab': tab_handler,
'shift tab': lambda: self._prev_completion(p_size)
} }
try: try:
......
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2017 Bram Schoenmakers <bram@topydo.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import urwid
from topydo.ui.columns.Utils import PaletteItem
class CompletionBoxWidget(urwid.ListBox):
def __init__(self):
self.items = urwid.SimpleFocusListWalker([])
self.min_width = 0
super().__init__(self.items)
def __len__(self):
return len(self.items)
@property
def height(self):
""" Returns height of the widget, with maximum set to 4 lines. """
return min(len(self), 4)
@property
def margin(self):
"""
Returns margin for rendering the widget always glued to the cursor.
"""
return len(self.items[0].original_widget.text)
def add_completions(self, p_completions):
"""
Creates proper urwid.Text widgets for all completion candidates from
p_completions list, and populates them into the items attribute.
"""
palette = PaletteItem.MARKED
for completion in p_completions:
width = len(completion)
if width > self.min_width:
self.min_width = width
w = urwid.Text(completion)
self.items.append(urwid.AttrMap(w, None, focus_map=palette))
self.items.set_focus(0)
def clear(self):
self.items.clear()
self.min_width = 0
def set_focus(self, p_position, p_coming_from=None):
self.focus.set_attr_map({PaletteItem.MARKED: None})
super().set_focus(p_position, p_coming_from)
self.focus.set_attr_map({None: PaletteItem.MARKED})
...@@ -90,14 +90,6 @@ class ConsoleWidget(urwid.LineBox): ...@@ -90,14 +90,6 @@ class ConsoleWidget(urwid.LineBox):
elif p_key == ':': elif p_key == ':':
urwid.emit_signal(self, 'close', True) urwid.emit_signal(self, 'close', True)
def render(self, p_size, focus):
"""
This override intercepts the width of the widget such that it can be
stored. The width is used for rendering `ls` output.
"""
self.width = p_size[0]
return super().render(p_size, focus)
def selectable(self): def selectable(self):
return True return True
...@@ -116,7 +108,3 @@ class ConsoleWidget(urwid.LineBox): ...@@ -116,7 +108,3 @@ class ConsoleWidget(urwid.LineBox):
def clear(self): def clear(self):
self.pile.contents = [] self.pile.contents = []
def console_width(self):
# return the last known width (last render)
return self.width
...@@ -60,6 +60,21 @@ _COPY_COLUMN = 3 ...@@ -60,6 +60,21 @@ _COPY_COLUMN = 3
_INSERT_COLUMN = 4 _INSERT_COLUMN = 4
class CliWrapper(urwid.Pile):
"""
Simple wrapper widget for CommandLineWidget with KeystateWidget and
CompletionBoxWidget rendered dynamically.
"""
def __init__(self, *args, **kwargs):
self.width = 0
super().__init__(*args, **kwargs)
def render(self, p_size, focus):
self.width = p_size[0]
return super().render(p_size, focus)
class MainPile(urwid.Pile): class MainPile(urwid.Pile):
""" """
This subclass of Pile doesn't change focus on cursor up/down / mouse press This subclass of Pile doesn't change focus on cursor up/down / mouse press
...@@ -130,6 +145,7 @@ class UIApplication(CLIApplicationBase): ...@@ -130,6 +145,7 @@ class UIApplication(CLIApplicationBase):
self.status_line = urwid.Columns([ self.status_line = urwid.Columns([
('weight', 1, urwid.Filler(self.commandline)), ('weight', 1, urwid.Filler(self.commandline)),
]) ])
self.cli_wrapper = CliWrapper([(1, self.status_line)])
self.keymap = config().column_keymap() self.keymap = config().column_keymap()
self._alarm = None self._alarm = None
...@@ -143,6 +159,8 @@ class UIApplication(CLIApplicationBase): ...@@ -143,6 +159,8 @@ class UIApplication(CLIApplicationBase):
urwid.connect_signal(self.commandline, 'blur', self._blur_commandline) urwid.connect_signal(self.commandline, 'blur', self._blur_commandline)
urwid.connect_signal(self.commandline, 'execute_command', urwid.connect_signal(self.commandline, 'execute_command',
self._execute_handler) self._execute_handler)
urwid.connect_signal(self.commandline, 'show_completions', self._show_completion_box)
urwid.connect_signal(self.commandline, 'hide_completions', self._hide_completion_box)
def hide_console(p_focus_commandline=False): def hide_console(p_focus_commandline=False):
if p_focus_commandline: if p_focus_commandline:
...@@ -168,7 +186,7 @@ class UIApplication(CLIApplicationBase): ...@@ -168,7 +186,7 @@ class UIApplication(CLIApplicationBase):
self.mainwindow = MainPile([ self.mainwindow = MainPile([
('weight', 1, self.columns), ('weight', 1, self.columns),
(1, self.status_line), ('pack', self.cli_wrapper),
]) ])
urwid.connect_signal(self.mainwindow, 'blur_console', hide_console) urwid.connect_signal(self.mainwindow, 'blur_console', hide_console)
...@@ -531,6 +549,33 @@ class UIApplication(CLIApplicationBase): ...@@ -531,6 +549,33 @@ class UIApplication(CLIApplicationBase):
self.keystate_widget.set_text(p_keystate) self.keystate_widget.set_text(p_keystate)
self._keystate_visible = len(p_keystate) > 0 self._keystate_visible = len(p_keystate) > 0
def _show_completion_box(self):
contents = self.cli_wrapper.contents
if len(contents) == 1:
completion_box = self.commandline.completion_box
opts = ('given', completion_box.height)
max_width = self.cli_wrapper.width
pos = self.commandline.get_cursor_coords((max_width,))[0]
l_margin = pos - completion_box.margin
r_margin = max_width - pos - completion_box.min_width + completion_box.margin
padding = urwid.Padding(completion_box,
min_width=completion_box.min_width,
left=l_margin,
right=r_margin)
contents.insert(0, (padding, opts))
completion_box.focus.set_attr_map({None: PaletteItem.MARKED})
self.cli_wrapper.focus_position = 1
def _hide_completion_box(self):
contents = self.cli_wrapper.contents
if len(contents) == 2:
del contents[0]
self.cli_wrapper.focus_position = 0
def _set_alarm(self, p_callback): def _set_alarm(self, p_callback):
""" Sets alarm to execute p_action specified in 0.5 sec. """ """ Sets alarm to execute p_action specified in 0.5 sec. """
self._alarm = self.mainloop.set_alarm_in(0.5, p_callback) self._alarm = self.mainloop.set_alarm_in(0.5, p_callback)
...@@ -624,7 +669,7 @@ class UIApplication(CLIApplicationBase): ...@@ -624,7 +669,7 @@ class UIApplication(CLIApplicationBase):
def _console_width(self): def _console_width(self):
terminal_size = namedtuple('Terminal_Size', 'columns lines') terminal_size = namedtuple('Terminal_Size', 'columns lines')
width = self.console.console_width() - 2 width = self.cli_wrapper.width - 2
sz = terminal_size(width, 1) sz = terminal_size(width, 1)
return sz return sz
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment