From ba54ff92453cce259df9922f36005286645558a1 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 23 Apr 2018 07:35:28 +0100 Subject: [PATCH 01/61] Retrieve size information when crawling file system --- sml_sync/models.py | 2 +- sml_sync/sync.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sml_sync/models.py b/sml_sync/models.py index b4a6330..7fd00eb 100644 --- a/sml_sync/models.py +++ b/sml_sync/models.py @@ -20,7 +20,7 @@ def without_path_prefix(self, prefix): ) -FileAttrs = collections.namedtuple('FileAttrs', ['last_modified']) +FileAttrs = collections.namedtuple('FileAttrs', ['last_modified', 'size']) DirectoryAttrs = collections.namedtuple('DirectoryAttrs', ['last_modified']) diff --git a/sml_sync/sync.py b/sml_sync/sync.py index 026f586..0d576d1 100644 --- a/sml_sync/sync.py +++ b/sml_sync/sync.py @@ -113,7 +113,7 @@ def _rsync_list(self, path, rsync_opts=None): exclude_list = self._get_exclude_list() rsync_cmd = [ 'rsync', '-a', '-e', ssh_cmd, '--itemize-changes', '--dry-run', - '--out-format', '%i||%n||%M', *exclude_list, *rsync_opts, path, + '--out-format', '%i||%n||%M||%l', *exclude_list, *rsync_opts, path, '/dev/false' ] process = _run_ssh_cmd(rsync_cmd) @@ -137,7 +137,7 @@ def _parse_rsync_list_result(self, stdout): fs_objects = [] for line in stdout.splitlines(): try: - changes, path, mtime_string = line.split('||') + changes, path, mtime_string, size_string = line.split('||') try: is_directory = changes[1] == 'd' except IndexError: @@ -150,10 +150,11 @@ def _parse_rsync_list_result(self, stdout): DirectoryAttrs(mtime) ) else: + size = int(size_string) fs_object = FsObject( path, FsObjectType.FILE, - FileAttrs(mtime) + FileAttrs(mtime, size) ) fs_objects.append(fs_object) except Exception as e: From 6d407e3d7f5eb0db5b15d1d0a69fa6607a3e4cf1 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Wed, 25 Apr 2018 06:08:18 +0100 Subject: [PATCH 02/61] Flake8 fixes --- sml_sync/screens/diff.py | 46 ++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 6b6d84a..adde0b4 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -106,18 +106,26 @@ def _render_containers(self, differences): difference[1].path for difference in differences if difference[0] in {'TYPE_DIFFERENT', 'ATTRS_DIFFERENT'} ] - if not extra_local_paths and not extra_remote_paths and not other_differences: - self._has_differences = False - self._menu_containers = [ - Window( - FormattedTextControl( - 'Local directory and SherlockML are synchronized.'), - height=1) - ] + are_synchronized = ( + not extra_local_paths + and not extra_remote_paths + and not other_differences + ) + if are_synchronized: + self._has_differences = False + self._menu_containers = [ + Window( + FormattedTextControl( + 'Local directory and SherlockML are synchronized.' + ), + height=1) + ] else: self._has_differences = True if extra_local_paths: - text = 'There {} {} {} that {} locally but not on SherlockML'.format( + template = \ + 'There {} {} {} that {} locally but not on SherlockML' + text = template.format( self._plural_verb('is', len(extra_local_paths)), len(extra_local_paths), self._plural('file', len(extra_local_paths)), @@ -127,7 +135,8 @@ def _render_containers(self, differences): self._menu_containers.append(container) self._menu_container_names.append(SummaryContainerName.LOCAL) if extra_remote_paths: - text = 'There {} {} {} that {} only on SherlockML'.format( + template = 'There {} {} {} that {} only on SherlockML' + text = template.format( self._plural_verb('is', len(extra_remote_paths)), len(extra_remote_paths), self._plural('file', len(extra_remote_paths)), @@ -137,7 +146,8 @@ def _render_containers(self, differences): self._menu_containers.append(container) self._menu_container_names.append(SummaryContainerName.REMOTE) if other_differences: - text = 'There {} {} {} that {} not synchronized'.format( + template = 'There {} {} {} that {} not synchronized' + text = template.format( self._plural_verb('is', len(other_differences)), len(other_differences), self._plural('file', len(other_differences)), @@ -209,34 +219,34 @@ def __init__(self, differences, exchange): self._details = Details(differences, self._summary.current_focus) self.bindings = KeyBindings() - @self.bindings.add('d') + @self.bindings.add('d') # noqa: F811 def _(event): self._exchange.publish(Messages.SYNC_SHERLOCKML_TO_LOCAL) - @self.bindings.add('u') + @self.bindings.add('u') # noqa: F811 def _(event): self._exchange.publish(Messages.SYNC_LOCAL_TO_SHERLOCKML) - @self.bindings.add('r') + @self.bindings.add('r') # noqa: F811 def _(event): self._exchange.publish(Messages.REFRESH_DIFFERENCES) - @self.bindings.add('w') + @self.bindings.add('w') # noqa: F811 def _(event): self._exchange.publish(Messages.START_WATCH_SYNC) - @self.bindings.add('?') + @self.bindings.add('?') # noqa: F811 def _(event): self._toggle_help() - @self.bindings.add('tab') + @self.bindings.add('tab') # noqa: F811 @self.bindings.add('down') @self.bindings.add('left') def _(event): new_focus = self._summary.focus_next() self._details.set_focus(new_focus) - @self.bindings.add('s-tab') + @self.bindings.add('s-tab') # noqa: F811 @self.bindings.add('up') @self.bindings.add('right') def _(event): From fcc1e18207263a677a1776fbe6444587ed986976 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Wed, 25 Apr 2018 06:31:36 +0100 Subject: [PATCH 03/61] Encode differences in a named tuple Previously, this was just done in a simple tuple. That harmed readability. --- sml_sync/file_trees.py | 19 +++++++++---------- sml_sync/models.py | 18 ++++++++++++++++++ sml_sync/screens/diff.py | 27 +++++++++++++++------------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/sml_sync/file_trees.py b/sml_sync/file_trees.py index 4a279f9..c51c2a3 100644 --- a/sml_sync/file_trees.py +++ b/sml_sync/file_trees.py @@ -1,10 +1,9 @@ -import logging import os import stat from datetime import datetime -from .models import DirectoryAttrs, FileAttrs, FsObject, FsObjectType +from .models import FsObjectType, Difference, DifferenceType def get_remote_mtime(path, sftp): @@ -37,18 +36,18 @@ def compare_file_trees(left, right): right_file_paths = {obj.path: obj for obj in right} left_only = [obj for obj in left if obj.path not in right_file_paths] for obj in left_only: - yield ('LEFT_ONLY', obj) + yield Difference(DifferenceType.LEFT_ONLY, left=obj, right=None) right_only = [obj for obj in right if obj.path not in left_file_paths] for obj in right_only: - yield ('RIGHT_ONLY', obj) + yield Difference(DifferenceType.RIGHT_ONLY, left=None, right=obj) for left_obj in left: if left_obj.path in right_file_paths: right_obj = right_file_paths[left_obj.path] if left_obj.obj_type != right_obj.obj_type: - yield ('TYPE_DIFFERENT', left_obj, right_obj) - elif ( - left_obj.attrs != right_obj.attrs and - left_obj.obj_type == FsObjectType.FILE - ): - yield ('ATTRS_DIFFERENT', left_obj, right_obj) + yield Difference( + DifferenceType.TYPE_DIFFERENT, left_obj, right_obj) + elif (left_obj.attrs != right_obj.attrs and + left_obj.obj_type == FsObjectType.FILE): + yield Difference( + DifferenceType.ATTRS_DIFFERENT, left_obj, right_obj) diff --git a/sml_sync/models.py b/sml_sync/models.py index 7fd00eb..e1f23d2 100644 --- a/sml_sync/models.py +++ b/sml_sync/models.py @@ -41,3 +41,21 @@ class ChangeEventType(Enum): 'FsChangeEvent', ['event_type', 'is_directory', 'path', 'extra_args'] ) + + +class DifferenceType(Enum): + # path exists only in left tree + LEFT_ONLY = 'LEFT_ONLY' + + # path exists only in right tree + RIGHT_ONLY = 'RIGHT_ONLY' + + # path exists in both, but is a file in one and a directory in the other + TYPE_DIFFERENT = 'TYPE_DIFFERENT' + + # path exists in both, but they have different attributes + ATTRS_DIFFERENT = 'ATTRS_DIFFERENT' + + +Difference = collections.namedtuple( + 'Difference', ['difference_type', 'left', 'right']) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index adde0b4..1d34b38 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -8,6 +8,7 @@ from prompt_toolkit.layout.controls import FormattedTextControl from ..pubsub import Messages +from ..models import DifferenceType from .base import BaseScreen from .help import help_modal @@ -95,16 +96,17 @@ def _set_selection_index(self, new_index): def _render_containers(self, differences): extra_local_paths = [ - difference[1].path for difference in differences - if difference[0] == 'LEFT_ONLY' + difference.left.path for difference in differences + if difference.difference_type == DifferenceType.LEFT_ONLY ] extra_remote_paths = [ - difference[1].path for difference in differences - if difference[0] == 'RIGHT_ONLY' + difference.right.path for difference in differences + if difference.difference_type == DifferenceType.RIGHT_ONLY ] other_differences = [ - difference[1].path for difference in differences - if difference[0] in {'TYPE_DIFFERENT', 'ATTRS_DIFFERENT'} + difference.left.path for difference in differences + if difference.difference_type in + {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} ] are_synchronized = ( not extra_local_paths @@ -177,23 +179,24 @@ def _render(self): self._container = Window() elif self._focus == SummaryContainerName.LOCAL: paths = [ - difference[1].path + difference.left.path for difference in self._differences - if difference[0] == 'LEFT_ONLY' + if difference.difference_type == DifferenceType.LEFT_ONLY ] self._render_paths(paths) elif self._focus == SummaryContainerName.REMOTE: paths = [ - difference[1].path + difference.right.path for difference in self._differences - if difference[0] == 'RIGHT_ONLY' + if difference.difference_type == DifferenceType.RIGHT_ONLY ] self._render_paths(paths) else: paths = [ - difference[1].path + difference.left.path for difference in self._differences - if difference[0] in {'TYPE_DIFFERENT', 'ATTRS_DIFFERENT'} + if difference.difference_type in + {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} ] self._render_paths(paths) From c1598ed42d6f49e2c47ffe3c29bb69033d055151 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Wed, 25 Apr 2018 06:53:31 +0100 Subject: [PATCH 04/61] Pass full file object when rendering This will let us render more than just the path. --- sml_sync/screens/diff.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 1d34b38..8cff41c 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -178,30 +178,33 @@ def _render(self): if self._focus is None: self._container = Window() elif self._focus == SummaryContainerName.LOCAL: - paths = [ - difference.left.path + file_objects = [ + difference.left for difference in self._differences if difference.difference_type == DifferenceType.LEFT_ONLY ] - self._render_paths(paths) + self._render_differences(file_objects) elif self._focus == SummaryContainerName.REMOTE: - paths = [ - difference.right.path + file_objects = [ + difference.right for difference in self._differences if difference.difference_type == DifferenceType.RIGHT_ONLY ] - self._render_paths(paths) + self._render_differences(file_objects) else: - paths = [ - difference.left.path + file_objects = [ + difference.left for difference in self._differences if difference.difference_type in {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} ] - self._render_paths(paths) + self._render_differences(file_objects) - def _render_paths(self, paths): - path_texts = [' {}'.format(path) for path in paths] + def _render_differences(self, file_objects): + path_texts = [ + ' {}'.format(file_object.path) + for file_object in file_objects + ] self._control.text = '\n'.join(path_texts) From 03f3da998de25439a4a50d5f78b986812bce22b0 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 1 May 2018 07:24:43 +0100 Subject: [PATCH 05/61] Add table component This will help structure the file list, sizes and mtimes. --- sml_sync/screens/components/__init__.py | 2 + sml_sync/screens/components/table.py | 51 +++++++++++++++++++ sml_sync/screens/components/tests/__init__.py | 0 .../screens/components/tests/test_table.py | 31 +++++++++++ 4 files changed, 84 insertions(+) create mode 100644 sml_sync/screens/components/__init__.py create mode 100644 sml_sync/screens/components/table.py create mode 100644 sml_sync/screens/components/tests/__init__.py create mode 100644 sml_sync/screens/components/tests/test_table.py diff --git a/sml_sync/screens/components/__init__.py b/sml_sync/screens/components/__init__.py new file mode 100644 index 0000000..f93709a --- /dev/null +++ b/sml_sync/screens/components/__init__.py @@ -0,0 +1,2 @@ + +from .table import Table, TableColumn # noqa diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py new file mode 100644 index 0000000..9524961 --- /dev/null +++ b/sml_sync/screens/components/table.py @@ -0,0 +1,51 @@ +from collections import namedtuple +import itertools +from typing import List + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.layout import ( + Window, HSplit, FormattedTextControl, BufferControl, ScrollbarMargin) + + +TableColumn = namedtuple('Column', ['rows', 'header']) + + +class Table(object): + + def __init__(self, columns: List[TableColumn]): + formatted_headers = [] + formatted_columns = [] + for column in columns: + width = max( + len(column.header), + max(len(row) for row in column.rows) + ) + formatted_rows = [row.ljust(width, ' ') for row in column.rows] + formatted_headers.append(column.header.ljust(width, ' ')) + formatted_columns.append(formatted_rows) + + rows = list( + itertools.zip_longest(*formatted_columns, fillvalue='') + ) + + rows_string = [' '.join(row) for row in rows] + table_body = '\n'.join(rows_string) + + self._header_control = FormattedTextControl( + ' '.join(formatted_headers)) + + document = Document(table_body, 0) + _buffer = Buffer(document=document, read_only=True) + self._body_control = BufferControl(_buffer) + + self.window = HSplit([ + Window(self._header_control, height=1), + Window( + self._body_control, + right_margins=[ScrollbarMargin(display_arrows=True)] + ) + ]) + + def __pt_container__(self): + return self.window diff --git a/sml_sync/screens/components/tests/__init__.py b/sml_sync/screens/components/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py new file mode 100644 index 0000000..9cf1c7a --- /dev/null +++ b/sml_sync/screens/components/tests/test_table.py @@ -0,0 +1,31 @@ +import textwrap + +from .. import Table, TableColumn + + +def test_simple_table(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['d', 'e', 'f'], header='t2') + + table = Table([col1, col2]) + + assert table._header_control.text == 't1 t2' + assert table._body_control.buffer.text == textwrap.dedent( + """\ + a d + b e + c f """) # noqa: W291 (ignore trailing whitespace) + + +def test_table_varying_row_lengths(): + col1 = TableColumn(rows=['a', 'some-long-value'], header='t1') + col2 = TableColumn(rows=['long', 'b'], header='t2') + + table = Table([col1, col2]) + + assert table._header_control.text == textwrap.dedent("""\ + t1 t2 """) + assert table._body_control.buffer.text == textwrap.dedent( + """\ + a long + some-long-value b """) From b9a4bf1d5e5393c19fa80623e5f4f7c12784589a Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 1 May 2018 07:29:47 +0100 Subject: [PATCH 06/61] Raise error if the number of rows is inconsistent --- sml_sync/screens/components/table.py | 3 +++ sml_sync/screens/components/tests/test_table.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 9524961..0292bc5 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -14,6 +14,9 @@ class Table(object): def __init__(self, columns: List[TableColumn]): + if len(set(len(column.rows) for column in columns)) != 1: + raise ValueError('All columns must have the same number of rows.') + formatted_headers = [] formatted_columns = [] for column in columns: diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index 9cf1c7a..4ba95e0 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -1,5 +1,7 @@ import textwrap +import pytest + from .. import Table, TableColumn @@ -29,3 +31,11 @@ def test_table_varying_row_lengths(): """\ a long some-long-value b """) + + +def test_different_length_rows(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['e'], header='t2') + + with pytest.raises(ValueError): + Table([col1, col2]) From 656029f8091c3ab89fa27b63ee75aa8df34d32a1 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 1 May 2018 07:32:43 +0100 Subject: [PATCH 07/61] Handle case where there are no rows --- sml_sync/screens/components/table.py | 2 +- sml_sync/screens/components/tests/test_table.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 0292bc5..ad7911d 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -22,7 +22,7 @@ def __init__(self, columns: List[TableColumn]): for column in columns: width = max( len(column.header), - max(len(row) for row in column.rows) + max((len(row) for row in column.rows), default=0) ) formatted_rows = [row.ljust(width, ' ') for row in column.rows] formatted_headers.append(column.header.ljust(width, ' ')) diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index 4ba95e0..5c4f99b 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -39,3 +39,13 @@ def test_different_length_rows(): with pytest.raises(ValueError): Table([col1, col2]) + + +def test_no_rows(): + col1 = TableColumn(rows=[], header='t1') + col2 = TableColumn(rows=[], header='t2') + + table = Table([col1, col2]) + + assert table._header_control.text == 't1 t2' + assert table._body_control.buffer.text == '' From 7e2f53136eb5e13477ce33dca50736fc6f108555 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 1 May 2018 07:36:03 +0100 Subject: [PATCH 08/61] Test case where there are no columns --- sml_sync/screens/components/table.py | 2 +- sml_sync/screens/components/tests/test_table.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index ad7911d..43b496d 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -14,7 +14,7 @@ class Table(object): def __init__(self, columns: List[TableColumn]): - if len(set(len(column.rows) for column in columns)) != 1: + if len(set(len(column.rows) for column in columns)) not in {0, 1}: raise ValueError('All columns must have the same number of rows.') formatted_headers = [] diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index 5c4f99b..c19de3a 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -49,3 +49,10 @@ def test_no_rows(): assert table._header_control.text == 't1 t2' assert table._body_control.buffer.text == '' + + +def test_no_columns(): + table = Table([]) + + assert table._header_control.text == '' + assert table._body_control.buffer.text == '' From d2b5c7973233651787336ccaee1ef39a05030adb Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 07:57:35 +0100 Subject: [PATCH 09/61] Set table preferred width This allows embedding the table container in HSplits and VSplits. --- sml_sync/screens/components/table.py | 3 +++ sml_sync/screens/components/tests/test_table.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 43b496d..f0f8d9f 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -50,5 +50,8 @@ def __init__(self, columns: List[TableColumn]): ) ]) + def preferred_width(self, max_available_width): + return self.window.preferred_width(max_available_width) + def __pt_container__(self): return self.window diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index c19de3a..20a2704 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -17,6 +17,7 @@ def test_simple_table(): a d b e c f """) # noqa: W291 (ignore trailing whitespace) + assert table.preferred_width(100).preferred == 5 def test_table_varying_row_lengths(): @@ -31,6 +32,7 @@ def test_table_varying_row_lengths(): """\ a long some-long-value b """) + assert table.preferred_width(100).preferred == 20 def test_different_length_rows(): @@ -49,6 +51,7 @@ def test_no_rows(): assert table._header_control.text == 't1 t2' assert table._body_control.buffer.text == '' + assert table.preferred_width(100).preferred == 5 def test_no_columns(): @@ -56,3 +59,4 @@ def test_no_columns(): assert table._header_control.text == '' assert table._body_control.buffer.text == '' + assert table.preferred_width(100).preferred == 0 From d6f4ef843bca2fe53669ad5f98660124337c6fbc Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 08:26:25 +0100 Subject: [PATCH 10/61] Implement table preferred height Allows embedding table in {H,V}Splits. --- sml_sync/screens/components/table.py | 42 ++++++++++++------- .../screens/components/tests/test_table.py | 28 +++++++++---- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index f0f8d9f..4eaab69 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -35,23 +35,37 @@ def __init__(self, columns: List[TableColumn]): rows_string = [' '.join(row) for row in rows] table_body = '\n'.join(rows_string) - self._header_control = FormattedTextControl( - ' '.join(formatted_headers)) - - document = Document(table_body, 0) - _buffer = Buffer(document=document, read_only=True) - self._body_control = BufferControl(_buffer) - - self.window = HSplit([ - Window(self._header_control, height=1), - Window( - self._body_control, - right_margins=[ScrollbarMargin(display_arrows=True)] - ) - ]) + if rows: + document = Document(table_body, 0) + _buffer = Buffer(document=document, read_only=True) + self._body_control = BufferControl(_buffer) + body_windows = [ + Window( + self._body_control, + right_margins=[ScrollbarMargin(display_arrows=True)] + ) + ] + else: + body_windows = [] + + self.window = HSplit( + self._header_windows(formatted_headers) + body_windows + ) + + def _header_windows(self, formatted_headers): + if len(formatted_headers): + header_control = FormattedTextControl( + ' '.join(formatted_headers)) + header_windows = [Window(header_control, height=1)] + else: + header_windows = [Window(height=1, width=0)] + return header_windows def preferred_width(self, max_available_width): return self.window.preferred_width(max_available_width) + def preferred_height(self, width, max_available_height): + return self.window.preferred_height(width, max_available_height) + def __pt_container__(self): return self.window diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index 20a2704..dd07788 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -11,13 +11,17 @@ def test_simple_table(): table = Table([col1, col2]) - assert table._header_control.text == 't1 t2' - assert table._body_control.buffer.text == textwrap.dedent( + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == 't1 t2' + assert body_window.content.buffer.text == textwrap.dedent( """\ a d b e c f """) # noqa: W291 (ignore trailing whitespace) assert table.preferred_width(100).preferred == 5 + assert table.preferred_height(5, 100).preferred == 4 def test_table_varying_row_lengths(): @@ -26,13 +30,17 @@ def test_table_varying_row_lengths(): table = Table([col1, col2]) - assert table._header_control.text == textwrap.dedent("""\ + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == textwrap.dedent("""\ t1 t2 """) - assert table._body_control.buffer.text == textwrap.dedent( + assert body_window.content.buffer.text == textwrap.dedent( """\ a long some-long-value b """) assert table.preferred_width(100).preferred == 20 + assert table.preferred_height(5, 100).preferred == 3 def test_different_length_rows(): @@ -49,14 +57,18 @@ def test_no_rows(): table = Table([col1, col2]) - assert table._header_control.text == 't1 t2' - assert table._body_control.buffer.text == '' + assert len(table.window.children) == 1 + [header_window] = table.window.children + + assert header_window.content.text == 't1 t2' assert table.preferred_width(100).preferred == 5 + assert table.preferred_height(5, 100).preferred == 1 def test_no_columns(): table = Table([]) - assert table._header_control.text == '' - assert table._body_control.buffer.text == '' + assert len(table.window.children) == 1 + assert table.preferred_width(100).preferred == 0 + assert table.preferred_height(0, 100).preferred == 1 From 21fed5f5bb1a67df71c3b3c49f7967edbf3b3a43 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 08:39:17 +0100 Subject: [PATCH 11/61] Implement missing container methods --- sml_sync/screens/components/table.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 4eaab69..9899635 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -67,5 +67,11 @@ def preferred_width(self, max_available_width): def preferred_height(self, width, max_available_height): return self.window.preferred_height(width, max_available_height) + def write_to_screen(self, *args, **kwargs): + return self.window.write_to_screen(*args, **kwargs) + + def get_children(self): + return self.window.get_children() + def __pt_container__(self): return self.window From eb4a3dca0210c681a81494e3d174a01271752fa6 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 10:29:27 +0100 Subject: [PATCH 12/61] Use table to represent differences --- sml_sync/models.py | 6 ++++++ sml_sync/screens/diff.py | 26 +++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/sml_sync/models.py b/sml_sync/models.py index e1f23d2..ff44e70 100644 --- a/sml_sync/models.py +++ b/sml_sync/models.py @@ -19,6 +19,12 @@ def without_path_prefix(self, prefix): self.attrs ) + def is_file(self): + return self.obj_type == FsObjectType.FILE + + def is_directory(self): + return self.obj_type == FsObjectType.DIRECTORY + FileAttrs = collections.namedtuple('FileAttrs', ['last_modified', 'size']) DirectoryAttrs = collections.namedtuple('DirectoryAttrs', ['last_modified']) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 8cff41c..939ce4f 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -3,7 +3,7 @@ import inflect from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit +from prompt_toolkit.layout import HSplit, VSplit, to_container from prompt_toolkit.layout.containers import FloatContainer, Window from prompt_toolkit.layout.controls import FormattedTextControl @@ -11,6 +11,7 @@ from ..models import DifferenceType from .base import BaseScreen from .help import help_modal +from .components import Table, TableColumn HELP_TITLE = 'Differences between local directory and SherlockML' @@ -166,8 +167,8 @@ class Details(object): def __init__(self, differences, initial_focus): self._focus = initial_focus self._differences = differences - self._control = FormattedTextControl('') - self.container = Window(self._control) + self.container = VSplit([Window(FormattedTextControl('Loading...'))]) + self._render() def set_focus(self, new_focus): @@ -176,7 +177,7 @@ def set_focus(self, new_focus): def _render(self): if self._focus is None: - self._container = Window() + self.container.children = [] elif self._focus == SummaryContainerName.LOCAL: file_objects = [ difference.left @@ -201,11 +202,22 @@ def _render(self): self._render_differences(file_objects) def _render_differences(self, file_objects): - path_texts = [ - ' {}'.format(file_object.path) + file_paths = [file_object.path for file_object in file_objects] + file_sizes = [ + str(file_object.attrs.size) if file_object.is_file() else '' + for file_object in file_objects + ] + file_mtimes = [ + str(file_object.attrs.last_modified) + if file_object.is_file() else '' for file_object in file_objects ] - self._control.text = '\n'.join(path_texts) + columns = [ + TableColumn(file_paths, 'PATH'), + TableColumn(file_sizes, 'SIZE'), + TableColumn(file_mtimes, 'LAST MODIFIED') + ] + self.container.children = [to_container(Table(columns))] class DifferencesScreen(BaseScreen): From d35f38d1f9e1bc39c1753193355722fbec765162 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 10:47:19 +0100 Subject: [PATCH 13/61] Change semantics of focus to be Up/Down --- sml_sync/screens/diff.py | 59 ++++++++-------------------------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 939ce4f..03555e2 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -40,9 +40,8 @@ class SummaryContainerName(Enum): - LOCAL = 'LOCAL' - REMOTE = 'REMOTE' - BOTH = 'BOTH' + UP = 'UP' + DOWN = 'DOWN' class Summary(object): @@ -125,40 +124,14 @@ def _render_containers(self, differences): ] else: self._has_differences = True - if extra_local_paths: - template = \ - 'There {} {} {} that {} locally but not on SherlockML' - text = template.format( - self._plural_verb('is', len(extra_local_paths)), - len(extra_local_paths), - self._plural('file', len(extra_local_paths)), - self._plural_verb('exists', len(extra_local_paths)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.LOCAL) - if extra_remote_paths: - template = 'There {} {} {} that {} only on SherlockML' - text = template.format( - self._plural_verb('is', len(extra_remote_paths)), - len(extra_remote_paths), - self._plural('file', len(extra_remote_paths)), - self._plural_verb('exists', len(extra_remote_paths)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.REMOTE) - if other_differences: - template = 'There {} {} {} that {} not synchronized' - text = template.format( - self._plural_verb('is', len(other_differences)), - len(other_differences), - self._plural('file', len(other_differences)), - self._plural_verb('is', len(other_differences)) - ) - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.BOTH) + text = 'Up' + container = Window(FormattedTextControl(text), height=1) + self._menu_containers.append(container) + self._menu_container_names.append(SummaryContainerName.UP) + text = 'Down' + container = Window(FormattedTextControl(text), height=1) + self._menu_containers.append(container) + self._menu_container_names.append(SummaryContainerName.DOWN) self._set_selection_index(0) @@ -178,28 +151,20 @@ def set_focus(self, new_focus): def _render(self): if self._focus is None: self.container.children = [] - elif self._focus == SummaryContainerName.LOCAL: + elif self._focus == SummaryContainerName.UP: file_objects = [ difference.left for difference in self._differences if difference.difference_type == DifferenceType.LEFT_ONLY ] self._render_differences(file_objects) - elif self._focus == SummaryContainerName.REMOTE: + elif self._focus == SummaryContainerName.DOWN: file_objects = [ difference.right for difference in self._differences if difference.difference_type == DifferenceType.RIGHT_ONLY ] self._render_differences(file_objects) - else: - file_objects = [ - difference.left - for difference in self._differences - if difference.difference_type in - {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} - ] - self._render_differences(file_objects) def _render_differences(self, file_objects): file_paths = [file_object.path for file_object in file_objects] From 3951b550ecce7788804b53c0df25cb484f739f8b Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 11:15:57 +0100 Subject: [PATCH 14/61] Render up/down details --- sml_sync/screens/diff.py | 57 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 03555e2..6d1ac2d 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -151,36 +151,35 @@ def set_focus(self, new_focus): def _render(self): if self._focus is None: self.container.children = [] - elif self._focus == SummaryContainerName.UP: - file_objects = [ - difference.left - for difference in self._differences - if difference.difference_type == DifferenceType.LEFT_ONLY - ] - self._render_differences(file_objects) - elif self._focus == SummaryContainerName.DOWN: - file_objects = [ - difference.right - for difference in self._differences - if difference.difference_type == DifferenceType.RIGHT_ONLY - ] - self._render_differences(file_objects) - - def _render_differences(self, file_objects): - file_paths = [file_object.path for file_object in file_objects] - file_sizes = [ - str(file_object.attrs.size) if file_object.is_file() else '' - for file_object in file_objects - ] - file_mtimes = [ - str(file_object.attrs.last_modified) - if file_object.is_file() else '' - for file_object in file_objects - ] + else: + self._render_differences(self._differences, self._focus.name) + + def _render_differences(self, differences, direction): + action_map = { + (DifferenceType.LEFT_ONLY, 'UP'): 'create', + (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create', + (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete', + (DifferenceType.RIGHT_ONLY, 'UP'): 'delete' + } + paths = [] + actions = [] + + for difference in differences: + if difference.difference_type == DifferenceType.LEFT_ONLY: + paths.append(difference.left.path) + elif difference.difference_type == DifferenceType.RIGHT_ONLY: + paths.append(difference.right.path) + else: + paths.append(difference.left.path) + + actions.append( + action_map.get( + (difference.difference_type, direction), 'replace') + ) + columns = [ - TableColumn(file_paths, 'PATH'), - TableColumn(file_sizes, 'SIZE'), - TableColumn(file_mtimes, 'LAST MODIFIED') + TableColumn(paths, 'PATH'), + TableColumn(actions, 'ACTION') ] self.container.children = [to_container(Table(columns))] From 80b7e8b1a0e21e855b015658bd86d47a0ee8d4d3 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 14:15:57 +0100 Subject: [PATCH 15/61] Ignore emacs dir-locals --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7699786..094b85e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .mypy_cache/ __pycache__ .pytest_cache/ +.dir-locals.el \ No newline at end of file From 361ac26b6927b905e5d4c79114eb2e619c1b0ba5 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 14:43:34 +0100 Subject: [PATCH 16/61] Render local and remote sizes --- sml_sync/screens/diff.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 6d1ac2d..9dc0fc1 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -154,6 +154,26 @@ def _render(self): else: self._render_differences(self._differences, self._focus.name) + def _render_local_mtime(self, difference): + if difference.left is not None and difference.left.is_file(): + return str(difference.left.attrs.last_modified) + return '' + + def _render_remote_mtime(self, difference): + if difference.right is not None and difference.right.is_file(): + return str(difference.right.attrs.last_modified) + return '' + + def _render_local_size(self, difference): + if difference.left is not None and difference.left.is_file(): + return str(difference.left.attrs.size) + return '' + + def _render_remote_size(self, difference): + if difference.right is not None and difference.right.is_file(): + return str(difference.right.attrs.size) + return '' + def _render_differences(self, differences, direction): action_map = { (DifferenceType.LEFT_ONLY, 'UP'): 'create', @@ -163,6 +183,10 @@ def _render_differences(self, differences, direction): } paths = [] actions = [] + local_mtimes = [] + remote_mtimes = [] + local_sizes = [] + remote_sizes = [] for difference in differences: if difference.difference_type == DifferenceType.LEFT_ONLY: @@ -172,6 +196,12 @@ def _render_differences(self, differences, direction): else: paths.append(difference.left.path) + local_mtimes.append(self._render_local_mtime(difference)) + remote_mtimes.append(self._render_remote_mtime(difference)) + + local_sizes.append(self._render_local_size(difference)) + remote_sizes.append(self._render_remote_size(difference)) + actions.append( action_map.get( (difference.difference_type, direction), 'replace') @@ -179,7 +209,11 @@ def _render_differences(self, differences, direction): columns = [ TableColumn(paths, 'PATH'), - TableColumn(actions, 'ACTION') + TableColumn(actions, 'ACTION'), + TableColumn(local_mtimes, 'LOCAL MTIME'), + TableColumn(remote_mtimes, 'REMOTE MTIME'), + TableColumn(local_sizes, 'LOCAL SIZE'), + TableColumn(remote_sizes, 'REMOTE SIZE'), ] self.container.children = [to_container(Table(columns))] From c37496d98c8bdfea5f5157cfadbd96b9fed5bdb0 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 14:55:29 +0100 Subject: [PATCH 17/61] Human friendly information for attributes --- sml_sync/screens/diff.py | 9 +++++---- sml_sync/screens/humanize.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 9dc0fc1..67f602e 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -12,6 +12,7 @@ from .base import BaseScreen from .help import help_modal from .components import Table, TableColumn +from .humanize import naturaltime, naturalsize HELP_TITLE = 'Differences between local directory and SherlockML' @@ -156,22 +157,22 @@ def _render(self): def _render_local_mtime(self, difference): if difference.left is not None and difference.left.is_file(): - return str(difference.left.attrs.last_modified) + return naturaltime(difference.left.attrs.last_modified) return '' def _render_remote_mtime(self, difference): if difference.right is not None and difference.right.is_file(): - return str(difference.right.attrs.last_modified) + return naturaltime(difference.right.attrs.last_modified) return '' def _render_local_size(self, difference): if difference.left is not None and difference.left.is_file(): - return str(difference.left.attrs.size) + return naturalsize(difference.left.attrs.size) return '' def _render_remote_size(self, difference): if difference.right is not None and difference.right.is_file(): - return str(difference.right.attrs.size) + return naturalsize(difference.right.attrs.size) return '' def _render_differences(self, differences, direction): diff --git a/sml_sync/screens/humanize.py b/sml_sync/screens/humanize.py index 0d69458..7bd43c9 100644 --- a/sml_sync/screens/humanize.py +++ b/sml_sync/screens/humanize.py @@ -123,3 +123,29 @@ def naturaltime(value, future=False, months=True): return "now" return ago % delta + + +suffixes = ('kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB') + + +def naturalsize(value, format='%.1f'): + """Format a number of byteslike a human readable filesize (eg. 10 kB). By + default, decimal suffixes (kB, MB) are used. Passing binary=true will use + binary suffixes (KiB, MiB) are used and the base will be 2**10 instead of + 10**3. If ``gnu`` is True, the binary argument is ignored and GNU-style + (ls -sh style) prefixes are used (K, M) with the 2**10 definition. + Non-gnu modes are compatible with jinja2's ``filesizeformat`` filter.""" + + base = 1024 + bytes = float(value) + + if bytes == 1: + return '1 Byte' + elif bytes < base: + return '%d Bytes' % bytes + + for i, s in enumerate(suffixes): + unit = base ** (i+2) + if bytes < unit: + return (format + ' %s') % ((base * bytes / unit), s) + return (format + ' %s') % ((base * bytes / unit), s) From c9fc6859155af2dd2f163bfb16d7a7928abf6a82 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 15:00:45 +0100 Subject: [PATCH 18/61] Clarify naturalsize docstring --- sml_sync/screens/humanize.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/sml_sync/screens/humanize.py b/sml_sync/screens/humanize.py index 7bd43c9..6cd5ebd 100644 --- a/sml_sync/screens/humanize.py +++ b/sml_sync/screens/humanize.py @@ -129,12 +129,7 @@ def naturaltime(value, future=False, months=True): def naturalsize(value, format='%.1f'): - """Format a number of byteslike a human readable filesize (eg. 10 kB). By - default, decimal suffixes (kB, MB) are used. Passing binary=true will use - binary suffixes (KiB, MiB) are used and the base will be 2**10 instead of - 10**3. If ``gnu`` is True, the binary argument is ignored and GNU-style - (ls -sh style) prefixes are used (K, M) with the 2**10 definition. - Non-gnu modes are compatible with jinja2's ``filesizeformat`` filter.""" + """Format a number of byteslike a human readable filesize (eg. 10 kB) """ base = 1024 bytes = float(value) From e265bfabf1021fcdd47cfb4be4d8758d008304f3 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 15:00:59 +0100 Subject: [PATCH 19/61] Improve copy of actions --- sml_sync/screens/diff.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 67f602e..abc23e3 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -177,10 +177,14 @@ def _render_remote_size(self, difference): def _render_differences(self, differences, direction): action_map = { - (DifferenceType.LEFT_ONLY, 'UP'): 'create', - (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create', - (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete', - (DifferenceType.RIGHT_ONLY, 'UP'): 'delete' + (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', + (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create local', + (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete local', + (DifferenceType.RIGHT_ONLY, 'UP'): 'delete remote', + (DifferenceType.TYPE_DIFFERENT, 'UP'): 'replace remote', + (DifferenceType.TYPE_DIFFERENT, 'DOWN'): 'replace local', + (DifferenceType.ATTRS_DIFFERENT, 'UP'): 'replace remote', + (DifferenceType.ATTRS_DIFFERENT, 'DOWN'): 'replace local' } paths = [] actions = [] From 4873326a41b9873f17cfeb5fd3008a7570c4cd19 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 27 May 2018 15:33:39 +0100 Subject: [PATCH 20/61] Remove unused default for calculating action --- sml_sync/screens/diff.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index abc23e3..e5c8180 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -207,10 +207,8 @@ def _render_differences(self, differences, direction): local_sizes.append(self._render_local_size(difference)) remote_sizes.append(self._render_remote_size(difference)) - actions.append( - action_map.get( - (difference.difference_type, direction), 'replace') - ) + action = action_map[(difference.difference_type, direction)] + actions.append(action) columns = [ TableColumn(paths, 'PATH'), From 346b907b6a6748154fc80dfc89bc194400c07bc3 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 08:05:12 +0100 Subject: [PATCH 21/61] Place summary container as sidebar --- sml_sync/screens/diff.py | 129 ++++++++++++------------------------- sml_sync/screens/styles.py | 20 ++++++ 2 files changed, 62 insertions(+), 87 deletions(-) create mode 100644 sml_sync/screens/styles.py diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index e5c8180..d27accd 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -1,7 +1,6 @@ from enum import Enum -import inflect from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import HSplit, VSplit, to_container from prompt_toolkit.layout.containers import FloatContainer, Window @@ -13,6 +12,7 @@ from .help import help_modal from .components import Table, TableColumn from .humanize import naturaltime, naturalsize +from . import styles HELP_TITLE = 'Differences between local directory and SherlockML' @@ -47,93 +47,47 @@ class SummaryContainerName(Enum): class Summary(object): - def __init__(self, differences): - self._differences = differences - self._has_differences = False - self._current_index = None - self._menu_containers = [] - self._menu_container_names = [] - self._margin_control = FormattedTextControl('') - self._margin = Window(self._margin_control, width=4) - - self._inflect = inflect.engine() - self._plural = self._inflect.plural - self._plural_verb = self._inflect.plural_verb - - self._render_containers(differences) + def __init__(self): + self._current_index = 0 + self._focus_names = [ + SummaryContainerName.UP, SummaryContainerName.DOWN] + self._menu_container = HSplit([]) + self.container = VSplit([ + Window(width=1), + HSplit([ + Window(height=1), + self._menu_container, + Window() + ]), + Window(width=4), + ]) + self._render() @property def current_focus(self): - if self._has_differences: - return self._menu_container_names[self._current_index] - else: - return None - - @property - def containers(self): - return [VSplit([self._margin, HSplit(self._menu_containers)])] + return self._focus_names[self._current_index] def focus_next(self): - if self._has_differences: - return self._set_selection_index(self._current_index + 1) + self._set_selection_index(self._current_index + 1) + return self.current_focus def focus_previous(self): - if self._has_differences: - return self._set_selection_index(self._current_index - 1) + self._set_selection_index(self._current_index - 1) + return self.current_focus def _set_selection_index(self, new_index): - if self._has_differences: - # Wrap around when selecting - self._current_index = new_index % len(self._menu_containers) - margin_lines = [] - for icontainer in range(len(self._menu_containers)): - margin_lines.append( - ' > ' if icontainer == self._current_index - else (' ' * 4) - ) - margin_text = '\n'.join(margin_lines) - self._margin_control.text = margin_text - return self._menu_container_names[self._current_index] - - def _render_containers(self, differences): - extra_local_paths = [ - difference.left.path for difference in differences - if difference.difference_type == DifferenceType.LEFT_ONLY - ] - extra_remote_paths = [ - difference.right.path for difference in differences - if difference.difference_type == DifferenceType.RIGHT_ONLY - ] - other_differences = [ - difference.left.path for difference in differences - if difference.difference_type in - {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} - ] - are_synchronized = ( - not extra_local_paths - and not extra_remote_paths - and not other_differences - ) - if are_synchronized: - self._has_differences = False - self._menu_containers = [ - Window( - FormattedTextControl( - 'Local directory and SherlockML are synchronized.' - ), - height=1) - ] - else: - self._has_differences = True - text = 'Up' - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.UP) - text = 'Down' - container = Window(FormattedTextControl(text), height=1) - self._menu_containers.append(container) - self._menu_container_names.append(SummaryContainerName.DOWN) - self._set_selection_index(0) + self._current_index = new_index % len(self._focus_names) + self._render() + + def _render(self): + menu_entries = ['Up', 'Down'] + windows = [] + for ientry, entry in enumerate(menu_entries): + style = 'reverse' if ientry == self._current_index else '' + control = FormattedTextControl(entry, style) + window = Window(control, height=1) + windows.append(window) + self._menu_container.children = windows class Details(object): @@ -234,7 +188,7 @@ def __init__(self, differences, exchange): '[?] Help ' '[q] Quit' ), height=2, style='reverse') - self._summary = Summary(differences) + self._summary = Summary() self._details = Details(differences, self._summary.current_focus) self.bindings = KeyBindings() @@ -272,13 +226,14 @@ def _(event): new_focus = self._summary.focus_previous() self._details.set_focus(new_focus) - self._screen_container = HSplit( - [Window(height=1)] + - self._summary.containers + - [Window(height=1), Window(char='-', height=1), Window(height=1)] + - [self._details.container] + - [self._bottom_toolbar] - ) + self._screen_container = HSplit([ + VSplit([ + self._summary.container, + Window(width=1, char=styles.get_vertical_border_char()), + self._details.container + ]), + self._bottom_toolbar + ]) self.main_container = FloatContainer( self._screen_container, floats=[] diff --git a/sml_sync/screens/styles.py b/sml_sync/screens/styles.py new file mode 100644 index 0000000..f2cfad4 --- /dev/null +++ b/sml_sync/screens/styles.py @@ -0,0 +1,20 @@ + +import sys + +from prompt_toolkit.application.current import get_app + + +def _try_char(character, backup, encoding=sys.stdout.encoding): + """ + Return `character` if it can be encoded using sys.stdout, else return the + backup character. + """ + if character.encode(encoding, 'replace') == b'?': + return backup + else: + return character + + +def get_vertical_border_char(): + " Return the character to be used for the vertical border. " + return _try_char('\u2502', '|', get_app().output.encoding()) From 980fe0f6745cc1f6341c599bc48d144e0cb083f7 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 10:42:27 +0100 Subject: [PATCH 22/61] Factor menu out into separate component --- sml_sync/screens/components/__init__.py | 1 + sml_sync/screens/components/vertical_menu.py | 69 +++++++++++++ sml_sync/screens/diff.py | 103 ++++++++++--------- 3 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 sml_sync/screens/components/vertical_menu.py diff --git a/sml_sync/screens/components/__init__.py b/sml_sync/screens/components/__init__.py index f93709a..fe5b8d1 100644 --- a/sml_sync/screens/components/__init__.py +++ b/sml_sync/screens/components/__init__.py @@ -1,2 +1,3 @@ from .table import Table, TableColumn # noqa +from .vertical_menu import MenuEntry, VerticalMenu # noqa diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py new file mode 100644 index 0000000..f6028c6 --- /dev/null +++ b/sml_sync/screens/components/vertical_menu.py @@ -0,0 +1,69 @@ +import logging +from collections import namedtuple + +from typing import List + +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Window, FormattedTextControl + +MenuEntry = namedtuple('MenuEntry', ['id_', 'text']) + + +class VerticalMenu(object): + + def __init__(self, entries: List[MenuEntry]): + self._current_index = 0 + self._entries = entries + self._control = FormattedTextControl( + '', focusable=True, show_cursor=False, + key_bindings=self._get_key_bindings()) + self._set_control_text() + self._window = Window(self._control) + self._menu_change_callbacks = [] + + @property + def current_selection(self): + return self._entries[self._current_index].id_ + + def register_menu_change_callback(self, callback): + self._menu_change_callbacks.append(callback) + + def _execute_callbacks(self, new_selection): + for callback in self._menu_change_callbacks: + callback(new_selection) + + def _select_next(self): + self._set_selection_index(self._current_index + 1) + + def _select_previous(self): + self._set_selection_index(self._current_index - 1) + + def _get_key_bindings(self): + bindings = KeyBindings() + + @bindings.add('up') # noqa: F811 + def _(event): + self._select_previous() + + @bindings.add('down') # noqa: F811 + def _(event): + self._select_next() + + return bindings + + def _set_selection_index(self, new_index): + new_index = new_index % len(self._entries) + if self._current_index != new_index: + self._current_index = new_index + self._set_control_text() + self._execute_callbacks(self.current_selection) + + def _set_control_text(self): + control_lines = [] + for ientry, entry in enumerate(self._entries): + style = 'reverse' if ientry == self._current_index else '' + control_lines.append((style, entry.text + '\n')) + self._control.text = control_lines + + def __pt_container__(self): + return self._window diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index d27accd..03c6396 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -10,7 +10,7 @@ from ..models import DifferenceType from .base import BaseScreen from .help import help_modal -from .components import Table, TableColumn +from .components import Table, TableColumn, VerticalMenu, MenuEntry from .humanize import naturaltime, naturalsize from . import styles @@ -40,18 +40,30 @@ """ -class SummaryContainerName(Enum): +class SelectionName(Enum): UP = 'UP' DOWN = 'DOWN' +class DiffScreenMessages(Enum): + """ + Messages used internally in the differences screen + """ + SELECTION_UPDATED = 'SELECTION_UPDATED' + + class Summary(object): - def __init__(self): + def __init__(self, exchange): + self._exchange = exchange self._current_index = 0 - self._focus_names = [ - SummaryContainerName.UP, SummaryContainerName.DOWN] - self._menu_container = HSplit([]) + self._menu_container = VerticalMenu([ + MenuEntry(SelectionName.UP, 'Up'), + MenuEntry(SelectionName.DOWN, 'Down') + ]) + self._menu_container.register_menu_change_callback( + lambda new_selection: self._on_new_selection(new_selection) + ) self.container = VSplit([ Window(width=1), HSplit([ @@ -61,53 +73,43 @@ def __init__(self): ]), Window(width=4), ]) - self._render() @property - def current_focus(self): - return self._focus_names[self._current_index] + def current_selection(self): + return self._menu_container.current_selection - def focus_next(self): - self._set_selection_index(self._current_index + 1) - return self.current_focus + def gain_focus(self, app): + app.layout.focus(self._menu_container) - def focus_previous(self): - self._set_selection_index(self._current_index - 1) - return self.current_focus - - def _set_selection_index(self, new_index): - self._current_index = new_index % len(self._focus_names) - self._render() - - def _render(self): - menu_entries = ['Up', 'Down'] - windows = [] - for ientry, entry in enumerate(menu_entries): - style = 'reverse' if ientry == self._current_index else '' - control = FormattedTextControl(entry, style) - window = Window(control, height=1) - windows.append(window) - self._menu_container.children = windows + def _on_new_selection(self, new_selection): + self._exchange.publish( + DiffScreenMessages.SELECTION_UPDATED, new_selection) class Details(object): - def __init__(self, differences, initial_focus): - self._focus = initial_focus + def __init__(self, exchange, differences, initial_selection): + self._selection = initial_selection self._differences = differences - self.container = VSplit([Window(FormattedTextControl('Loading...'))]) + self._table = None + self.container = VSplit([]) self._render() - def set_focus(self, new_focus): - self._focus = new_focus + exchange.subscribe( + DiffScreenMessages.SELECTION_UPDATED, + self._set_selection + ) + + def gain_focus(self, app): + app.layout.focus(self._table) + + def _set_selection(self, new_selection): + self._selection = new_selection self._render() def _render(self): - if self._focus is None: - self.container.children = [] - else: - self._render_differences(self._differences, self._focus.name) + self._render_differences(self._differences, self._selection.name) def _render_local_mtime(self, difference): if difference.left is not None and difference.left.is_file(): @@ -172,7 +174,8 @@ def _render_differences(self, differences, direction): TableColumn(local_sizes, 'LOCAL SIZE'), TableColumn(remote_sizes, 'REMOTE SIZE'), ] - self.container.children = [to_container(Table(columns))] + self._table = Table(columns) + self.container.children = [to_container(self._table)] class DifferencesScreen(BaseScreen): @@ -188,8 +191,9 @@ def __init__(self, differences, exchange): '[?] Help ' '[q] Quit' ), height=2, style='reverse') - self._summary = Summary() - self._details = Details(differences, self._summary.current_focus) + self._summary = Summary(exchange) + self._details = Details( + exchange, differences, self._summary.current_selection) self.bindings = KeyBindings() @self.bindings.add('d') # noqa: F811 @@ -212,19 +216,13 @@ def _(event): def _(event): self._toggle_help() - @self.bindings.add('tab') # noqa: F811 - @self.bindings.add('down') - @self.bindings.add('left') + @self.bindings.add('right') # noqa: F811 def _(event): - new_focus = self._summary.focus_next() - self._details.set_focus(new_focus) + self._details.gain_focus(event.app) - @self.bindings.add('s-tab') # noqa: F811 - @self.bindings.add('up') - @self.bindings.add('right') + @self.bindings.add('left') # noqa: F811 def _(event): - new_focus = self._summary.focus_previous() - self._details.set_focus(new_focus) + self._summary.gain_focus(event.app) self._screen_container = HSplit([ VSplit([ @@ -239,6 +237,9 @@ def _(event): floats=[] ) + def on_mount(self, app): + self._summary.gain_focus(app) + def _toggle_help(self): if self.main_container.floats: self.main_container.floats = [] From d9938496ba8db79e7a22b4a142f4df2c8c228f1c Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:18:23 +0100 Subject: [PATCH 23/61] Unused import --- sml_sync/screens/components/vertical_menu.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py index f6028c6..909ff1b 100644 --- a/sml_sync/screens/components/vertical_menu.py +++ b/sml_sync/screens/components/vertical_menu.py @@ -1,4 +1,3 @@ -import logging from collections import namedtuple from typing import List From a7cf013cc553360bef8bc23f9bced323856640ec Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:18:31 +0100 Subject: [PATCH 24/61] Initial tests for vertical menu --- .../components/tests/test_vertical_menu.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 sml_sync/screens/components/tests/test_vertical_menu.py diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py new file mode 100644 index 0000000..0976a45 --- /dev/null +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -0,0 +1,40 @@ + +from prompt_toolkit.layout import to_container +from prompt_toolkit.key_binding.key_processor import KeyProcessor, KeyPress + +from .. import VerticalMenu, MenuEntry + + +def test_simple_menu(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + menu_text = to_container(menu).content.text + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + +def test_change_entry(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + control = to_container(menu).content + key_bindings = control.key_bindings + key_processor = KeyProcessor(key_bindings) + key_processor.feed(KeyPress('up')) + key_processor.process_keys() + + menu_text = to_container(menu).content.text + + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('', 'menu entry 1\n') + assert menu_line2 == ('reverse', 'menu entry 2\n') + + assert menu.current_selection == 2 From f1ab1eef4e0eb2e28f9f12e521ba8c7d08c2ff37 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:21:07 +0100 Subject: [PATCH 25/61] Factor out common test fixtures --- .../components/tests/test_vertical_menu.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index 0976a45..333a33d 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -5,13 +5,28 @@ from .. import VerticalMenu, MenuEntry +def get_menu_text(menu): + """ Get the formatted text corresponding to the menu """ + control = to_container(menu).content + return control.text + + +def simulate_key(menu, key): + """ Simulate passing `key` to a menu """ + control = to_container(menu).content + key_bindings = control.key_bindings + key_processor = KeyProcessor(key_bindings) + key_processor.feed(KeyPress(key)) + key_processor.process_keys() + + def test_simple_menu(): entry1 = MenuEntry(1, 'menu entry 1') entry2 = MenuEntry(2, 'menu entry 2') menu = VerticalMenu([entry1, entry2]) - menu_text = to_container(menu).content.text + menu_text = get_menu_text(menu) assert len(menu_text) == 2 [menu_line1, menu_line2] = menu_text assert menu_line1 == ('reverse', 'menu entry 1\n') @@ -24,14 +39,9 @@ def test_change_entry(): menu = VerticalMenu([entry1, entry2]) - control = to_container(menu).content - key_bindings = control.key_bindings - key_processor = KeyProcessor(key_bindings) - key_processor.feed(KeyPress('up')) - key_processor.process_keys() - - menu_text = to_container(menu).content.text + simulate_key(menu, 'up') + menu_text = get_menu_text(menu) assert len(menu_text) == 2 [menu_line1, menu_line2] = menu_text assert menu_line1 == ('', 'menu entry 1\n') From e320b3be0e17956db14dbf96df8d3d87c143e18b Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:22:44 +0100 Subject: [PATCH 26/61] Test both up and down key behaviour --- .../components/tests/test_vertical_menu.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index 333a33d..5f2e57c 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -33,13 +33,13 @@ def test_simple_menu(): assert menu_line2 == ('', 'menu entry 2\n') -def test_change_entry(): +def test_key_down(): entry1 = MenuEntry(1, 'menu entry 1') entry2 = MenuEntry(2, 'menu entry 2') menu = VerticalMenu([entry1, entry2]) - simulate_key(menu, 'up') + simulate_key(menu, 'down') menu_text = get_menu_text(menu) assert len(menu_text) == 2 @@ -48,3 +48,21 @@ def test_change_entry(): assert menu_line2 == ('reverse', 'menu entry 2\n') assert menu.current_selection == 2 + + +def test_key_up(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + simulate_key(menu, 'down') + simulate_key(menu, 'up') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + assert menu.current_selection == 1 From 4447ed5ffd97b236c793c8e6113db06036814a55 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:24:13 +0100 Subject: [PATCH 27/61] Test that key numbering wraps round --- .../components/tests/test_vertical_menu.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index 5f2e57c..ac28533 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -66,3 +66,21 @@ def test_key_up(): assert menu_line2 == ('', 'menu entry 2\n') assert menu.current_selection == 1 + + +def test_wrap_keys(): + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + simulate_key(menu, 'down') + simulate_key(menu, 'down') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'menu entry 1\n') + assert menu_line2 == ('', 'menu entry 2\n') + + assert menu.current_selection == 1 From f869a26cc9fdaac8bf7fb07aaeeeb736a75e2b84 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:34:32 +0100 Subject: [PATCH 28/61] Verify that callbacks are called --- .../components/tests/test_vertical_menu.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index ac28533..7c00cfa 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -1,4 +1,6 @@ +from unittest.mock import Mock + from prompt_toolkit.layout import to_container from prompt_toolkit.key_binding.key_processor import KeyProcessor, KeyPress @@ -84,3 +86,18 @@ def test_wrap_keys(): assert menu_line2 == ('', 'menu entry 2\n') assert menu.current_selection == 1 + + +def test_callback_called(): + mock_callback = Mock() + + entry1 = MenuEntry(1, 'menu entry 1') + entry2 = MenuEntry(2, 'menu entry 2') + + menu = VerticalMenu([entry1, entry2]) + + menu.register_menu_change_callback(mock_callback) + + simulate_key(menu, 'down') + + mock_callback.assert_called_with(2) From bc04f3ee0c0808cfec6ace8fb25b3bb564ef307d Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:38:19 +0100 Subject: [PATCH 29/61] Handle menu with no entries --- .../components/tests/test_vertical_menu.py | 9 +++++++++ sml_sync/screens/components/vertical_menu.py | 17 +++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index 7c00cfa..efc1ba6 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -101,3 +101,12 @@ def test_callback_called(): simulate_key(menu, 'down') mock_callback.assert_called_with(2) + + +def test_zero_entries(): + menu = VerticalMenu([]) + menu_text = get_menu_text(menu) + assert len(menu_text) == 0 + assert menu.current_selection is None + simulate_key(menu, 'up') + assert menu.current_selection is None diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py index 909ff1b..97180d6 100644 --- a/sml_sync/screens/components/vertical_menu.py +++ b/sml_sync/screens/components/vertical_menu.py @@ -22,7 +22,11 @@ def __init__(self, entries: List[MenuEntry]): @property def current_selection(self): - return self._entries[self._current_index].id_ + if self._entries: + return self._entries[self._current_index].id_ + else: + # No items in the menu + return None def register_menu_change_callback(self, callback): self._menu_change_callbacks.append(callback) @@ -51,11 +55,12 @@ def _(event): return bindings def _set_selection_index(self, new_index): - new_index = new_index % len(self._entries) - if self._current_index != new_index: - self._current_index = new_index - self._set_control_text() - self._execute_callbacks(self.current_selection) + if self._entries: + new_index = new_index % len(self._entries) + if self._current_index != new_index: + self._current_index = new_index + self._set_control_text() + self._execute_callbacks(self.current_selection) def _set_control_text(self): control_lines = [] From a4f80f08514bec105322e23c6010dd6ef8617937 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 12:40:27 +0100 Subject: [PATCH 30/61] Test for menu with a single entry --- .../components/tests/test_vertical_menu.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index efc1ba6..c22f846 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -110,3 +110,19 @@ def test_zero_entries(): assert menu.current_selection is None simulate_key(menu, 'up') assert menu.current_selection is None + + +def test_single_entry(): + menu = VerticalMenu([MenuEntry('only', 'menu entry')]) + menu_text = get_menu_text(menu) + assert len(menu_text) == 1 + [menu_line1] = menu_text + assert menu_line1 == ('reverse', 'menu entry\n') + assert menu.current_selection == 'only' + simulate_key(menu, 'up') + + menu_text = get_menu_text(menu) + assert len(menu_text) == 1 + [menu_line1] = menu_text + assert menu_line1 == ('reverse', 'menu entry\n') + assert menu.current_selection == 'only' From 2d5054166ad7555166ac308fa2c13576914462c5 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 14:46:32 +0100 Subject: [PATCH 31/61] Add help text for up and down sync --- sml_sync/screens/diff.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 03c6396..0afe38d 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -39,6 +39,18 @@ [?] Toggle this message. """ +UP_SYNC_HELP_TEXT = """\ +Press [u] to modify the SherlockML workspace so that it mirrors your local disk. + +This will make the following changes to your SherlockML workspace: +""" + +DOWN_SYNC_HELP_TEXT = """\ +Press [d] to modify your local filesystem so that it mirrors the SherlockML workspace. + +This will make the following changes to your local disk: +""" + class SelectionName(Enum): UP = 'UP' @@ -92,7 +104,7 @@ def __init__(self, exchange, differences, initial_selection): self._selection = initial_selection self._differences = differences self._table = None - self.container = VSplit([]) + self.container = HSplit([]) self._render() @@ -131,7 +143,7 @@ def _render_remote_size(self, difference): return naturalsize(difference.right.attrs.size) return '' - def _render_differences(self, differences, direction): + def _render_table(self, differences, direction): action_map = { (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create local', @@ -174,8 +186,21 @@ def _render_differences(self, differences, direction): TableColumn(local_sizes, 'LOCAL SIZE'), TableColumn(remote_sizes, 'REMOTE SIZE'), ] - self._table = Table(columns) - self.container.children = [to_container(self._table)] + table = Table(columns) + return table + + def _render_differences(self, differences, direction): + self._table = self._render_table(differences, direction) + help_text = ( + UP_SYNC_HELP_TEXT if direction == 'UP' else DOWN_SYNC_HELP_TEXT + ) + help_box = Window(FormattedTextControl(help_text), height=3) + self.container.children = [ + Window(height=1), + help_box, + Window(height=1), + to_container(self._table), + ] class DifferencesScreen(BaseScreen): @@ -227,7 +252,9 @@ def _(event): self._screen_container = HSplit([ VSplit([ self._summary.container, + Window(width=1), Window(width=1, char=styles.get_vertical_border_char()), + Window(width=1), self._details.container ]), self._bottom_toolbar From 52bee8ed5c9f7c6dbe10cba782c82a581531981f Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 14:55:52 +0100 Subject: [PATCH 32/61] Make sure app rerenders after re-drawing details --- sml_sync/screens/diff.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 0afe38d..6f5d9fc 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -1,6 +1,7 @@ from enum import Enum +from prompt_toolkit.application.current import get_app from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import HSplit, VSplit, to_container from prompt_toolkit.layout.containers import FloatContainer, Window @@ -201,6 +202,7 @@ def _render_differences(self, differences, direction): Window(height=1), to_container(self._table), ] + get_app().invalidate() class DifferencesScreen(BaseScreen): From 52a6dd9698af56960e481b4c93cb0329923bea9f Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 15:07:43 +0100 Subject: [PATCH 33/61] Allow setting menu width --- sml_sync/screens/components/vertical_menu.py | 23 +++++++++++++++----- sml_sync/screens/diff.py | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py index 97180d6..2d6fa1e 100644 --- a/sml_sync/screens/components/vertical_menu.py +++ b/sml_sync/screens/components/vertical_menu.py @@ -1,6 +1,6 @@ from collections import namedtuple -from typing import List +from typing import List, Optional from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import Window, FormattedTextControl @@ -10,14 +10,20 @@ class VerticalMenu(object): - def __init__(self, entries: List[MenuEntry]): + def __init__(self, entries: List[MenuEntry], width: Optional[int]=None): self._current_index = 0 self._entries = entries + if width is None: + self._formatted_entries = [entry.text for entry in self._entries] + else: + self._formatted_entries = [ + _ensure_width(entry.text, width) for entry in self._entries + ] self._control = FormattedTextControl( '', focusable=True, show_cursor=False, key_bindings=self._get_key_bindings()) self._set_control_text() - self._window = Window(self._control) + self._window = Window(self._control, width=width) self._menu_change_callbacks = [] @property @@ -64,10 +70,17 @@ def _set_selection_index(self, new_index): def _set_control_text(self): control_lines = [] - for ientry, entry in enumerate(self._entries): + for ientry, entry in enumerate(self._formatted_entries): style = 'reverse' if ientry == self._current_index else '' - control_lines.append((style, entry.text + '\n')) + control_lines.append((style, entry + '\n')) self._control.text = control_lines def __pt_container__(self): return self._window + + +def _ensure_width(inp: str, width: int): + """ + Ensure that string `inp` is exactly `width` characters long. + """ + return inp[:width].ljust(width) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 6f5d9fc..b30c406 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -73,7 +73,7 @@ def __init__(self, exchange): self._menu_container = VerticalMenu([ MenuEntry(SelectionName.UP, 'Up'), MenuEntry(SelectionName.DOWN, 'Down') - ]) + ], width=10) self._menu_container.register_menu_change_callback( lambda new_selection: self._on_new_selection(new_selection) ) From 5c1566119a837c17381097290beb53733ecd4e87 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 15:15:56 +0100 Subject: [PATCH 34/61] Test passing explicit width to menu --- .../components/tests/test_vertical_menu.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index c22f846..5a5d736 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -126,3 +126,38 @@ def test_single_entry(): [menu_line1] = menu_text assert menu_line1 == ('reverse', 'menu entry\n') assert menu.current_selection == 'only' + + +def test_different_entry_widths(): + entry1 = MenuEntry(1, 'short') + entry2 = MenuEntry(2, 'very long entry') + + menu = VerticalMenu([entry1, entry2]) + menu_text = get_menu_text(menu) + + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'short\n') + assert menu_line2 == ('', 'very long entry\n') + + width = to_container(menu).preferred_width(100) + assert width.preferred == 15 + assert width.min == 0 + assert width.max > 100 + + +def test_fixed_width(): + entry1 = MenuEntry(1, 'short') + entry2 = MenuEntry(2, 'very long entry') + + menu = VerticalMenu([entry1, entry2], width=8) + menu_text = get_menu_text(menu) + + assert len(menu_text) == 2 + [menu_line1, menu_line2] = menu_text + assert menu_line1 == ('reverse', 'short \n') + assert menu_line2 == ('', 'very lon\n') + + width = to_container(menu).preferred_width(100) + assert width.preferred == 8 + assert width.min == width.max == 8 From 7b36d677db28a0b47a779f65b5180022c9347078 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 15:27:22 +0100 Subject: [PATCH 35/61] Add entry for entering `watch` mode --- sml_sync/screens/diff.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index b30c406..dcd9fa0 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -52,10 +52,16 @@ This will make the following changes to your local disk: """ +WATCH_HELP_TEXT = """\ +Press [w] to enter `watch` mode. Any time you save, move or delete a file, the change is automatically replicated on SherlockML. +""" + + class SelectionName(Enum): UP = 'UP' DOWN = 'DOWN' + WATCH = 'WATCH' class DiffScreenMessages(Enum): @@ -72,7 +78,8 @@ def __init__(self, exchange): self._current_index = 0 self._menu_container = VerticalMenu([ MenuEntry(SelectionName.UP, 'Up'), - MenuEntry(SelectionName.DOWN, 'Down') + MenuEntry(SelectionName.DOWN, 'Down'), + MenuEntry(SelectionName.WATCH, 'Watch'), ], width=10) self._menu_container.register_menu_change_callback( lambda new_selection: self._on_new_selection(new_selection) @@ -83,8 +90,7 @@ def __init__(self, exchange): Window(height=1), self._menu_container, Window() - ]), - Window(width=4), + ]) ]) @property @@ -122,7 +128,11 @@ def _set_selection(self, new_selection): self._render() def _render(self): - self._render_differences(self._differences, self._selection.name) + if self._selection in {SelectionName.UP, SelectionName.DOWN}: + self._render_differences(self._differences, self._selection.name) + else: + self._render_watch() + get_app().invalidate() def _render_local_mtime(self, difference): if difference.left is not None and difference.left.is_file(): @@ -144,6 +154,16 @@ def _render_remote_size(self, difference): return naturalsize(difference.right.attrs.size) return '' + def _render_watch(self): + help_text = WATCH_HELP_TEXT + help_box = Window(FormattedTextControl(help_text), height=3) + self.container.children = [ + Window(height=1), + help_box, + Window() + ] + + def _render_table(self, differences, direction): action_map = { (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', @@ -202,7 +222,6 @@ def _render_differences(self, differences, direction): Window(height=1), to_container(self._table), ] - get_app().invalidate() class DifferencesScreen(BaseScreen): From d8ad7df2ef733aea22cf82e76ff89736c6b3a008 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 15:56:26 +0100 Subject: [PATCH 36/61] Render help boxes using text area --- sml_sync/screens/diff.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index dcd9fa0..5ba9505 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -6,6 +6,7 @@ from prompt_toolkit.layout import HSplit, VSplit, to_container from prompt_toolkit.layout.containers import FloatContainer, Window from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.widgets import TextArea from ..pubsub import Messages from ..models import DifferenceType @@ -154,15 +155,14 @@ def _render_remote_size(self, difference): return naturalsize(difference.right.attrs.size) return '' - def _render_watch(self): - help_text = WATCH_HELP_TEXT - help_box = Window(FormattedTextControl(help_text), height=3) - self.container.children = [ - Window(height=1), - help_box, - Window() - ] + def _render_help_box(self, text): + text_area = TextArea( + text, focusable=False, read_only=True, dont_extend_height=True) + return to_container(text_area) + def _render_watch(self): + help_box = self._render_help_box(WATCH_HELP_TEXT) + self.container.children = [Window(height=1), help_box, Window()] def _render_table(self, differences, direction): action_map = { @@ -212,14 +212,12 @@ def _render_table(self, differences, direction): def _render_differences(self, differences, direction): self._table = self._render_table(differences, direction) - help_text = ( + help_box = self._render_help_box( UP_SYNC_HELP_TEXT if direction == 'UP' else DOWN_SYNC_HELP_TEXT ) - help_box = Window(FormattedTextControl(help_text), height=3) self.container.children = [ Window(height=1), help_box, - Window(height=1), to_container(self._table), ] From a503dfd21414051a6e4c3c33007aff61151f2297 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Mon, 28 May 2018 15:58:27 +0100 Subject: [PATCH 37/61] Remove extra empty line --- sml_sync/screens/diff.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 5ba9505..e231ad7 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -58,7 +58,6 @@ """ - class SelectionName(Enum): UP = 'UP' DOWN = 'DOWN' From 23eee43be7d6999da7865c2bbc96736859c29d76 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 29 May 2018 07:28:15 +0100 Subject: [PATCH 38/61] Only perform sync when in correct mode --- sml_sync/screens/diff.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index e231ad7..c09554e 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -241,11 +241,13 @@ def __init__(self, differences, exchange): @self.bindings.add('d') # noqa: F811 def _(event): - self._exchange.publish(Messages.SYNC_SHERLOCKML_TO_LOCAL) + if self._summary.current_selection == SelectionName.DOWN: + self._exchange.publish(Messages.SYNC_SHERLOCKML_TO_LOCAL) @self.bindings.add('u') # noqa: F811 def _(event): - self._exchange.publish(Messages.SYNC_LOCAL_TO_SHERLOCKML) + if self._summary.current_selection == SelectionName.UP: + self._exchange.publish(Messages.SYNC_LOCAL_TO_SHERLOCKML) @self.bindings.add('r') # noqa: F811 def _(event): @@ -253,7 +255,8 @@ def _(event): @self.bindings.add('w') # noqa: F811 def _(event): - self._exchange.publish(Messages.START_WATCH_SYNC) + if self._summary.current_selection == SelectionName.WATCH: + self._exchange.publish(Messages.START_WATCH_SYNC) @self.bindings.add('?') # noqa: F811 def _(event): From d7b74ecce4ffa02c39af5145bd81e568ddaa7681 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 29 May 2018 07:31:59 +0100 Subject: [PATCH 39/61] Handle fully synchronized case --- sml_sync/screens/diff.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index c09554e..e17ea80 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -57,6 +57,10 @@ Press [w] to enter `watch` mode. Any time you save, move or delete a file, the change is automatically replicated on SherlockML. """ +FULLY_SYNCHRONIZED_HELP_TEXT = """\ +Your local disk and the SherlockML workspace are fully synchronized. +""" + class SelectionName(Enum): UP = 'UP' @@ -210,15 +214,19 @@ def _render_table(self, differences, direction): return table def _render_differences(self, differences, direction): - self._table = self._render_table(differences, direction) - help_box = self._render_help_box( - UP_SYNC_HELP_TEXT if direction == 'UP' else DOWN_SYNC_HELP_TEXT - ) - self.container.children = [ - Window(height=1), - help_box, - to_container(self._table), - ] + if not differences: + help_box = self._render_help_box(FULLY_SYNCHRONIZED_HELP_TEXT) + self.container.children = [Window(height=1), help_box, Window()] + else: + self._table = self._render_table(differences, direction) + help_box = self._render_help_box( + UP_SYNC_HELP_TEXT if direction == 'UP' else DOWN_SYNC_HELP_TEXT + ) + self.container.children = [ + Window(height=1), + help_box, + to_container(self._table), + ] class DifferencesScreen(BaseScreen): From 0361d556f020e0f608fd931f02c3d66ca3249caa Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 29 May 2018 07:36:21 +0100 Subject: [PATCH 40/61] Allow passing custom separator to table --- sml_sync/screens/components/table.py | 8 +++++--- .../screens/components/tests/test_table.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 9899635..84cdf5d 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -13,10 +13,12 @@ class Table(object): - def __init__(self, columns: List[TableColumn]): + def __init__(self, columns: List[TableColumn], sep: str = ' '): if len(set(len(column.rows) for column in columns)) not in {0, 1}: raise ValueError('All columns must have the same number of rows.') + self._sep = sep + formatted_headers = [] formatted_columns = [] for column in columns: @@ -32,7 +34,7 @@ def __init__(self, columns: List[TableColumn]): itertools.zip_longest(*formatted_columns, fillvalue='') ) - rows_string = [' '.join(row) for row in rows] + rows_string = [sep.join(row) for row in rows] table_body = '\n'.join(rows_string) if rows: @@ -55,7 +57,7 @@ def __init__(self, columns: List[TableColumn]): def _header_windows(self, formatted_headers): if len(formatted_headers): header_control = FormattedTextControl( - ' '.join(formatted_headers)) + self._sep.join(formatted_headers)) header_windows = [Window(header_control, height=1)] else: header_windows = [Window(height=1, width=0)] diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index dd07788..b7f27b2 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -72,3 +72,22 @@ def test_no_columns(): assert table.preferred_width(100).preferred == 0 assert table.preferred_height(0, 100).preferred == 1 + + +def test_custom_separator(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='t1') + col2 = TableColumn(rows=['d', 'e', 'f'], header='t2') + + table = Table([col1, col2], sep=' | ') + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + assert header_window.content.text == 't1 | t2' + assert body_window.content.buffer.text == textwrap.dedent( + """\ + a | d + b | e + c | f """) # noqa: W291 (ignore trailing whitespace) + assert table.preferred_width(100).preferred == 7 + assert table.preferred_height(5, 100).preferred == 4 From d0eb9d030ae7c24cce6042b5aac917e9aed61f43 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 29 May 2018 07:43:01 +0100 Subject: [PATCH 41/61] Use two spaces in columns --- sml_sync/screens/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index e17ea80..bbe2f96 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -210,7 +210,7 @@ def _render_table(self, differences, direction): TableColumn(local_sizes, 'LOCAL SIZE'), TableColumn(remote_sizes, 'REMOTE SIZE'), ] - table = Table(columns) + table = Table(columns, sep=' ') return table def _render_differences(self, differences, direction): From 673a54a4407067c7c52e6335bc3a8febbc718b3c Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Tue, 29 May 2018 07:43:12 +0100 Subject: [PATCH 42/61] Use - to indicate absent table entry --- sml_sync/screens/diff.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index bbe2f96..17d1402 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -141,22 +141,22 @@ def _render(self): def _render_local_mtime(self, difference): if difference.left is not None and difference.left.is_file(): return naturaltime(difference.left.attrs.last_modified) - return '' + return '-' def _render_remote_mtime(self, difference): if difference.right is not None and difference.right.is_file(): return naturaltime(difference.right.attrs.last_modified) - return '' + return '-' def _render_local_size(self, difference): if difference.left is not None and difference.left.is_file(): return naturalsize(difference.left.attrs.size) - return '' + return '-' def _render_remote_size(self, difference): if difference.right is not None and difference.right.is_file(): return naturalsize(difference.right.attrs.size) - return '' + return '-' def _render_help_box(self, text): text_area = TextArea( From a6f9017f04dd36d8953eaa559b582597c69b54ae Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Wed, 30 May 2018 07:50:12 +0100 Subject: [PATCH 43/61] Ability to specify how columns should be aligned in table --- sml_sync/screens/components/__init__.py | 2 +- sml_sync/screens/components/table.py | 37 +++++++++++++++++-- .../screens/components/tests/test_table.py | 27 +++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/sml_sync/screens/components/__init__.py b/sml_sync/screens/components/__init__.py index fe5b8d1..39c08d1 100644 --- a/sml_sync/screens/components/__init__.py +++ b/sml_sync/screens/components/__init__.py @@ -1,3 +1,3 @@ -from .table import Table, TableColumn # noqa +from .table import Table, TableColumn, ColumnSettings, Alignment # noqa from .vertical_menu import MenuEntry, VerticalMenu # noqa diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 84cdf5d..13db526 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -1,6 +1,7 @@ from collections import namedtuple import itertools -from typing import List +from enum import Enum +from typing import List, Optional from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document @@ -8,7 +9,28 @@ Window, HSplit, FormattedTextControl, BufferControl, ScrollbarMargin) -TableColumn = namedtuple('Column', ['rows', 'header']) +class Alignment(Enum): + RIGHT = 'RIGHT' + LEFT = 'LEFT' + + +class ColumnSettings(object): + + def __init__(self, alignment=Alignment.LEFT): + self.alignment = alignment + + +class TableColumn( + namedtuple('Column', ['rows', 'header', 'settings'])): + + def __new__( + cls, rows: List[str], + header: str, + settings: Optional[ColumnSettings] = None + ): + if settings is None: + settings = ColumnSettings() + return super(TableColumn, cls).__new__(cls, rows, header, settings) class Table(object): @@ -26,7 +48,10 @@ def __init__(self, columns: List[TableColumn], sep: str = ' '): len(column.header), max((len(row) for row in column.rows), default=0) ) - formatted_rows = [row.ljust(width, ' ') for row in column.rows] + formatted_rows = [ + self._format_cell(row, column.settings, width) + for row in column.rows + ] formatted_headers.append(column.header.ljust(width, ' ')) formatted_columns.append(formatted_rows) @@ -54,6 +79,12 @@ def __init__(self, columns: List[TableColumn], sep: str = ' '): self._header_windows(formatted_headers) + body_windows ) + def _format_cell(self, content, column_settings, width): + if column_settings.alignment == Alignment.LEFT: + return content.ljust(width) + else: + return content.rjust(width) + def _header_windows(self, formatted_headers): if len(formatted_headers): header_control = FormattedTextControl( diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index b7f27b2..b58eb02 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -2,7 +2,7 @@ import pytest -from .. import Table, TableColumn +from .. import Table, TableColumn, ColumnSettings, Alignment def test_simple_table(): @@ -91,3 +91,28 @@ def test_custom_separator(): c | f """) # noqa: W291 (ignore trailing whitespace) assert table.preferred_width(100).preferred == 7 assert table.preferred_height(5, 100).preferred == 4 + + +def test_right_align(): + col1 = TableColumn(rows=['a', 'b', 'c'], header='header1') + col2 = TableColumn( + rows=['d', 'e', 'f'], + header='header2', + settings=ColumnSettings(alignment=Alignment.RIGHT) + ) + + table = Table([col1, col2], sep='|') + + assert len(table.window.children) == 2 + [header_window, body_window] = table.window.children + + retrieved_table = ( + header_window.content.text + '\n' + + body_window.content.buffer.text + ) + assert retrieved_table == textwrap.dedent( + """\ + header1|header2 + a | d + b | e + c | f""") # noqa: W291 (ignore trailing whitespace) From 8bede9aa7a29baf510bc0b23f6ff0132b2d7e5d4 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Wed, 30 May 2018 07:53:25 +0100 Subject: [PATCH 44/61] Right align modification time and date columns --- sml_sync/screens/diff.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 17d1402..0fbde7b 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -12,7 +12,8 @@ from ..models import DifferenceType from .base import BaseScreen from .help import help_modal -from .components import Table, TableColumn, VerticalMenu, MenuEntry +from .components import ( + Table, TableColumn, VerticalMenu, MenuEntry, ColumnSettings, Alignment) from .humanize import naturaltime, naturalsize from . import styles @@ -205,10 +206,22 @@ def _render_table(self, differences, direction): columns = [ TableColumn(paths, 'PATH'), TableColumn(actions, 'ACTION'), - TableColumn(local_mtimes, 'LOCAL MTIME'), - TableColumn(remote_mtimes, 'REMOTE MTIME'), - TableColumn(local_sizes, 'LOCAL SIZE'), - TableColumn(remote_sizes, 'REMOTE SIZE'), + TableColumn( + local_mtimes, + 'LOCAL MTIME', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + remote_mtimes, + 'REMOTE MTIME', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + local_sizes, + 'LOCAL SIZE', + ColumnSettings(alignment=Alignment.RIGHT)), + TableColumn( + remote_sizes, + 'REMOTE SIZE', + ColumnSettings(alignment=Alignment.RIGHT)) ] table = Table(columns, sep=' ') return table From cb3962a02004fa4583a24d9055f3c5afb4aecc83 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Fri, 1 Jun 2018 12:09:52 +0100 Subject: [PATCH 45/61] Unsubscribe from exchange when stopping diff screen --- sml_sync/screens/diff.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 0fbde7b..fe6250e 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -1,4 +1,5 @@ +import logging from enum import Enum from prompt_toolkit.application.current import get_app @@ -115,12 +116,13 @@ class Details(object): def __init__(self, exchange, differences, initial_selection): self._selection = initial_selection self._differences = differences + self._exchange = exchange self._table = None self.container = HSplit([]) self._render() - exchange.subscribe( + self._subscription_id = self._exchange.subscribe( DiffScreenMessages.SELECTION_UPDATED, self._set_selection ) @@ -128,6 +130,15 @@ def __init__(self, exchange, differences, initial_selection): def gain_focus(self, app): app.layout.focus(self._table) + def stop(self): + try: + self._exchange.unsubscribe(self._subscription_id) + except AttributeError: + logging.warning( + 'Tried to unsubscribe from exchange before ' + 'subscription was activated.' + ) + def _set_selection(self, new_selection): self._selection = new_selection self._render() @@ -309,6 +320,9 @@ def _(event): def on_mount(self, app): self._summary.gain_focus(app) + def stop(self): + self._details.stop() + def _toggle_help(self): if self.main_container.floats: self.main_container.floats = [] From 2d2f2b3421f3d5a1a177d73dfd45d2e9fa5fb01e Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Fri, 1 Jun 2018 12:20:21 +0100 Subject: [PATCH 46/61] Sort entries in action table --- sml_sync/screens/diff.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index fe6250e..7788a0a 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -43,6 +43,17 @@ [?] Toggle this message. """ +ACTION_TEXT = { + (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', + (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create local', + (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete local', + (DifferenceType.RIGHT_ONLY, 'UP'): 'delete remote', + (DifferenceType.TYPE_DIFFERENT, 'UP'): 'replace remote', + (DifferenceType.TYPE_DIFFERENT, 'DOWN'): 'replace local', + (DifferenceType.ATTRS_DIFFERENT, 'UP'): 'replace remote', + (DifferenceType.ATTRS_DIFFERENT, 'DOWN'): 'replace local' +} + UP_SYNC_HELP_TEXT = """\ Press [u] to modify the SherlockML workspace so that it mirrors your local disk. @@ -180,16 +191,17 @@ def _render_watch(self): self.container.children = [Window(height=1), help_box, Window()] def _render_table(self, differences, direction): - action_map = { - (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', - (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create local', - (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete local', - (DifferenceType.RIGHT_ONLY, 'UP'): 'delete remote', - (DifferenceType.TYPE_DIFFERENT, 'UP'): 'replace remote', - (DifferenceType.TYPE_DIFFERENT, 'DOWN'): 'replace local', - (DifferenceType.ATTRS_DIFFERENT, 'UP'): 'replace remote', - (DifferenceType.ATTRS_DIFFERENT, 'DOWN'): 'replace local' - } + def sort_key(difference): + text = ACTION_TEXT[(difference.difference_type, direction)] + if 'delete' in text: + return 0 + elif 'replace' in text: + return 1 + else: + return 2 + + sorted_differences = sorted(differences, key=sort_key) + paths = [] actions = [] local_mtimes = [] @@ -197,7 +209,7 @@ def _render_table(self, differences, direction): local_sizes = [] remote_sizes = [] - for difference in differences: + for difference in sorted_differences: if difference.difference_type == DifferenceType.LEFT_ONLY: paths.append(difference.left.path) elif difference.difference_type == DifferenceType.RIGHT_ONLY: @@ -211,7 +223,7 @@ def _render_table(self, differences, direction): local_sizes.append(self._render_local_size(difference)) remote_sizes.append(self._render_remote_size(difference)) - action = action_map[(difference.difference_type, direction)] + action = ACTION_TEXT[(difference.difference_type, direction)] actions.append(action) columns = [ From 95b074f0715846140628eac62099ecd3eeebfbe3 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Fri, 1 Jun 2018 12:48:23 +0100 Subject: [PATCH 47/61] Order files by size after action --- sml_sync/screens/diff.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 7788a0a..e18e545 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -190,15 +190,38 @@ def _render_watch(self): help_box = self._render_help_box(WATCH_HELP_TEXT) self.container.children = [Window(height=1), help_box, Window()] + def _size_transferred(self, difference, direction): + if ( + difference.difference_type == DifferenceType.LEFT_ONLY + and difference.left.is_file() + ): + return difference.left.attrs.size + elif ( + difference.difference_type == DifferenceType.RIGHT_ONLY + and difference.right.is_file() + ): + return difference.right.attrs.size + elif ( + difference.difference_type in + {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} + ): + if direction == 'UP' and difference.left.is_file(): + return difference.left.attrs.size + elif direction == 'DOWN' and difference.right.is_file(): + return difference.right.attrs.size + return 0 + return 0 + def _render_table(self, differences, direction): def sort_key(difference): text = ACTION_TEXT[(difference.difference_type, direction)] + size = self._size_transferred(difference, direction) if 'delete' in text: - return 0 + return (0, -size) elif 'replace' in text: - return 1 + return (1, -size) else: - return 2 + return (2, -size) sorted_differences = sorted(differences, key=sort_key) From 27257e4a7cafdba12649327232acbdd17f32394f Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Fri, 1 Jun 2018 12:58:52 +0100 Subject: [PATCH 48/61] Use VerticalLine widget instead of custom --- sml_sync/screens/diff.py | 5 ++--- sml_sync/screens/styles.py | 20 -------------------- 2 files changed, 2 insertions(+), 23 deletions(-) delete mode 100644 sml_sync/screens/styles.py diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index e18e545..df0a0ef 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -7,7 +7,7 @@ from prompt_toolkit.layout import HSplit, VSplit, to_container from prompt_toolkit.layout.containers import FloatContainer, Window from prompt_toolkit.layout.controls import FormattedTextControl -from prompt_toolkit.widgets import TextArea +from prompt_toolkit.widgets import TextArea, VerticalLine from ..pubsub import Messages from ..models import DifferenceType @@ -16,7 +16,6 @@ from .components import ( Table, TableColumn, VerticalMenu, MenuEntry, ColumnSettings, Alignment) from .humanize import naturaltime, naturalsize -from . import styles HELP_TITLE = 'Differences between local directory and SherlockML' @@ -341,7 +340,7 @@ def _(event): VSplit([ self._summary.container, Window(width=1), - Window(width=1, char=styles.get_vertical_border_char()), + VerticalLine(), Window(width=1), self._details.container ]), diff --git a/sml_sync/screens/styles.py b/sml_sync/screens/styles.py deleted file mode 100644 index f2cfad4..0000000 --- a/sml_sync/screens/styles.py +++ /dev/null @@ -1,20 +0,0 @@ - -import sys - -from prompt_toolkit.application.current import get_app - - -def _try_char(character, backup, encoding=sys.stdout.encoding): - """ - Return `character` if it can be encoded using sys.stdout, else return the - backup character. - """ - if character.encode(encoding, 'replace') == b'?': - return backup - else: - return character - - -def get_vertical_border_char(): - " Return the character to be used for the vertical border. " - return _try_char('\u2502', '|', get_app().output.encoding()) From a86669b876287eccbaf84ad1395581fb864c2b80 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Fri, 1 Jun 2018 13:10:33 +0100 Subject: [PATCH 49/61] Details screen shouldn't gain focus in WATCH state --- sml_sync/screens/diff.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index df0a0ef..ae8baa9 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -330,7 +330,8 @@ def _(event): @self.bindings.add('right') # noqa: F811 def _(event): - self._details.gain_focus(event.app) + if self._summary.current_selection != SelectionName.WATCH: + self._details.gain_focus(event.app) @self.bindings.add('left') # noqa: F811 def _(event): From 2b881a4d30454d30197f53b15c332ae602d1a3b6 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 06:53:11 +0100 Subject: [PATCH 50/61] Replace hard-coded strings with enum values --- sml_sync/screens/diff.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index ae8baa9..d38917b 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -42,16 +42,6 @@ [?] Toggle this message. """ -ACTION_TEXT = { - (DifferenceType.LEFT_ONLY, 'UP'): 'create remote', - (DifferenceType.RIGHT_ONLY, 'DOWN'): 'create local', - (DifferenceType.LEFT_ONLY, 'DOWN'): 'delete local', - (DifferenceType.RIGHT_ONLY, 'UP'): 'delete remote', - (DifferenceType.TYPE_DIFFERENT, 'UP'): 'replace remote', - (DifferenceType.TYPE_DIFFERENT, 'DOWN'): 'replace local', - (DifferenceType.ATTRS_DIFFERENT, 'UP'): 'replace remote', - (DifferenceType.ATTRS_DIFFERENT, 'DOWN'): 'replace local' -} UP_SYNC_HELP_TEXT = """\ Press [u] to modify the SherlockML workspace so that it mirrors your local disk. @@ -87,6 +77,18 @@ class DiffScreenMessages(Enum): SELECTION_UPDATED = 'SELECTION_UPDATED' +ACTION_TEXT = { + (DifferenceType.LEFT_ONLY, SelectionName.UP): 'create remote', + (DifferenceType.RIGHT_ONLY, SelectionName.DOWN): 'create local', + (DifferenceType.LEFT_ONLY, SelectionName.DOWN): 'delete local', + (DifferenceType.RIGHT_ONLY, SelectionName.UP): 'delete remote', + (DifferenceType.TYPE_DIFFERENT, SelectionName.UP): 'replace remote', + (DifferenceType.TYPE_DIFFERENT, SelectionName.DOWN): 'replace local', + (DifferenceType.ATTRS_DIFFERENT, SelectionName.UP): 'replace remote', + (DifferenceType.ATTRS_DIFFERENT, SelectionName.DOWN): 'replace local' +} + + class Summary(object): def __init__(self, exchange): @@ -155,7 +157,7 @@ def _set_selection(self, new_selection): def _render(self): if self._selection in {SelectionName.UP, SelectionName.DOWN}: - self._render_differences(self._differences, self._selection.name) + self._render_differences(self._differences, self._selection) else: self._render_watch() get_app().invalidate() @@ -204,9 +206,9 @@ def _size_transferred(self, difference, direction): difference.difference_type in {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} ): - if direction == 'UP' and difference.left.is_file(): + if direction == SelectionName.UP and difference.left.is_file(): return difference.left.attrs.size - elif direction == 'DOWN' and difference.right.is_file(): + elif direction == SelectionName.DOWN and difference.right.is_file(): return difference.right.attrs.size return 0 return 0 @@ -278,7 +280,7 @@ def _render_differences(self, differences, direction): else: self._table = self._render_table(differences, direction) help_box = self._render_help_box( - UP_SYNC_HELP_TEXT if direction == 'UP' else DOWN_SYNC_HELP_TEXT + UP_SYNC_HELP_TEXT if direction == SelectionName.UP else DOWN_SYNC_HELP_TEXT ) self.container.children = [ Window(height=1), From 586d1ea4b2cd8595b7411351eaf2244fefd318be Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 06:57:58 +0100 Subject: [PATCH 51/61] Docstring explaining sort order --- sml_sync/screens/diff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index d38917b..29af6ec 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -215,6 +215,7 @@ def _size_transferred(self, difference, direction): def _render_table(self, differences, direction): def sort_key(difference): + """ Order first by action, then by size """ text = ACTION_TEXT[(difference.difference_type, direction)] size = self._size_transferred(difference, direction) if 'delete' in text: From 618a2adf4736587038e7c36452d1093e750790db Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 07:12:51 +0100 Subject: [PATCH 52/61] Refactor table component into smaller private methods --- sml_sync/screens/components/table.py | 52 +++++++++++++++------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 13db526..cf790e5 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -44,10 +44,7 @@ def __init__(self, columns: List[TableColumn], sep: str = ' '): formatted_headers = [] formatted_columns = [] for column in columns: - width = max( - len(column.header), - max((len(row) for row in column.rows), default=0) - ) + width = self._get_column_width(column) formatted_rows = [ self._format_cell(row, column.settings, width) for row in column.rows @@ -55,29 +52,17 @@ def __init__(self, columns: List[TableColumn], sep: str = ' '): formatted_headers.append(column.header.ljust(width, ' ')) formatted_columns.append(formatted_rows) - rows = list( - itertools.zip_longest(*formatted_columns, fillvalue='') + self.window = HSplit( + self._header_windows(formatted_headers) + + self._body_windows(formatted_columns) ) - rows_string = [sep.join(row) for row in rows] - table_body = '\n'.join(rows_string) - - if rows: - document = Document(table_body, 0) - _buffer = Buffer(document=document, read_only=True) - self._body_control = BufferControl(_buffer) - body_windows = [ - Window( - self._body_control, - right_margins=[ScrollbarMargin(display_arrows=True)] - ) - ] - else: - body_windows = [] - - self.window = HSplit( - self._header_windows(formatted_headers) + body_windows + def _get_column_width(self, column): + width = max( + len(column.header), + max((len(row) for row in column.rows), default=0) ) + return width def _format_cell(self, content, column_settings, width): if column_settings.alignment == Alignment.LEFT: @@ -94,6 +79,25 @@ def _header_windows(self, formatted_headers): header_windows = [Window(height=1, width=0)] return header_windows + def _body_windows(self, formatted_columns): + rows = list(itertools.zip_longest(*formatted_columns, fillvalue='')) + if rows: + rows_string = [self._sep.join(row) for row in rows] + table_body = '\n'.join(rows_string) + + document = Document(table_body, 0) + _buffer = Buffer(document=document, read_only=True) + self._body_control = BufferControl(_buffer) + body_windows = [ + Window( + self._body_control, + right_margins=[ScrollbarMargin(display_arrows=True)] + ) + ] + else: + body_windows = [] + return body_windows + def preferred_width(self, max_available_width): return self.window.preferred_width(max_available_width) From 67b90370df6db6199b1daba0beaadbed9940bcf3 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 07:18:29 +0100 Subject: [PATCH 53/61] Update help text for new layout --- sml_sync/screens/diff.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 29af6ec..f32ac6e 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -20,24 +20,24 @@ HELP_TITLE = 'Differences between local directory and SherlockML' HELP_TEXT = """\ -This is a summary of the differences between SherlockML and -your local directory. It summarizes the files that exist -only on SherlockML, the files that exist on locally and the -files that are on both, but with different modification times. +Synchronize your local filesystem and the SherlockML filesystem. + +Three synchronization modes are supported: + +'Up' will push all local changes to SherlockML. This will +erase any file that is on SherlockML, but not available locally. + +'Down' will bring all the changes down from SherlockML. This +will erase any file that is present locally but not on SherlockML. + +'Watch' enters `watch` mode. Any time you save, move or delete +a file or a directory, the change is automatically replicated +on SherlockML. Keys: - [u] Push all the local changes to SherlockML. This will - erase any file that is on SherlockML, but not available - locally. - [d] Bring all the changes down from SherlockML. This will - erase any file that is present locally but not on - SherlockML. [r] Refresh differences between the local file system and SherlockML. - [w] Enter incremental synchronization mode, where changes - to the local file system are automatically replicated - on SherlockML. [q] Quit the application. [?] Toggle this message. """ From 4e44e18c9fc9e33943d1f127d0ef52273705dc35 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 07:19:42 +0100 Subject: [PATCH 54/61] Update toolbar text --- sml_sync/screens/diff.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index f32ac6e..4cefcf0 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -296,13 +296,10 @@ def __init__(self, differences, exchange): super().__init__() self._exchange = exchange self._bottom_toolbar = Window(FormattedTextControl( - '[d] Sync SherlockML files down ' - '[u] Sync local files up ' '[r] Refresh ' - '[w] Incremental sync from local changes\n' '[?] Help ' '[q] Quit' - ), height=2, style='reverse') + ), height=1, style='reverse') self._summary = Summary(exchange) self._details = Details( exchange, differences, self._summary.current_selection) From a12afefc3cfc4fb7ffe1256adef4ad9b7479e471 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 07:19:53 +0100 Subject: [PATCH 55/61] Avoid long lines --- sml_sync/screens/diff.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 4cefcf0..432a4e3 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -206,9 +206,15 @@ def _size_transferred(self, difference, direction): difference.difference_type in {DifferenceType.TYPE_DIFFERENT, DifferenceType.ATTRS_DIFFERENT} ): - if direction == SelectionName.UP and difference.left.is_file(): + if ( + direction == SelectionName.UP + and difference.left.is_file() + ): return difference.left.attrs.size - elif direction == SelectionName.DOWN and difference.right.is_file(): + elif ( + direction == SelectionName.DOWN + and difference.right.is_file() + ): return difference.right.attrs.size return 0 return 0 @@ -281,7 +287,9 @@ def _render_differences(self, differences, direction): else: self._table = self._render_table(differences, direction) help_box = self._render_help_box( - UP_SYNC_HELP_TEXT if direction == SelectionName.UP else DOWN_SYNC_HELP_TEXT + UP_SYNC_HELP_TEXT + if direction == SelectionName.UP else + DOWN_SYNC_HELP_TEXT ) self.container.children = [ Window(height=1), From b3c2e83c3570f463869a9dccfa19ecbe81f2d471 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 07:25:36 +0100 Subject: [PATCH 56/61] Remove dependency on inflect --- requirements.txt | 1 - setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6aa072e..d34d957 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ sml daiquiri paramiko -inflect watchdog semantic_version git+https://github.com/jonathanslenders/python-prompt-toolkit@2.0 diff --git a/setup.py b/setup.py index 67dd637..b48d4d8 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,6 @@ 'sml', 'daiquiri', 'paramiko', - 'inflect', 'watchdog', 'semantic_version', 'prompt_toolkit>=2.0' From 5a1747b78334f3cde224ecb625bdb2003fbe54e1 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 08:35:22 +0100 Subject: [PATCH 57/61] Add missing package in setup.py --- setup.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b48d4d8..f50d0f6 100644 --- a/setup.py +++ b/setup.py @@ -7,12 +7,19 @@ with open(os.path.join(here, 'sml_sync', 'version.py')) as f: exec(f.read(), {}, version_ns) +packages = [ + 'sml_sync', + 'sml_sync.screens', + 'sml_sync.cli', + 'sml_sync.screens.components' +] + setup( name='sml_sync', version=version_ns['version'], description='SherlockML file synchronizer', author='ASI Data Science', - packages=['sml_sync', 'sml_sync.screens', 'sml_sync.cli'], + packages=packages, entry_points={ 'console_scripts': ['sml-sync=sml_sync:run'] }, From 6db950441725f6b82600d09b756daf6154f2cbbf Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sat, 2 Jun 2018 08:36:04 +0100 Subject: [PATCH 58/61] Release 0.3.0-alpha2. --- sml_sync/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sml_sync/version.py b/sml_sync/version.py index a9f2e00..7205d67 100644 --- a/sml_sync/version.py +++ b/sml_sync/version.py @@ -1,3 +1,3 @@ -__version__ = '0.2.4' +__version__ = '0.3.0-alpha2' version = __version__ From f6378ff4498baa8f522f74f24f314152f9a2b1e2 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 3 Jun 2018 10:02:46 +0100 Subject: [PATCH 59/61] Initial commit for mypy checks Running mypy in project root now passes. Many of the dependencies on external libraries remain ignored. --- mypy.ini | 28 +++++ sml_sync/screens/choose_remote_dir.py | 6 +- sml_sync/screens/components/table.py | 13 ++- .../screens/components/tests/test_table.py | 1 + .../components/tests/test_vertical_menu.py | 1 - sml_sync/screens/components/vertical_menu.py | 19 ++- sml_sync/screens/diff.py | 9 +- sml_sync/screens/help.py | 4 +- sml_sync/screens/sync.py | 6 +- sml_sync/screens/walking_trees.py | 6 +- sml_sync/screens/watch_sync.py | 8 +- sml_sync/ui.py | 4 +- stubs/__init__.pyi | 0 stubs/prompt_toolkit/__init__.pyi | 0 stubs/prompt_toolkit/application.pyi | 6 + stubs/prompt_toolkit/buffer.pyi | 13 +++ stubs/prompt_toolkit/document.pyi | 10 ++ stubs/prompt_toolkit/key_binding.pyi | 13 +++ stubs/prompt_toolkit/layout.pyi | 110 ++++++++++++++++++ stubs/prompt_toolkit/widgets.pyi | 16 +++ 20 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 mypy.ini create mode 100644 stubs/__init__.pyi create mode 100644 stubs/prompt_toolkit/__init__.pyi create mode 100644 stubs/prompt_toolkit/application.pyi create mode 100644 stubs/prompt_toolkit/buffer.pyi create mode 100644 stubs/prompt_toolkit/document.pyi create mode 100644 stubs/prompt_toolkit/key_binding.pyi create mode 100644 stubs/prompt_toolkit/layout.pyi create mode 100644 stubs/prompt_toolkit/widgets.pyi diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..2c45a30 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,28 @@ +[mypy] +mypy_path = stubs + +[mypy-sml_sync.screens.components.tests.*] +# Don't typecheck tests yet +ignore_errors = True + +# Dependencies that don't have stubs yet +[mypy-sml.*] +ignore_missing_imports = True + +[mypy-watchdog.*] +ignore_missing_imports = True + +[mypy-semantic-versions.*] +ignore_missing_imports = True + +[mypy-daiquiri] +ignore_missing_imports = True + +[mypy-semantic_version] +ignore_missing_imports = True + +[mypy-pytest] +ignore_missing_imports = True + +[mypy-paramiko] +ignore_missing_imports = True \ No newline at end of file diff --git a/sml_sync/screens/choose_remote_dir.py b/sml_sync/screens/choose_remote_dir.py index a3c1835..2575aa2 100644 --- a/sml_sync/screens/choose_remote_dir.py +++ b/sml_sync/screens/choose_remote_dir.py @@ -7,11 +7,9 @@ from enum import Enum from queue import Empty, Queue -from prompt_toolkit.application.current import get_app +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, VSplit, Window, FormattedTextControl from prompt_toolkit.widgets import TextArea from ..pubsub import Messages diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index cf790e5..a220dc6 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -16,7 +16,7 @@ class Alignment(Enum): class ColumnSettings(object): - def __init__(self, alignment=Alignment.LEFT): + def __init__(self, alignment: Alignment = Alignment.LEFT) -> None: self.alignment = alignment @@ -35,7 +35,7 @@ def __new__( class Table(object): - def __init__(self, columns: List[TableColumn], sep: str = ' '): + def __init__(self, columns: List[TableColumn], sep: str = ' ') -> None: if len(set(len(column.rows) for column in columns)) not in {0, 1}: raise ValueError('All columns must have the same number of rows.') @@ -70,7 +70,7 @@ def _format_cell(self, content, column_settings, width): else: return content.rjust(width) - def _header_windows(self, formatted_headers): + def _header_windows(self, formatted_headers: List[str]) -> List[Window]: if len(formatted_headers): header_control = FormattedTextControl( self._sep.join(formatted_headers)) @@ -79,13 +79,16 @@ def _header_windows(self, formatted_headers): header_windows = [Window(height=1, width=0)] return header_windows - def _body_windows(self, formatted_columns): + def _body_windows( + self, + formatted_columns: List[List[str]] + ) -> List[Window]: rows = list(itertools.zip_longest(*formatted_columns, fillvalue='')) if rows: rows_string = [self._sep.join(row) for row in rows] table_body = '\n'.join(rows_string) - document = Document(table_body, 0) + document = Document(table_body, cursor_position=0) _buffer = Buffer(document=document, read_only=True) self._body_control = BufferControl(_buffer) body_windows = [ diff --git a/sml_sync/screens/components/tests/test_table.py b/sml_sync/screens/components/tests/test_table.py index b58eb02..f394cbf 100644 --- a/sml_sync/screens/components/tests/test_table.py +++ b/sml_sync/screens/components/tests/test_table.py @@ -1,3 +1,4 @@ + import textwrap import pytest diff --git a/sml_sync/screens/components/tests/test_vertical_menu.py b/sml_sync/screens/components/tests/test_vertical_menu.py index 5a5d736..c4865c2 100644 --- a/sml_sync/screens/components/tests/test_vertical_menu.py +++ b/sml_sync/screens/components/tests/test_vertical_menu.py @@ -1,4 +1,3 @@ - from unittest.mock import Mock from prompt_toolkit.layout import to_container diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py index 2d6fa1e..12698c3 100644 --- a/sml_sync/screens/components/vertical_menu.py +++ b/sml_sync/screens/components/vertical_menu.py @@ -1,6 +1,6 @@ from collections import namedtuple -from typing import List, Optional +from typing import List, Optional, Any, Callable from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import Window, FormattedTextControl @@ -10,7 +10,11 @@ class VerticalMenu(object): - def __init__(self, entries: List[MenuEntry], width: Optional[int]=None): + def __init__( + self, + entries: List[MenuEntry], + width: Optional[int] = None + ) -> None: self._current_index = 0 self._entries = entries if width is None: @@ -24,17 +28,20 @@ def __init__(self, entries: List[MenuEntry], width: Optional[int]=None): key_bindings=self._get_key_bindings()) self._set_control_text() self._window = Window(self._control, width=width) - self._menu_change_callbacks = [] + self._menu_change_callbacks: List[Callable[[Any], None]] = [] @property - def current_selection(self): + def current_selection(self) -> Any: if self._entries: return self._entries[self._current_index].id_ else: # No items in the menu return None - def register_menu_change_callback(self, callback): + def register_menu_change_callback( + self, + callback: Callable[[Any], None] + ) -> None: self._menu_change_callbacks.append(callback) def _execute_callbacks(self, new_selection): @@ -79,7 +86,7 @@ def __pt_container__(self): return self._window -def _ensure_width(inp: str, width: int): +def _ensure_width(inp: str, width: int) -> str: """ Ensure that string `inp` is exactly `width` characters long. """ diff --git a/sml_sync/screens/diff.py b/sml_sync/screens/diff.py index 432a4e3..846bc33 100644 --- a/sml_sync/screens/diff.py +++ b/sml_sync/screens/diff.py @@ -2,11 +2,12 @@ import logging from enum import Enum -from prompt_toolkit.application.current import get_app +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit, to_container -from prompt_toolkit.layout.containers import FloatContainer, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import ( + HSplit, VSplit, to_container, FloatContainer, Window, + FormattedTextControl +) from prompt_toolkit.widgets import TextArea, VerticalLine from ..pubsub import Messages diff --git a/sml_sync/screens/help.py b/sml_sync/screens/help.py index 259f50c..e52a9b5 100644 --- a/sml_sync/screens/help.py +++ b/sml_sync/screens/help.py @@ -1,7 +1,5 @@ -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Float, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl, Float from prompt_toolkit.widgets import Frame diff --git a/sml_sync/screens/sync.py b/sml_sync/screens/sync.py index 316d022..78886d6 100644 --- a/sml_sync/screens/sync.py +++ b/sml_sync/screens/sync.py @@ -3,10 +3,8 @@ import time from enum import Enum -from prompt_toolkit.application.current import get_app -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.application import get_app +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl from .base import BaseScreen from .loading import LoadingIndicator diff --git a/sml_sync/screens/walking_trees.py b/sml_sync/screens/walking_trees.py index b3042ec..993799b 100644 --- a/sml_sync/screens/walking_trees.py +++ b/sml_sync/screens/walking_trees.py @@ -3,10 +3,8 @@ import time from enum import Enum -from prompt_toolkit.application.current import get_app -from prompt_toolkit.layout import HSplit -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.application import get_app +from prompt_toolkit.layout import HSplit, Window, FormattedTextControl from ..pubsub import Messages from .base import BaseScreen diff --git a/sml_sync/screens/watch_sync.py b/sml_sync/screens/watch_sync.py index 4a6b4a0..0b8f6b4 100644 --- a/sml_sync/screens/watch_sync.py +++ b/sml_sync/screens/watch_sync.py @@ -4,11 +4,11 @@ import time from datetime import datetime -from prompt_toolkit.application.current import get_app +from prompt_toolkit.application import get_app from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import HSplit, VSplit -from prompt_toolkit.layout.containers import FloatContainer, Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import ( + HSplit, VSplit, FloatContainer, Window, FormattedTextControl +) from . import humanize from ..models import ChangeEventType diff --git a/sml_sync/ui.py b/sml_sync/ui.py index 1924753..865b3a8 100644 --- a/sml_sync/ui.py +++ b/sml_sync/ui.py @@ -4,9 +4,7 @@ from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings -from prompt_toolkit.layout import HSplit, Layout -from prompt_toolkit.layout.containers import Window -from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout import HSplit, Layout, Window, FormattedTextControl from .pubsub import Messages diff --git a/stubs/__init__.pyi b/stubs/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/prompt_toolkit/__init__.pyi b/stubs/prompt_toolkit/__init__.pyi new file mode 100644 index 0000000..e69de29 diff --git a/stubs/prompt_toolkit/application.pyi b/stubs/prompt_toolkit/application.pyi new file mode 100644 index 0000000..70c0028 --- /dev/null +++ b/stubs/prompt_toolkit/application.pyi @@ -0,0 +1,6 @@ + +class Application(object): + pass + + +def get_app() -> Application: ... diff --git a/stubs/prompt_toolkit/buffer.pyi b/stubs/prompt_toolkit/buffer.pyi new file mode 100644 index 0000000..eb55ca2 --- /dev/null +++ b/stubs/prompt_toolkit/buffer.pyi @@ -0,0 +1,13 @@ + +from typing import Optional + +from .document import Document + + +class Buffer(object): + + def __init__( + self, + document: Optional[Document] = None, + read_only: bool = False + ) -> None: ... diff --git a/stubs/prompt_toolkit/document.pyi b/stubs/prompt_toolkit/document.pyi new file mode 100644 index 0000000..72507af --- /dev/null +++ b/stubs/prompt_toolkit/document.pyi @@ -0,0 +1,10 @@ + +from typing import Optional + + +class Document(object): + + def __init__( + self, + text: str = '', + cursor_position: Optional[int] = None) -> None: ... diff --git a/stubs/prompt_toolkit/key_binding.pyi b/stubs/prompt_toolkit/key_binding.pyi new file mode 100644 index 0000000..b77ff94 --- /dev/null +++ b/stubs/prompt_toolkit/key_binding.pyi @@ -0,0 +1,13 @@ + +from typing import List + + +class KeyBindingsBase(object): + pass + + +class KeyBindings(KeyBindingsBase): + def __init__(self) -> None: ... + + +def merge_key_bindings(bindings: List[KeyBindings]) -> KeyBindingsBase: ... diff --git a/stubs/prompt_toolkit/layout.pyi b/stubs/prompt_toolkit/layout.pyi new file mode 100644 index 0000000..034c928 --- /dev/null +++ b/stubs/prompt_toolkit/layout.pyi @@ -0,0 +1,110 @@ + +from typing import Optional, Any, List, Iterable + +from .key_binding import KeyBindings +from .buffer import Buffer + + +class UIControl(object): + + def reset(self) -> None: ... + + +class FormattedTextControl(UIControl): + + def __init__( + self, + text: str, + focusable: bool = False, + show_cursor: bool = False, + key_bindings: Optional[KeyBindings] = None) -> None: ... + + +class BufferControl(UIControl): + + def __init__( + self, + buffer: Optional[Buffer] = None + ) -> None: ... + + +class Margin(object): + pass + + +class ScrollbarMargin(Margin): + + def __init__(self, display_arrows: int = 2) -> None: ... + + +class Container(object): + + def reset(self) -> None: ... + + def preferred_width(self, max_available_width: int) -> int: ... + + def preferred_height( + self, + width: int, + max_available_height: int + ) -> int: ... + + +class Window(Container): + + def __init__( + self, + content: Optional[UIControl] = None, + width: Optional[int] = None, + height: Optional[int] = None, + left_margins: Optional[Iterable[Margin]] = None, + right_margins: Optional[Iterable[Margin]] = None + ) -> None: ... + + +class HSplit(Container): + + def __init__( + self, + children: List[Any], + width: Optional[int] = None, + height: Optional[int] = None + ) -> None: ... + + +class VSplit(Container): + + def __init__( + self, + children: List[Any], + width: Optional[int] = None, + height: Optional[int] = None + ) -> None: ... + + +class Float(object): + + def __init__( + self, + content: Optional[Container] + ) -> None: ... + + +class FloatContainer(Container): + + def __init__( + self, + content: Container, + floats: Iterable[Float] + ) -> None: ... + + +def to_container(container: Any) -> Container: ... + + +class Layout(object): + + def __init__( + self, + container: Container + ) -> None: ... diff --git a/stubs/prompt_toolkit/widgets.pyi b/stubs/prompt_toolkit/widgets.pyi new file mode 100644 index 0000000..b079e78 --- /dev/null +++ b/stubs/prompt_toolkit/widgets.pyi @@ -0,0 +1,16 @@ + +from .layout import Container + + +class TextArea(object): + + def __init__(self, text: str = '') -> None: ... + + +class Frame(object): + + def __init__(self, body: Container) -> None: ... + + +class VerticalLine(object): + pass From 33e213117b183007f5f7740f90b5bf96fa903103 Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 3 Jun 2018 12:26:03 +0100 Subject: [PATCH 60/61] Remove unnecessary methods in Table Now that we use to_container, we don't need to have a bunch of methods that just proxy to window. --- sml_sync/screens/components/table.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index a220dc6..1dec4ca 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -101,17 +101,5 @@ def _body_windows( body_windows = [] return body_windows - def preferred_width(self, max_available_width): - return self.window.preferred_width(max_available_width) - - def preferred_height(self, width, max_available_height): - return self.window.preferred_height(width, max_available_height) - - def write_to_screen(self, *args, **kwargs): - return self.window.write_to_screen(*args, **kwargs) - - def get_children(self): - return self.window.get_children() - def __pt_container__(self): return self.window From da50be54886fe224092e9c98046cbea6efc2cb4e Mon Sep 17 00:00:00 2001 From: Pascal Bugnion Date: Sun, 3 Jun 2018 12:27:06 +0100 Subject: [PATCH 61/61] Explicit typechecks on screens.components package We now use mypy in strict mode on the sml_sync.screens.components subpackage. --- mypy.ini | 4 ++++ sml_sync/screens/components/table.py | 17 ++++++++++---- sml_sync/screens/components/vertical_menu.py | 24 ++++++++++---------- stubs/prompt_toolkit/key_binding.pyi | 4 +++- stubs/prompt_toolkit/layout.pyi | 6 +++-- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/mypy.ini b/mypy.ini index 2c45a30..6019f4c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,10 @@ [mypy] mypy_path = stubs +[mypy-sml_sync.screens.components.*] +disallow_any_unimported = True +disallow_untyped_defs = True + [mypy-sml_sync.screens.components.tests.*] # Don't typecheck tests yet ignore_errors = True diff --git a/sml_sync/screens/components/table.py b/sml_sync/screens/components/table.py index 1dec4ca..4c81fd9 100644 --- a/sml_sync/screens/components/table.py +++ b/sml_sync/screens/components/table.py @@ -6,7 +6,9 @@ from prompt_toolkit.buffer import Buffer from prompt_toolkit.document import Document from prompt_toolkit.layout import ( - Window, HSplit, FormattedTextControl, BufferControl, ScrollbarMargin) + Window, HSplit, FormattedTextControl, BufferControl, ScrollbarMargin, + Container +) class Alignment(Enum): @@ -27,7 +29,7 @@ def __new__( cls, rows: List[str], header: str, settings: Optional[ColumnSettings] = None - ): + ) -> 'TableColumn': if settings is None: settings = ColumnSettings() return super(TableColumn, cls).__new__(cls, rows, header, settings) @@ -57,14 +59,19 @@ def __init__(self, columns: List[TableColumn], sep: str = ' ') -> None: self._body_windows(formatted_columns) ) - def _get_column_width(self, column): + def _get_column_width(self, column: TableColumn) -> int: width = max( len(column.header), max((len(row) for row in column.rows), default=0) ) return width - def _format_cell(self, content, column_settings, width): + def _format_cell( + self, + content: str, + column_settings: ColumnSettings, + width: int + ) -> str: if column_settings.alignment == Alignment.LEFT: return content.ljust(width) else: @@ -101,5 +108,5 @@ def _body_windows( body_windows = [] return body_windows - def __pt_container__(self): + def __pt_container__(self) -> Container: return self.window diff --git a/sml_sync/screens/components/vertical_menu.py b/sml_sync/screens/components/vertical_menu.py index 12698c3..b42e735 100644 --- a/sml_sync/screens/components/vertical_menu.py +++ b/sml_sync/screens/components/vertical_menu.py @@ -3,7 +3,7 @@ from typing import List, Optional, Any, Callable from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout import Window, FormattedTextControl +from prompt_toolkit.layout import Window, FormattedTextControl, Container MenuEntry = namedtuple('MenuEntry', ['id_', 'text']) @@ -44,30 +44,30 @@ def register_menu_change_callback( ) -> None: self._menu_change_callbacks.append(callback) - def _execute_callbacks(self, new_selection): + def _execute_callbacks(self, new_selection: Any) -> None: for callback in self._menu_change_callbacks: callback(new_selection) - def _select_next(self): + def _select_next(self) -> None: self._set_selection_index(self._current_index + 1) - def _select_previous(self): + def _select_previous(self) -> None: self._set_selection_index(self._current_index - 1) - def _get_key_bindings(self): + def _get_key_bindings(self) -> KeyBindings: bindings = KeyBindings() - @bindings.add('up') # noqa: F811 - def _(event): + @bindings.add('up') + def up_key(event: Any) -> None: self._select_previous() - @bindings.add('down') # noqa: F811 - def _(event): + @bindings.add('down') + def down_key(event: Any) -> None: self._select_next() return bindings - def _set_selection_index(self, new_index): + def _set_selection_index(self, new_index: int) -> None: if self._entries: new_index = new_index % len(self._entries) if self._current_index != new_index: @@ -75,14 +75,14 @@ def _set_selection_index(self, new_index): self._set_control_text() self._execute_callbacks(self.current_selection) - def _set_control_text(self): + def _set_control_text(self) -> None: control_lines = [] for ientry, entry in enumerate(self._formatted_entries): style = 'reverse' if ientry == self._current_index else '' control_lines.append((style, entry + '\n')) self._control.text = control_lines - def __pt_container__(self): + def __pt_container__(self) -> Container: return self._window diff --git a/stubs/prompt_toolkit/key_binding.pyi b/stubs/prompt_toolkit/key_binding.pyi index b77ff94..e8fba24 100644 --- a/stubs/prompt_toolkit/key_binding.pyi +++ b/stubs/prompt_toolkit/key_binding.pyi @@ -1,5 +1,5 @@ -from typing import List +from typing import List, Callable, Any class KeyBindingsBase(object): @@ -9,5 +9,7 @@ class KeyBindingsBase(object): class KeyBindings(KeyBindingsBase): def __init__(self) -> None: ... + def add(self, key: str) -> Callable[..., Any]: ... + def merge_key_bindings(bindings: List[KeyBindings]) -> KeyBindingsBase: ... diff --git a/stubs/prompt_toolkit/layout.pyi b/stubs/prompt_toolkit/layout.pyi index 034c928..be04a5d 100644 --- a/stubs/prompt_toolkit/layout.pyi +++ b/stubs/prompt_toolkit/layout.pyi @@ -1,5 +1,5 @@ -from typing import Optional, Any, List, Iterable +from typing import Optional, Any, List, Iterable, Union, Tuple from .key_binding import KeyBindings from .buffer import Buffer @@ -12,9 +12,11 @@ class UIControl(object): class FormattedTextControl(UIControl): + text: Union[str, List[Tuple[str, str]]] + def __init__( self, - text: str, + text: Union[str, List[Tuple[str, str]]], focusable: bool = False, show_cursor: bool = False, key_bindings: Optional[KeyBindings] = None) -> None: ...