From 49fe40e93d188eaeefd88ecf79340c49b6490a82 Mon Sep 17 00:00:00 2001 From: eugenesvk Date: Thu, 1 Dec 2022 01:27:11 +0700 Subject: [PATCH 1/4] bump python plugin host to v3.8 --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..98fccd6 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 \ No newline at end of file From 39a22aa615dd1f5387437c912e7ff49e14cafd52 Mon Sep 17 00:00:00 2001 From: eugenesvk Date: Thu, 1 Dec 2022 01:27:29 +0700 Subject: [PATCH 2/4] add debouncing code license --- License.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 License.txt diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..6af263d --- /dev/null +++ b/License.txt @@ -0,0 +1,6 @@ + +Debouncing code license +Copyright (c) 2012 John Isaacks +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. From 3f06b492329fe059fbf595475b03ff4787671040 Mon Sep 17 00:00:00 2001 From: eugenesvk Date: Thu, 1 Dec 2022 17:57:04 +0700 Subject: [PATCH 3/4] (auto)remove spurious tabs on empty lines --- elastic_tabstops.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/elastic_tabstops.py b/elastic_tabstops.py index 114f38d..f729819 100644 --- a/elastic_tabstops.py +++ b/elastic_tabstops.py @@ -2,7 +2,7 @@ UPDATE Use this command to reprocess the whole file. Shouldn't be necessary except in specific cases. - + { "keys": ["super+j"], "command": "elastic_tabstops_update"}, """ @@ -72,7 +72,7 @@ def cell_widths_for_row(view, row): def find_cell_widths_for_block(view, row): cell_widths = [] - + #starting row and backward row_iter = row while row_iter >= 0: @@ -82,7 +82,7 @@ def find_cell_widths_for_block(view, row): cell_widths.insert(0, widths) row_iter -= 1 first_row = row_iter + 1 - + #forward (not including starting row) row_iter = row num_rows = lines_in_buffer(view) @@ -92,7 +92,7 @@ def find_cell_widths_for_block(view, row): if len(widths) == 0: break cell_widths.append(widths) - + return cell_widths, first_row def adjust_row(view, glued, row, widths): @@ -101,14 +101,14 @@ def adjust_row(view, glued, row, widths): return glued bias = 0 location = -1 - + for w, it in zip(widths,row_tabs): location += 1 + w it += bias difference = location - it if difference == 0: continue - + end_tab_point = view.text_point(row, it) partial_line = view.substr(view.line(end_tab_point))[0:it] stripped_partial_line = partial_line.rstrip() @@ -156,7 +156,7 @@ def process_rows(view, rows): for row in rows: if row in checked_rows: continue - + cell_widths_by_row, row_index = find_cell_widths_for_block(view, row) set_block_cell_widths_to_max(cell_widths_by_row) for widths in cell_widths_by_row: @@ -184,28 +184,28 @@ class ElasticTabstopsListener(sublime_plugin.EventListener): def on_modified(self, view): if self.running: return - + view = fix_view(view) - + history_item = view.command_history(1)[1] if history_item: if history_item.get('name') == "ElasticTabstops": return if history_item.get('commands') and history_item['commands'][0][1].get('name') == "ElasticTabstops": return - + selected_rows = self.selected_rows_by_view.get(view.id(), set()) selected_rows = selected_rows.union(get_selected_rows(view)) - + try: self.running = True translate = False if view.settings().get("translate_tabs_to_spaces"): translate = True view.settings().set("translate_tabs_to_spaces", False) - + process_rows(view, selected_rows) - + finally: self.running = False if translate: @@ -246,9 +246,9 @@ def run(self, edit, direction, extend): else: raise Exception("invalid direction") next_tab_col = s.b - + b = self.view.text_point(row, next_tab_col) - + if extend: new_regions.append(sublime.Region(s.a, b)) else: From 2e40cb77c2a5f05de7c8d9ed69ccb4b0d6fa0e52 Mon Sep 17 00:00:00 2001 From: eugenesvk Date: Thu, 1 Dec 2022 15:44:59 +0700 Subject: [PATCH 4/4] feat: add debouncing to avoid being blocked on each editing keypress On large vertical blocks the plugin struggles to do a lot of inserts needed to vertically align many lines. This makes typing close to impossible since the plugin tries to realign on every width-changing edit. With debouncing, the realignment only starts after a user-configurable delay has passed after the last edit/selection change operation, so it doesn't block continuous typing --- elastic_tabstops.py | 155 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 17 deletions(-) diff --git a/elastic_tabstops.py b/elastic_tabstops.py index f729819..6734aa0 100644 --- a/elastic_tabstops.py +++ b/elastic_tabstops.py @@ -11,6 +11,8 @@ import sublime_plugin import re import sys +import time + if sys.version_info[0] < 3: from edit import Edit from itertools import izip, izip_longest @@ -20,6 +22,22 @@ from ElasticTabstops.edit import Edit from itertools import zip_longest +# binary representation of all ST events +NEW = 1 +CLONE = 2 +LOAD = 4 +PRE_SAVE = 8 +POST_SAVE = 16 +MODIFIED = 32 +SELECTION_MODIFIED = 64 +ACTIVATED = 128 +DEACTIVATED = 256 + +try: + set_timeout = sublime.set_timeout_async +except AttributeError: + set_timeout = sublime.set_timeout + def lines_in_buffer(view): row, col = view.rowcol(view.size()) #"row" is the index of the last row; need to add 1 to get number of rows @@ -178,10 +196,119 @@ def fix_view(view): return view class ElasticTabstopsListener(sublime_plugin.EventListener): - selected_rows_by_view = {} - running = False - - def on_modified(self, view): + def __init__(self): + self.view_events = {} + + def debounce(self, view, event_id): + """Invoke evaluation of changes after some idle time + view (View): The view to perform evaluation for + event_id (int) : The event identifier """ + # based on https://github.com/jisaacks/GitGutter/blob/master/modules/events.py + key = view.id() + try: + self.view_events[key].push(event_id) + except KeyError: + if view.buffer_id(): + new_listener = ViewEventListener(view) + new_listener.push(event_id) + self.view_events[key] = new_listener + for vid in [vid for vid, listener in self.view_events.items() # collect garbage + if listener.view.buffer_id() == 0]: + del self.view_events[vid] + + # TODO: check if activated/selection asyncs bug and need to revert to the blocking version + def on_activated_async (self, view): + self.debounce(view,ACTIVATED) + def on_selection_modified_async(self, view): + self.debounce(view,SELECTION_MODIFIED) + def on_modified (self, view): + # user editing during the line-by-line tabstop fixing operation can bug by deleting/inserting a symbol; use the sync API to block it + self.debounce(view, MODIFIED) + +class ViewEventListener(object): + """Queues and forwards view events to Commands + A ViewEventListener object queues all events received from a view and starts a single Sublime timer to forward the event to Commands after some idle time. Prevents bloating Sublime API due to dozens of timers running for debouncing events + """ + def __init__(self, view): + """Initialize ViewEventListener object + view (View): The view the object is created for """ + + self.view = view + self.settings = view.settings() # + self.busy = False # flag: timer is running + self.running = False # flag: modification is running + self.events = 0 # a binary combination of above events + self.latest_time = 0.0 # latest time of append() call + _delay = self.settings.get('eltab_debounce_delay',1000) # config: debounce delay + self.delay = max(200,_delay if _delay is not None else 0) # debounce delay in milliseconds + self.selected_rows_by_view = {} + + def push(self, event_id): + """Push the event to the queue and start idle timer. + Add the event identifier to 'events' and update the 'latest_time'. This marks an event to be received rather than counting the number of received events. The idle timer is started only, if no other one is already in flight. + event_id (int): One of the event identifiers""" + self.latest_time = time.time() + self.events |= event_id + if not self.busy: + self.start_timer(self.delay) + + def start_timer(self, delay): + """Run commands after some idle time + If no events received during the idle time → run the commands + Else → restart timer to check later + Timer is stopped without calling the commands if a view is not visible to save some resources. Evaluation will be triggered by activating the view next time + delay (int): The delay in milliseconds to wait until probably + forward the events, if no other event was received in the meanwhile""" + start_time = self.latest_time + + def worker(): + """The function called after some idle time.""" + if start_time < self.latest_time: + self.start_timer(self.delay) + return + self.busy = False + if not self.is_view_visible(): + return + # bitwise AND comparison to see which events were triggered during the delay + if ACTIVATED == ACTIVATED & self.events: + self.activated() + if SELECTION_MODIFIED == SELECTION_MODIFIED & self.events: + self.selection_modified() + if MODIFIED == MODIFIED & self.events: + self.modified() + self.events = 0 + + self.busy = True + if MODIFIED == MODIFIED & self.events: + sublime.set_timeout(worker, delay) # force sync timeout + else: + set_timeout(worker, delay) # async timeout (if exists) + + def is_view_visible(self): + """Determine if the view is visible + Only an active view of a group is visible + Returns: bool: True if the view is visible in any window """ + window = self.view.window() + if window: + view_id = self.view.id() + for group in range(window.num_groups()): + active_view = window.active_view_in_group(group) + if active_view and active_view.id() == view_id: + return True + return False + + def activated(self): + view = self.view + view = fix_view(view) + self.selected_rows_by_view[view.id()] = get_selected_rows(view) + + def selection_modified(self): + view = self.view + view = fix_view(view) + self.selected_rows_by_view[view.id()] = get_selected_rows(view) + + def modified(self): + view = self.view if self.running: return @@ -189,9 +316,10 @@ def on_modified(self, view): history_item = view.command_history(1)[1] if history_item: - if history_item.get('name') == "ElasticTabstops": + if history_item.get ('name') == "ElasticTabstops": return - if history_item.get('commands') and history_item['commands'][0][1].get('name') == "ElasticTabstops": + if history_item.get('commands') and \ + history_item['commands'][0][1].get('name') == "ElasticTabstops": return selected_rows = self.selected_rows_by_view.get(view.id(), set()) @@ -200,24 +328,17 @@ def on_modified(self, view): try: self.running = True translate = False - if view.settings().get("translate_tabs_to_spaces"): + if self.settings.get("translate_tabs_to_spaces"): translate = True - view.settings().set("translate_tabs_to_spaces", False) + self.settings.set("translate_tabs_to_spaces", False) process_rows(view, selected_rows) finally: self.running = False if translate: - view.settings().set("translate_tabs_to_spaces",True) - - def on_selection_modified(self, view): - view = fix_view(view) - self.selected_rows_by_view[view.id()] = get_selected_rows(view) - - def on_activated(self, view): - view = fix_view(view) - self.selected_rows_by_view[view.id()] = get_selected_rows(view) + self.settings.set("translate_tabs_to_spaces",True) + class ElasticTabstopsUpdateCommand(sublime_plugin.TextCommand): def run(self, edit):