import functools import configparser import subprocess import dataclasses import json import datetime import collections import asyncio import threading import re import inspect import pathlib import logging import fnmatch import vim from typing import ( Optional, ClassVar, Self, Any, Callable, ) from .utils import Vim logger = logging.getLogger(__name__) MODULE_NAME = 'online_fxreader_pr34_vim' def future_dump_exception(future: Any) -> None: try: future.result() except: logger.exception('') class FastSelect: _instance: ClassVar[Optional['FastSelect']] = None def __init__(self) -> None: self.loop = asyncio.new_event_loop() self.thread = threading.Thread( target=self.loop.run_forever, ) self._buffer_frequency: dict[int, int] = dict() self._buffer_last_used: dict[int, int] = dict() self._filter_pattern: Optional[str] = None self._include_git : Optional[bool] = False self._filtered_ids: Optional[set[int]] = None self._items: Optional[list['self.entry_t']] = None self._buffers: Optional[list['self.entry_t']] = None self._tracked_files: Optional[list['self.entry_t']] = None self._queue: collections.deque[Callable[[], None]] = collections.deque() self._lock = threading.Lock() self.popup_id: Optional[int] = None self.thread.start() self._option_id: asyncio.Future[Optional[int]] = None self._options: list[str] = None auto_group = '{}_{}_{}'.format( MODULE_NAME, type(self).__name__.lower(), 'close', ).capitalize() vim.command(r""" func! UIThread(timer_id) python3 online_fxreader_pr34_vim.beta.FastSelect.singleton().ui_thread() endfunc """) Vim.run_command(r""" call timer_start(100, 'UIThread', {'repeat': -1}) """) Vim.run_command( r""" augroup {auto_group} autocmd! autocmd VimLeavePre * python3 online_fxreader_pr34_vim.beta.FastSelect.singleton().close() autocmd BufEnter * python3 online_fxreader_pr34_vim.beta.FastSelect.singleton().on_buf_enter() augroup END """.format( auto_group=auto_group, ) ) def __del__(self) -> None: self.close() def close(self) -> None: logger.info(dict(msg='close started')) self.loop.call_soon_threadsafe(self.loop.stop) self.thread.join() logger.info(dict(msg='close done')) @classmethod def singleton(cls) -> Self: if cls._instance is None: cls._instance = cls() return cls._instance def pick_option_put_id(self, option_id: int) -> None: self.loop.call_soon_threadsafe(lambda: self._option_id.set_result(option_id)) @dataclasses.dataclass class entry_t: path: pathlib.Path buf_number: Optional[int] = None def _get_buffers( self, res_future: Optional[asyncio.Future[ list[entry_t] ]] = None, ) -> list[entry_t]: res = [ self.entry_t( buf_number=o.number, path=pathlib.Path(o.name).absolute(), ) for o in vim.buffers ] if res_future: self.loop.call_soon_threadsafe(lambda: res_future.set_result(res)) return res async def _switch_buffer(self) -> None: with self._lock: self._reset_items() await self._sync_task(self._update_items) # self._items = buffers with self._lock: self._set_filter_pattern('') selected_id = await self._pick_option_from_popup( # [o[0] for o in buffers] ) logger.info(dict(selected_id=selected_id)) def ui_switch_buffer(): nonlocal selected_id # nonlocal buffers logger.warning(dict( buffers=self._items[:3], id=selected_id, )) # print(vim.buffers, selected_id) if not selected_id is None: selected_item = self._items[selected_id] if selected_item.buf_number is None: Vim.run_command('badd %s' % json.dumps(str(selected_item.path))[1:-1]) Vim.run_command('e %s' % json.dumps(str(selected_item.path))[1:-1]) else: vim.current.buffer = vim.buffers[selected_item.buf_number] with self._lock: self._queue.append(ui_switch_buffer) def switch_buffer(self) -> None: logger.info(dict(msg='before switch_buffer started')) result = asyncio.run_coroutine_threadsafe(self._switch_buffer(), self.loop) result.add_done_callback(future_dump_exception) logger.info(dict(msg='after switch_buffer started')) async def _pick_option_from_popup( self, # options: list[str], ) -> Optional[int]: logger.info(dict(msg='started')) self._filter_pattern = '' self._popup_id = None # self._options = options self._option_id = asyncio.Future[int]() await self._pick_option_start_popup() option_id = await self._option_id logger.info(dict(option_id=option_id)) self._options = None self._option_id = None logger.info(dict(msg='done')) if option_id >= 0: return self._filtered_ids[option_id] else: return None def ui_thread(self): with self._lock: # Vim.run_command(r''' # set laststatus=2 # set statusline={} #'''.format(datetime.datetime.now().isoformat())) while len(self._queue) > 0: cmd = self._queue.pop() try: cmd_str = inspect.getsource(cmd) except: cmd_str = str(cmd) try: logger.warning(dict(msg='start command', cmd=cmd_str)) cmd() except: logger.exception('') # self._result.append( # vim.command(cmd) # ) def on_buf_enter(self) -> None: result = asyncio.run_coroutine_threadsafe( self._on_buf_enter( buf_number=vim.current.buffer.number, buf_name=pathlib.Path(vim.current.buffer.name), ), self.loop, ) result.add_done_callback(future_dump_exception) def on_filter_key(self, key: str) -> None: logger.info(dict(msg='got key', key=key)) if key == bytes([27]): logger.info(dict(msg='closing popup')) vim.Function('popup_close')(self._popup_id) return 1 if key == b'\x80kb': logger.info(dict(msg='backspace')) with self._lock: self._set_filter_pattern(self._filter_pattern[:-1]) # C-g elif key == b'\x07': with self._lock: self._include_git = not self._include_git self._update_items() self._update_filtered() # self._update_popup() else: try: key_str = key.decode('utf-8') except: return vim.Function('popup_filter_menu')( self._popup_id, key ) # return 0 if not key_str.isprintable(): return vim.Function('popup_filter_menu')( self._popup_id, key ) # return 0 else: with self._lock: self._set_filter_pattern(self._filter_pattern + key_str) self._update_popup() return 1 async def _sync_task( self, cb: Callable[[], None], # future: asyncio.Future[bool] ) -> None: res_future: asyncio.Future[bool] = asyncio.Future() def wrapper(): res : bool = True try: cb() except: logger.exception('') res = False self.loop.call_soon_threadsafe(lambda: res_future.set_result(res)) with self._lock: self._queue.append(wrapper) return await res_future def _update_items(self) -> None: known_files: dict[str, int] = dict() if self._buffers is None: self._buffers = self._get_buffers() logger.info(dict(buffers=self._buffers[:3])) if self._include_git: if self._tracked_files is None: for o in self._buffers: assert o.buf_number known_files[str(o.path)] = o.buf_number ls_files_output = [ o.strip() for o in subprocess.check_output( ['git', 'ls-files'] ).decode('utf-8').splitlines() ] self._tracked_files = [] for o in ls_files_output: path = pathlib.Path( o, ).absolute() entry = self.entry_t( path=path, buf_number=known_files.get(str(path)), ) if entry.buf_number: continue self._tracked_files.append(entry) logger.info(dict(tracked_files=self._tracked_files[:3])) self._items = self._buffers + self._tracked_files else: self._items = self._buffers self._items = sorted( self._items, # key=lambda x: -self._buffer_frequency.get(x[1], 0) key=lambda x: -self._buffer_last_used.get(x.buf_number, 0), ) def _reset_items(self) -> None: self._buffers = None self._tracked_files = None self._items = None def _update_filtered(self) -> None: pattern = re.compile(self._filter_pattern) self._filtered_ids = [ i for i, o in enumerate(self._items) if not pattern.search(str(o.path)) is None ] self._options = [str(self._items[o].path) for o in self._filtered_ids] def _set_filter_pattern(self, filter_pattern: str) -> None: self._filter_pattern = filter_pattern self._update_filtered() def _update_popup(self) -> None: vim.Function('popup_settext')( self._popup_id, self._options, ) vim.Function('popup_setoptions')(self._popup_id, {'title': 'Select a file, [%s]' % self._filter_pattern}) async def _on_buf_enter( self, buf_number: int, buf_name: pathlib.Path, ) -> None: # logger.info(dict(msg='waiting')) with self._lock: # buf_number = vim.current.buffer.number if not buf_number in self._buffer_frequency: self._buffer_frequency[buf_number] = 0 self._buffer_frequency[buf_number] += 1 self._buffer_last_used[buf_number] = datetime.datetime.now().timestamp() logger.info( dict( msg='updated', buf_path=str(buf_name), frequency=self._buffer_frequency[buf_number], buf_number=buf_number, ) ) async def _pick_option_start_popup( self, ): callback_name = '{}_{}_{}'.format( MODULE_NAME, type(self).__name__.lower(), 'popup_callback', ).capitalize() filter_name = '{}_{}_{}'.format( MODULE_NAME, type(self).__name__.lower(), 'popup_filter', ).capitalize() if int(vim.eval('exists("{}")'.format(callback_name))) == 1: logger.warning(dict(msg='callback already defined, %s' % callback_name)) vim.command( r""" function! {callback_name}(id, result) if a:result > 0 call py3eval('online_fxreader_pr34_vim.beta.FastSelect.singleton().pick_option_put_id(' . (a:result - 1). ')') else call py3eval('online_fxreader_pr34_vim.beta.FastSelect.singleton().pick_option_put_id(-1)') endif endfunction """.format( callback_name=callback_name, ) ) vim.command( r""" function! {filter_name}(win_id, key) return py3eval('online_fxreader_pr34_vim.beta.FastSelect.singleton().on_filter_key(key)', #{key: a:key}) endfunction """.replace( '{filter_name}', filter_name, ) ) logger.info(dict(msg='before popup')) popup_menu = vim.Function('popup_menu') def create_popup(): self._popup_id = popup_menu( self._options, { 'title': 'Select a file', 'callback': callback_name, 'filter': filter_name, 'wrap': 1, 'maxwidth': 80, 'close': 'button', 'resize': 1, 'drag': 1, 'maxheight': '16', }, ) with self._lock: self._queue.append( create_popup, # lambda : vim.command( # "call popup_menu({options}, {'title': '{title}', 'callback': '{callback}'})".replace( # '{options}', '[%s]' % ','.join([ # '\'%s\'' % o.replace('\'', '\\\'') # for o in self._options # ]), # ).replace( # '{title}', 'Select a file', # ).replace( # '{callback}', # callback_name # ) # ) ) # logger.info(dict(popup_id=popup_id)) # logger.info(dict(msg='after popup')) def init(): FastSelect.singleton()