Commit 7ca36b81 authored by Bram Schoenmakers's avatar Bram Schoenmakers

Merge branch 'completion'

parents 9acb8f44 6f77ce7e
......@@ -23,7 +23,7 @@ import sys
from topydo.lib.Config import config, ConfigError
_SUBCOMMAND_MAP = {
SUBCOMMAND_MAP = {
'add': 'AddCommand',
'app': 'AppendCommand',
'append': 'AppendCommand',
......@@ -63,9 +63,9 @@ def get_subcommand(p_args):
"""
Returns the class of the requested subcommand. An invalid p_subcommand
will result in an ImportError, since this is a programming mistake
(most likely an error in the _SUBCOMMAND_MAP).
(most likely an error in the SUBCOMMAND_MAP).
"""
classname = _SUBCOMMAND_MAP[p_subcommand]
classname = SUBCOMMAND_MAP[p_subcommand]
modulename = 'topydo.commands.{}'.format(classname)
__import__(modulename, globals(), locals(), [classname], 0)
......@@ -111,14 +111,14 @@ def get_subcommand(p_args):
if subcommand in alias_map:
result, args = resolve_alias(subcommand, args[1:])
elif subcommand in _SUBCOMMAND_MAP:
elif subcommand in SUBCOMMAND_MAP:
result = import_subcommand(subcommand)
args = args[1:]
elif subcommand == 'help':
try:
subcommand = args[1]
if subcommand in _SUBCOMMAND_MAP:
if subcommand in SUBCOMMAND_MAP:
args = [subcommand, 'help']
return get_subcommand(args)
except IndexError:
......@@ -128,14 +128,14 @@ def get_subcommand(p_args):
p_command = config().default_command()
if p_command in alias_map:
result, args = resolve_alias(p_command, args)
elif p_command in _SUBCOMMAND_MAP:
elif p_command in SUBCOMMAND_MAP:
result = import_subcommand(p_command)
# leave args unchanged
except IndexError:
p_command = config().default_command()
if p_command in alias_map:
result, args = resolve_alias(p_command, args)
elif p_command in _SUBCOMMAND_MAP:
elif p_command in SUBCOMMAND_MAP:
result = import_subcommand(p_command)
return (result, args)
# 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 datetime
from functools import lru_cache
from topydo.Commands import SUBCOMMAND_MAP
from topydo.lib.Config import config
@lru_cache(maxsize=1)
def _get_subcmds():
subcmd_map = config().aliases().copy()
subcmd_map.update(SUBCOMMAND_MAP)
return sorted(subcmd_map.keys())
def date_suggestions():
"""
Returns a list of relative date that is presented to the user as auto
complete suggestions.
"""
# don't use strftime, prevent locales to kick in
days_of_week = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday"
}
dates = [
'today',
'tomorrow',
]
# show days of week up to next week
dow = datetime.date.today().weekday()
for i in range(dow + 2 % 7, dow + 7):
dates.append(days_of_week[i % 7])
# and some more relative days starting from next week
dates += ["1w", "2w", "1m", "2m", "3m", "1y"]
return dates
class CompleterBase(object):
def __init__(self, p_todolist):
self.todolist = p_todolist
self._all_subcmds = _get_subcmds()
def _contexts(self, p_word):
completions = ['@' + context for context in self.todolist.contexts() if
context.startswith(p_word[1:])]
return sorted(completions)
def _projects(self, p_word):
completions = ['+' + project for project in self.todolist.projects() if
project.startswith(p_word[1:])]
return sorted(completions)
def _subcmds(self, p_word):
completions = [cmd for cmd in self._all_subcmds if
cmd.startswith(p_word)]
return completions
def get_completions(self, p_word, p_is_first_word=False):
completions = []
if p_word.startswith('+'):
completions = self._projects(p_word)
elif p_word.startswith('@'):
completions = self._contexts(p_word)
elif p_is_first_word:
completions = self._subcmds(p_word)
return completions
# 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/>.
"""
This module provides a completer class that can be used by the Column UI
CommmandLineWidget.
"""
from topydo.lib.Config import config
from topydo.ui.CompleterBase import CompleterBase, date_suggestions
class ColumnCompleter(CompleterBase):
"""
Completer class that completes projects, contexts, dates and
subcommands designed to work with CommandLineWidget for column UI.
"""
def get_completions(self, p_word, p_is_first_word=False):
def dates(p_word, p_tag):
dates = []
for date in date_suggestions():
candidate = p_tag + ':' + date
if candidate.startswith(p_word):
dates.append(candidate)
return dates
due_tag = config().tag_due()
start_tag = config().tag_start()
if p_word.startswith(due_tag + ':'):
return dates(p_word, due_tag)
elif p_word.startswith(start_tag + ':'):
return dates(p_word, start_tag)
else:
return super().get_completions(p_word, p_is_first_word)
......@@ -16,16 +16,28 @@
import urwid
from os.path import commonprefix
from topydo.ui.columns.CompletionBoxWidget import CompletionBoxWidget
class CommandLineWidget(urwid.Edit):
def __init__(self, *args, **kwargs):
def __init__(self, p_completer, *args, **kwargs):
self.history = []
self.history_pos = None
# temporary history storage for edits before cmd execution
self.history_tmp = []
self.completer = p_completer
self.completion_box = CompletionBoxWidget()
self._surrounding_text = None # text before insertion of completion
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):
self.set_edit_text("")
......@@ -71,12 +83,148 @@ class CommandLineWidget(urwid.Edit):
if self.history_pos != 0:
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):
"""
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
text = self.edit_text
completer = self.completer
word_before_cursor, start = get_word_before_pos(text, pos)
completions = completer.get_completions(word_before_cursor, start == 0)
# store slices before and after place for completion
self._surrounding_text = (text[:start], text[pos:])
single_completion = len(completions) == 1
completion_done = single_completion and completions[0] == word_before_cursor
if completion_done or not completions:
self.completion_mode = False
return
elif single_completion:
replacement = completions[0]
else:
replacement = commonprefix(completions)
zero_candidate = replacement if replacement else word_before_cursor
if zero_candidate != completions[0]:
completions.insert(0, zero_candidate)
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 _home(self):
""" Moves cursor to the beginning of the line. """
self.set_edit_pos(0)
def _end(self):
""" Moves cursor to the end of the line. """
end = len(self.edit_text)
self.set_edit_pos(end)
def _home_del(self):
""" Deletes the line content before the cursor """
text = self.edit_text[self.edit_pos:]
self.set_edit_text(text)
self._home()
def _end_del(self):
""" Deletes the line content after the cursor """
text = self.edit_text[:self.edit_pos]
self.set_edit_text(text)
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 = {
'enter': self._emit_command,
'esc': self._blur,
'up': self._history_back,
'down': self._history_next
'down': self._history_next,
'ctrl a': self._home,
'ctrl e': self._end,
'ctrl u': self._home_del,
'ctrl k': self._end_del,
'tab': tab_handler,
'shift tab': lambda: self._prev_completion(p_size)
}
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):
elif p_key == ':':
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):
return True
......@@ -116,7 +108,3 @@ class ConsoleWidget(urwid.LineBox):
def clear(self):
self.pile.contents = []
def console_width(self):
# return the last known width (last render)
return self.width
......@@ -26,6 +26,7 @@ from string import ascii_uppercase
from topydo.Commands import get_subcommand
from topydo.lib.Config import config, ConfigError
from topydo.ui.columns.ColumnCompleter import ColumnCompleter
from topydo.lib.Sorter import Sorter
from topydo.lib.Filter import get_filter_list, RelevanceFilter, DependencyFilter
from topydo.lib.Utils import get_terminal_size
......@@ -59,6 +60,21 @@ _COPY_COLUMN = 3
_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):
"""
This subclass of Pile doesn't change focus on cursor up/down / mouse press
......@@ -123,11 +139,13 @@ class UIApplication(CLIApplicationBase):
self.columns = urwid.Columns([], dividechars=0,
min_width=config().column_width())
self.commandline = CommandLineWidget('topydo> ')
completer = ColumnCompleter(self.todolist)
self.commandline = CommandLineWidget(completer, 'topydo> ')
self.keystate_widget = KeystateWidget()
self.status_line = urwid.Columns([
('weight', 1, urwid.Filler(self.commandline)),
])
self.cli_wrapper = CliWrapper([(1, self.status_line)])
self.keymap = config().column_keymap()
self._alarm = None
......@@ -141,6 +159,8 @@ class UIApplication(CLIApplicationBase):
urwid.connect_signal(self.commandline, 'blur', self._blur_commandline)
urwid.connect_signal(self.commandline, 'execute_command',
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):
if p_focus_commandline:
......@@ -166,7 +186,7 @@ class UIApplication(CLIApplicationBase):
self.mainwindow = MainPile([
('weight', 1, self.columns),
(1, self.status_line),
('pack', self.cli_wrapper),
])
urwid.connect_signal(self.mainwindow, 'blur_console', hide_console)
......@@ -529,6 +549,33 @@ class UIApplication(CLIApplicationBase):
self.keystate_widget.set_text(p_keystate)
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):
""" Sets alarm to execute p_action specified in 0.5 sec. """
self._alarm = self.mainloop.set_alarm_in(0.5, p_callback)
......@@ -622,7 +669,7 @@ class UIApplication(CLIApplicationBase):
def _console_width(self):
terminal_size = namedtuple('Terminal_Size', 'columns lines')
width = self.console.console_width() - 2
width = self.cli_wrapper.width - 2
sz = terminal_size(width, 1)
return sz
......
......@@ -21,7 +21,7 @@ import shlex
import sys
from topydo.ui.CLIApplicationBase import CLIApplicationBase, error, GENERIC_HELP
from topydo.ui.prompt.TopydoCompleter import TopydoCompleter
from topydo.ui.prompt.PromptCompleter import PromptCompleter
from prompt_toolkit.shortcuts import prompt
from prompt_toolkit.history import InMemoryHistory
......@@ -61,7 +61,7 @@ class PromptApplication(CLIApplicationBase):
"""
self.todolist.erase()
self.todolist.add_list(self.todofile.read())
self.completer = TopydoCompleter(self.todolist)
self.completer = PromptCompleter(self.todolist)
def run(self):
""" Main entry function. """
......
......@@ -19,94 +19,38 @@ This module provides a completer class that can be used by the prompt provided
by the prompt toolkit.
"""
import datetime
import re
from prompt_toolkit.completion import Completer, Completion
from topydo.Commands import _SUBCOMMAND_MAP
from topydo.ui.CompleterBase import CompleterBase, date_suggestions
from topydo.lib.Config import config
from topydo.lib.RelativeDate import relative_date_to_date
def _subcommands(p_word_before_cursor):
""" Generator for subcommand name completion. """
sc_map = config().aliases()
sc_map.update(_SUBCOMMAND_MAP)
subcommands = [sc for sc in sorted(sc_map.keys()) if
sc.startswith(p_word_before_cursor)]
for command in subcommands:
yield Completion(command, -len(p_word_before_cursor))
def _dates(p_word_before_cursor):
""" Generator for date completion. """
def _date_suggestions():
"""
Returns a list of relative date that is presented to the user as auto
complete suggestions.
"""
# don't use strftime, prevent locales to kick in
days_of_week = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday"
}
dates = [
'today',
'tomorrow',
]
# show days of week up to next week
dow = datetime.date.today().weekday()
for i in range(dow + 2 % 7, dow + 7):
dates.append(days_of_week[i % 7])
# and some more relative days starting from next week
dates += ["1w", "2w", "1m", "2m", "3m", "1y"]
return dates
to_absolute = lambda s: relative_date_to_date(s).isoformat()
start_value_pos = p_word_before_cursor.find(':') + 1
value = p_word_before_cursor[start_value_pos:]
for reldate in _date_suggestions():
for reldate in date_suggestions():
if not reldate.startswith(value):
continue
yield Completion(reldate, -len(value), display_meta=to_absolute(reldate))
class TopydoCompleter(Completer):
class PromptCompleter(CompleterBase, Completer):
"""
Completer class that completes projects, contexts, dates and
subcommands.
subcommands and is compatible with prompt toolkit.
"""
def __init__(self, p_todolist):
self.todolist = p_todolist
def _projects(self, p_word_before_cursor):
""" Generator for project completion. """
projects = [p for p in self.todolist.projects() if
p.startswith(p_word_before_cursor[1:])]
for project in projects:
yield Completion("+" + project, -len(p_word_before_cursor))
def _contexts(self, p_word_before_cursor):
""" Generator for context completion. """
contexts = [c for c in self.todolist.contexts() if
c.startswith(p_word_before_cursor[1:])]
for context in contexts:
yield Completion("@" + context, -len(p_word_before_cursor))
def _completion_generator(self, p_word, is_first_word):
candidates = super().get_completions(p_word, is_first_word)
for candidate in candidates:
yield Completion(candidate, -len(p_word))
def get_completions(self, p_document, _):
# include all characters except whitespaces (for + and @)
......@@ -114,15 +58,9 @@ class TopydoCompleter(Completer):
is_first_word = not re.match(r'\s*\S+\s',
p_document.current_line_before_cursor)
if is_first_word:
return _subcommands(word_before_cursor)
elif word_before_cursor.startswith('+'):
return self._projects(word_before_cursor)
elif word_before_cursor.startswith('@'):
return self._contexts(word_before_cursor)
elif word_before_cursor.startswith(config().tag_due() + ':'):
if word_before_cursor.startswith(config().tag_due() + ':'):
return _dates(word_before_cursor)
elif word_before_cursor.startswith(config().tag_start() + ':'):
return _dates(word_before_cursor)
return []
else:
return self._completion_generator(word_before_cursor, is_first_word)
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