Browse Source

chore: basic command palette implementation (#598)

pull/469/merge
Pier CeccoPierangioliEugenio 3 weeks ago committed by GitHub
parent
commit
c5d0e51aa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 0
      apps/ttkode/ttkode/app/command_palette/__init__.py
  2. 239
      apps/ttkode/ttkode/app/command_palette/command_palette.py
  3. 70
      apps/ttkode/ttkode/app/command_palette/command_palette_items.py
  4. 95
      apps/ttkode/ttkode/app/command_palette/search_file_threading.py
  5. 0
      apps/ttkode/ttkode/app/helpers/__init__.py
  6. 98
      apps/ttkode/ttkode/app/helpers/search_file.py
  7. 11
      apps/ttkode/ttkode/app/ttkode.py
  8. 7
      apps/ttkode/ttkode/plugins/_010/findwidget.py
  9. 32
      tests/t.generic/test.generic.014.pathlib.01.py

0
apps/ttkode/ttkode/app/command_palette/__init__.py

239
apps/ttkode/ttkode/app/command_palette/command_palette.py

@ -0,0 +1,239 @@
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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.
__all__ = ['TTKode_CommandPalette']
from pathlib import Path
from enum import Enum,auto
from typing import List, Tuple, Optional
import TermTk as ttk
from ttkode.app.command_palette.search_file_threading import TTKode_CP_SearchFileThreading, TTKode_CP_SearchFileItem
from ttkode.app.command_palette.command_palette_items import TTKodeCommandPaletteListItem, TTKodeCommandPaletteListItemFile
class _ListAction(Enum):
UP=auto()
DOWN=auto()
SELECT=auto()
class _TTKodeCommandPaletteListWidget(ttk.TTkAbstractScrollView):
_color_hovered = ttk.TTkColor.bg('#444444')
_color_hilghlight = ttk.TTkColor.bg("#173C70")
__slots__ = ('_items', '_highlight', '_hovered', 'selected')
_items:List[TTKodeCommandPaletteListItem]
_highlight:Optional[TTKodeCommandPaletteListItem]
_hovered:Optional[TTKodeCommandPaletteListItem]
selected:ttk.pyTTkSignal
def __init__(self, **kwargs):
self._items = []
self._highlight = None
self._hovered = None
self.selected = ttk.pyTTkSignal(TTKodeCommandPaletteListItem)
super().__init__(**kwargs)
def viewFullAreaSize(self) -> Tuple[int,int]:
w = self.width()
h = len(self._items)
return w, h
def clean(self) -> None:
self._highlight = None
self._hovered = None
self._items = []
self.viewMoveTo(0,0)
def extend(self, items:List[TTKodeCommandPaletteListItem]):
self._items.extend(items)
self._items = sorted(self._items, key=lambda x: x.sorted_key())
self.viewChanged.emit()
self.update()
def _pushAction(self, action:_ListAction) -> None:
if not self._items:
return
_highlight = self._highlight
if _highlight is None:
_highlight = self._items[0]
self._highlight = _highlight
_items = self._items
ox,oy = self.getViewOffsets()
h = self.height()
index = _items.index(_highlight) if _highlight in _items else None
if action is _ListAction.UP:
index = -1 if index is None else index-1
elif action is _ListAction.DOWN:
index = 0 if index is None or index>=len(_items)-1 else index+1
elif action is _ListAction.SELECT:
if self._highlight:
self.selected.emit(self._highlight)
return
else:
index = 0
self._highlight = _items[index]
index = _items.index(self._highlight)
if index < oy:
oy = index
elif oy+h <= index:
oy = index-h+1
self.viewMoveTo(ox,oy)
self.update()
def mouseReleaseEvent(self, evt):
ox,oy = self.getViewOffsets()
x,y = evt.x,evt.y
y+=oy
_items = self._items
if 0 <= y < len(_items):
self.selected.emit(_items[y])
self.update()
return True
def mouseMoveEvent(self, evt):
ox,oy = self.getViewOffsets()
x,y = evt.x,evt.y
y+=oy
_items = self._items
if 0 <= y < len(_items):
self._hovered = _items[y]
else:
self._hovered = None
self.update()
return True
def leaveEvent(self, evt):
self._hovered = None
self.update()
return True
def paintEvent(self, canvas):
w,h = self.size()
ox,oy = self.getViewOffsets()
for i,item in enumerate(self._items[oy:oy+h]):
color = ttk.TTkColor.RST
if item is self._hovered:
color = self._color_hovered
elif item is self._highlight:
color = self._color_hilghlight
elif self._highlight is None and i == 0:
color = self._color_hilghlight
text = item.toTTkString(width=w).completeColor(color)
canvas.fill(pos=(0,i),size=(w,1),color=color)
canvas.drawTTkString(text=text, pos=(0,i))
class _TTKodeCommandPaletteList(ttk.TTkAbstractScrollArea):
__slots__ = ('_list_widget', 'selected')
selected:ttk.pyTTkSignal
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._list_widget = _TTKodeCommandPaletteListWidget()
self.selected = self._list_widget.selected
self.setViewport(self._list_widget)
def clean(self) -> None:
return self._list_widget.clean()
def extend(self, items:List[TTKodeCommandPaletteListItem]):
return self._list_widget.extend(items=items)
def _pushAction(self, action:_ListAction) -> None:
return self._list_widget._pushAction(action=action)
class TTKode_CommandPalette(ttk.TTkResizableFrame):
__slots__ = ('_line_edit', '_cpl', '_sft')
def __init__(self, **kwargs):
layout = ttk.TTkGridLayout()
self._line_edit = le = ttk.TTkLineEdit(hint='Search files by name')
self._cpl = cpl = _TTKodeCommandPaletteList()
self._sft = TTKode_CP_SearchFileThreading()
layout.addWidget(le,0,0)
layout.addWidget(cpl,1,0)
super().__init__(layout=layout, **kwargs)
le.textEdited.connect(self._search)
self._sft.search_results.connect(self._process_search_results)
self._cpl.selected.connect(self._selected_item)
@ttk.pyTTkSlot(TTKodeCommandPaletteListItem)
def _selected_item(self, item:TTKodeCommandPaletteListItem) -> None:
if isinstance(item, TTKodeCommandPaletteListItemFile):
from ttkode.proxy import ttkodeProxy
ttkodeProxy.openFile(item._file)
self.close()
@ttk.pyTTkSlot(str)
def _search(self, pattern:ttk.TTkString) -> None:
self._cpl.clean()
self._sft.search(pattern=pattern.toAscii())
@ttk.pyTTkSlot(List[TTKode_CP_SearchFileItem])
def _process_search_results(self, items:List[TTKode_CP_SearchFileItem]):
items_path = [
TTKodeCommandPaletteListItemFile(
file=_f.file,
pattern=_f.match_pattern
) for _f in items
]
ttk.TTkLog.debug('\n'.join([str(f) for f in items]))
self._cpl.extend(items_path)
# def setFocus(self):
# return self._line_edit.setFocus()
def keyEvent(self, evt:ttk.TTkKeyEvent) -> bool:
if evt.type == ttk.TTkK.SpecialKey:
# Don't Handle the special focus switch key
if evt.key is ttk.TTkK.Key_Up:
self._cpl._pushAction(_ListAction.UP)
self.update()
return True
if evt.key is ttk.TTkK.Key_Down:
self._cpl._pushAction(_ListAction.DOWN)
self.update()
return True
if evt.key is ttk.TTkK.Key_Enter:
self._cpl._pushAction(_ListAction.SELECT)
self.update()
return True
if evt.key is ttk.TTkK.Key_Escape:
self.pippo='Esc'
self.close()
return True
if self._line_edit.keyEvent(evt=evt):
self._line_edit.setFocus()
return True
return False
def paintEvent(self, canvas:ttk.TTkCanvas) -> None:
super().paintEvent(canvas)
w,h = self.size()
canvas.drawChar(pos=( 0, 0), char='')
canvas.drawChar(pos=(w-1, 0), char='')
canvas.drawChar(pos=( 0,h-1), char='')
canvas.drawChar(pos=(w-1,h-1), char='')

70
apps/ttkode/ttkode/app/command_palette/command_palette_items.py

@ -0,0 +1,70 @@
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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.
__all__ = ['TTKodeCommandPaletteListItem', 'TTKodeCommandPaletteListItemFile']
from pathlib import Path
from typing import Tuple, Any
import TermTk as ttk
class TTKodeCommandPaletteListItem():
def toTTkString(self, width:int) -> ttk.TTkString:
return ttk.TTkString()
def sorted_key(self) -> Tuple[int, Any]:
return (0,0)
_colorDirectory = ttk.TTkColor.fg('#AAAAAA')
_colorMatch = ttk.TTkColor.fg('#00AAAA')
class TTKodeCommandPaletteListItemFile():
__slots__ = ('_file', '_key', '_pattern')
_pattern:str
_file:Path
_key:int
def __init__(self, file:Path, pattern:str):
self._file = file
self._pattern = pattern
_match_ret = file.name.split(pattern)[-1]
_match_level = len(_match_ret)
_match_level += 0x10000 * _match_ret.count('/')
self._key = _match_level
def sorted_key(self) -> Tuple[int, Any]:
return (self._key, self._file.name.lower())
def toTTkString(self, width:int) -> ttk.TTkString:
file = self._file
fileName = file.name
folder = file.parent
text = (
ttk.TTkString(ttk.TTkCfg.theme.fileIcon.getIcon(fileName), ttk.TTkCfg.theme.fileIconColor) +
ttk.TTkString(" " + fileName + " ") + ttk.TTkString( folder, _colorDirectory )
)
text = text.setColor(match=self._pattern, color=_colorMatch)
if len(text) > width:
text = text.substring(to=width-3) + '...'
return text

95
apps/ttkode/ttkode/app/command_palette/search_file_threading.py

@ -0,0 +1,95 @@
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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.
import os
import fnmatch
from dataclasses import dataclass
from threading import Thread,Event,Lock
from pathlib import Path
from typing import List, Generator, Tuple, Optional
import TermTk as ttk
from ttkode.app.helpers.search_file import TTKode_SearchFile
@dataclass
class TTKode_CP_SearchFileItem():
file:Path
match_pattern:str
class TTKode_CP_SearchFileThreading():
__slots__ = (
'_runId',
'_search_thread', '_search_stop_event', '_search_lock',
'search_results'
)
_runId:int
_search_lock:Lock
_search_thread:Optional[Thread]
_search_stop_event:Event
search_results:ttk.pyTTkSignal
def __init__(self, **kwargs):
self._runId = 0
self._search_thread = None
self._search_lock = Lock()
self._search_stop_event = Event()
self.search_results = ttk.pyTTkSignal(List[TTKode_CP_SearchFileItem])
@ttk.pyTTkSlot(str)
def search(self, pattern:str) -> None:
ttk.TTkLog.debug(pattern)
with self._search_lock:
if self._search_thread:
self._search_stop_event.set()
self._search_thread.join()
self._search_stop_event.clear()
self._search_thread = None
if not pattern:
return
self._runId += 1
self._search_thread = Thread(
target=self._search_threading,
args=(pattern,))
self._search_thread.start()
def _search_threading(self, search_pattern:str) -> None:
items = 1
ret:List[Path] = []
for file in TTKode_SearchFile.getFilesFromPattern('.', pattern=search_pattern):
if self._search_stop_event.is_set():
return
ret.append(
TTKode_CP_SearchFileItem(
file=file,
match_pattern=search_pattern
)
)
if len(ret) >= items:
self.search_results.emit(ret)
items <<= 2
ret = []
if ret:
self.search_results.emit(ret)

0
apps/ttkode/ttkode/app/helpers/__init__.py

98
apps/ttkode/ttkode/app/helpers/search_file.py

@ -0,0 +1,98 @@
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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.
__all__ = ['TTKode_SearchFile']
import os
import fnmatch
import mimetypes
from threading import Thread,Event,Lock
from pathlib import Path
from typing import List, Generator, Tuple, Optional
import TermTk as ttk
def is_text_file(file_path, block_size=512):
# Check MIME type
mime_type, _ = mimetypes.guess_type(file_path)
text_based_mime_types = [
'text/', 'application/json', 'application/xml',
'application/javascript', 'application/x-httpd-php',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
if mime_type is not None and any(mime_type.startswith(mime) for mime in text_based_mime_types):
return True
# Check for non-printable characters
try:
with open(file_path, 'rb') as file:
block = file.read(block_size)
if b'\0' in block:
return False
text_characters = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7f})
return not bool(block.translate(None, text_characters))
except Exception as e:
print(f"Error reading file: {e}")
return False
def _load_gitignore_patterns(gitignore_path):
if os.path.exists(gitignore_path):
with open(gitignore_path, 'r') as f:
patterns = f.read().splitlines()
return patterns
return []
def _glob_match_patterns(path, patterns) -> bool:
if path == '.':
check_path = ''
elif path.startswith('./'):
check_path = path[2:]
else:
check_path = path
return any(f"/{_p}/" in path for _p in patterns if _p) or any(fnmatch.fnmatch(check_path, _p) for _p in patterns if _p)
def _custom_walk(directory:str, include_patterns:List[str]=[], exclude_patterns:List[str]=[]) -> Generator[Tuple[str, str], None, None]:
gitignore_path = os.path.join(directory, '.gitignore')
exclude_patterns = exclude_patterns + _load_gitignore_patterns(gitignore_path)
for entry in sorted(os.listdir(directory)):
full_path = os.path.join(directory, entry)
if _glob_match_patterns(full_path, exclude_patterns):
continue
if not os.path.exists(full_path):
continue
if os.path.isdir(full_path):
if entry == '.git':
continue
yield from _custom_walk(full_path, include_patterns, exclude_patterns)
else:
if include_patterns and not _glob_match_patterns(full_path, include_patterns):
continue
yield directory, entry
class TTKode_SearchFile():
@staticmethod
def getFilesFromPattern(root_folder:Path, pattern:str) -> Generator[Tuple[Path], None, None]:
for _dir, _fileName in _custom_walk(directory=root_folder):
if not _glob_match_patterns(f"{_dir}/{_fileName}", [f"*{pattern}*"]):
continue
yield Path(_dir) / _fileName

11
apps/ttkode/ttkode/app/ttkode.py

@ -33,6 +33,7 @@ import TermTk as ttk
from TermTk.TTkWidgets.tabwidget import _TTkNewTabWidgetDragData
from .about import About
from .command_palette.command_palette import TTKode_CommandPalette
from .activitybar import TTKodeActivityBar
class TTKodeWidget():
@ -257,6 +258,9 @@ class TTKode(ttk.TTkGridLayout):
def _showAboutTTk(btn):
ttk.TTkHelper.overlay(None, ttk.TTkAbout(), 30,10)
appMenuBar.addMenu("TTKode", alignment=ttk.TTkK.CENTER_ALIGN).menuButtonClicked.connect(self.showCommandPalette)
appMenuBar.addMenu("&Quit", alignment=ttk.TTkK.RIGHT_ALIGN).menuButtonClicked.connect(self._quit)
helpMenu = appMenuBar.addMenu("&Help", alignment=ttk.TTkK.RIGHT_ALIGN)
helpMenu.addMenu("About ...").menuButtonClicked.connect(_showAbout)
@ -301,6 +305,7 @@ class TTKode(ttk.TTkGridLayout):
self._kodeTab.kodeTabCloseRequested.connect(self._handleTabCloseRequested)
ttk.TTkShortcut(ttk.TTkK.CTRL | ttk.TTkK.Key_S).activated.connect(self.saveLastDoc)
ttk.TTkShortcut(ttk.TTkK.CTRL | ttk.TTkK.Key_P).activated.connect(self.showCommandPalette)
@ttk.pyTTkSlot(_TextDocument)
@ -331,6 +336,12 @@ class TTKode(ttk.TTkGridLayout):
else:
ttk.TTkHelper.quit()
@ttk.pyTTkSlot()
def showCommandPalette(self):
w,h = self.size()
cp = TTKode_CommandPalette(size=(60,20))
ttk.TTkHelper.overlay(None, cp, w//2-30,0)
@ttk.pyTTkSlot()
def saveLastDoc(self):
if self._lastDoc:

7
apps/ttkode/ttkode/plugins/_010/findwidget.py

@ -33,14 +33,9 @@ from typing import Generator,List,Tuple,Dict,Optional
import TermTk as ttk
import os
import fnmatch
from ttkode.proxy import ttkodeProxy
from ttkode.app.ttkode import TTKodeFileWidgetItem
import mimetypes
def is_text_file(file_path, block_size=512):
# Check MIME type
mime_type, _ = mimetypes.guess_type(file_path)
@ -325,8 +320,6 @@ class FindWidget(ttk.TTkContainer):
if not search_pattern:
self._results_tree.clear()
return
if self._search_thread:
self._search_thread.join()
self._search_thread = Thread(
target=self._search_threading,
args=(search_pattern, include_patterns, exclude_patterns, replace_pattern))

32
tests/t.generic/test.generic.014.pathlib.01.py

@ -0,0 +1,32 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Eugenio Parodi <ceccopierangiolieugenio AT googlemail DOT com>
#
# 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 pathlib import Path
root = Path('.')
for _p in root.glob('**/*ttkwidgets/widget.py*', case_sensitive=False):
print(_p)
Loading…
Cancel
Save