From 1d65bdfdfdf522936976bdb920f2ec77ba241ded Mon Sep 17 00:00:00 2001 From: Pier CeccoPierangioliEugenio Date: Mon, 14 Apr 2025 23:52:20 +0100 Subject: [PATCH] feat: ttkode search plugin (#385) --- apps/ttkode/ttkode/app/ttkode.py | 69 +++++- apps/ttkode/ttkode/plugins/_010/findwidget.py | 225 +++++++++++++++++- 2 files changed, 283 insertions(+), 11 deletions(-) diff --git a/apps/ttkode/ttkode/app/ttkode.py b/apps/ttkode/ttkode/app/ttkode.py index 14160cfa..a3ed0af3 100644 --- a/apps/ttkode/ttkode/app/ttkode.py +++ b/apps/ttkode/ttkode/app/ttkode.py @@ -33,7 +33,7 @@ from TermTk import pyTTkSlot, pyTTkSignal from TermTk import TTkFrame, TTkButton from TermTk import TTkKodeTab from TermTk import TTkFileDialogPicker -from TermTk import TTkFileTree, TTkTextEdit +from TermTk import TTkFileTree, TTkTextEdit, TTkTextCursor from TermTk import TTkGridLayout from TermTk import TTkSplitter,TTkAppTemplate @@ -42,10 +42,24 @@ from TermTk import TTkLogViewer from TermTk import TTkMenuBarLayout from TermTk import TTkAbout from TermTk import TTkTestWidget, TTkTestWidgetSizes +from TermTk import TTkDnDEvent +from TermTk import TTkTreeWidget, TTkTreeWidgetItem, TTkFileTreeWidgetItem +from TermTk.TTkWidgets.tabwidget import _TTkNewTabWidgetDragData from .about import About from .activitybar import TTKodeActivityBar +class TTKodeFileWidgetItem(TTkTreeWidgetItem): + __slots__ = ('_path', '_lineNumber') + def __init__(self, *args, path:str, lineNumber:int=0, **kwargs) -> None: + self._path = path + self._lineNumber = lineNumber + super().__init__(*args, **kwargs) + def path(self) -> str: + return self._path + def lineNumber(self) -> int: + return self._lineNumber + class _TextDocument(TextDocumentHighlight): __slots__ = ('_filePath') def __init__(self, filePath:str="", **kwargs): @@ -64,6 +78,7 @@ class TTKode(TTkGridLayout): self.addWidget(appTemplate) self._kodeTab = TTkKodeTab(border=False, closable=True) + self._kodeTab.setDropEventProxy(self._dropEventProxyFile) appTemplate.setMenuBar(appMenuBar:=TTkMenuBarLayout(), TTkAppTemplate.MAIN) fileMenu = appMenuBar.addMenu("&File") @@ -81,7 +96,7 @@ class TTKode(TTkGridLayout): helpMenu.addMenu("About ...").menuButtonClicked.connect(_showAbout) helpMenu.addMenu("About ttk").menuButtonClicked.connect(_showAboutTTk) - fileTree = TTkFileTree(path='.') + fileTree = TTkFileTree(path='.', dragDropMode=TTkK.DragDropMode.AllowDrag) self._activityBar = TTKodeActivityBar() self._activityBar.addActivity(name="Explorer", icon=TTkString("╔██\n╚═╝"), widget=fileTree, select=True) @@ -100,7 +115,7 @@ class TTKode(TTkGridLayout): filePicker.pathPicked.connect(self._openFile) TTkHelper.overlay(None, filePicker, 20, 5, True) - def _openFile(self, filePath): + def _openFile(self, filePath, lineNumber=0): filePath = os.path.realpath(filePath) if filePath in self._documents: doc = self._documents[filePath]['doc'] @@ -115,6 +130,54 @@ class TTKode(TTkGridLayout): self._kodeTab.addTab(tedit, label) self._kodeTab.setCurrentWidget(tedit) + if lineNumber: + tedit.textCursor().movePosition(operation=TTkTextCursor.MoveOperation.End) + tedit.ensureCursorVisible() + tedit.textCursor().setPosition(line=lineNumber,pos=0) + tedit.ensureCursorVisible() + newCursor = tedit.textCursor().copy() + newCursor.clearSelection() + selection = TTkTextEdit.ExtraSelection( + cursor=newCursor, + color=TTkColor.bg("#444400"), + format=TTkK.SelectionFormat.FullWidthSelection) + tedit.setExtraSelections([selection]) + tedit.setFocus() + + def _dropEventProxyFile(self, evt:TTkDnDEvent): + data = evt.data() + filePath = None + + if ( issubclass(type(data), TTkTreeWidget._DropTreeData) and + data.items ): + if issubclass(type(data.items[0]), TTkFileTreeWidgetItem): + item:TTkFileTreeWidgetItem = data.items[0] + filePath = os.path.realpath(item.path()) + elif issubclass(type(data.items[0]), TTKodeFileWidgetItem): + item:TTkFileTreeWidgetItem = data.items[0] + filePath = os.path.realpath(item.path()) + + if filePath: + if filePath in self._documents: + doc = self._documents[filePath]['doc'] + else: + with open(filePath, 'r') as f: + content = f.read() + doc = _TextDocument(text=content, filePath=filePath) + self._documents[filePath] = {'doc':doc,'tabs':[]} + tedit = TTkTextEdit(document=doc, readOnly=False, lineNumber=True) + label = TTkString(TTkCfg.theme.fileIcon.getIcon(filePath),TTkCfg.theme.fileIconColor) + TTkColor.RST + " " + os.path.basename(filePath) + + newData = _TTkNewTabWidgetDragData( + widget=tedit, + label=label, + data=None, + closable=True + ) + newEvt = evt.clone() + newEvt.setData(newData) + return newEvt + return evt # def _closeFile(): # if (index := KodeTab.lastUsed.currentIndex()) >= 0: # KodeTab.lastUsed.removeTab(index) diff --git a/apps/ttkode/ttkode/plugins/_010/findwidget.py b/apps/ttkode/ttkode/plugins/_010/findwidget.py index 5f46bfa6..e0945535 100644 --- a/apps/ttkode/ttkode/plugins/_010/findwidget.py +++ b/apps/ttkode/ttkode/plugins/_010/findwidget.py @@ -22,21 +22,230 @@ __all__ = ['FindWidget'] +import os +import re +import fnmatch +import mimetypes + +from threading import Thread +from typing import Generator,List,Tuple + import TermTk as ttk -import ttkode +import os +import fnmatch + +from ttkode 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) + 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 _should_ignore(path, patterns): + for pattern in patterns: + if fnmatch.fnmatch(path, pattern): + return True + return False + +def _custom_walk(directory:str, patterns:List[str]=[]) -> Generator[Tuple[str, str], None, None]: + gitignore_path = os.path.join(directory, '.gitignore') + patterns = patterns + _load_gitignore_patterns(gitignore_path) + for entry in sorted(os.listdir(directory)): + full_path = os.path.join(directory, entry) + if _should_ignore(full_path, patterns): + continue + if os.path.isdir(full_path): + if entry == '.git': + continue + yield from _custom_walk(full_path, patterns) + else: + yield directory, entry + +def _walk_with_gitignore(root): + for dirpath, filenames in os.walk(root): + gitignore_path = os.path.join(dirpath, '.gitignore') + patterns = _load_gitignore_patterns(gitignore_path) + + filenames[:] = [f for f in filenames if not _should_ignore(os.path.join(dirpath, f), patterns)] + + yield dirpath, filenames + + +class _ExpandButton(ttk.TTkButton): + def __init__(self, **kwargs): + params = { + 'border':False, + 'checked':False, + 'checkable':True, + 'minSize':(4,1), + 'maxSize':(4,1), + } + super().__init__(**kwargs|params) + + def paintEvent(self, canvas): + if self.isChecked(): + canvas.drawChar(pos=(1,0),char='▼') + else: + canvas.drawChar(pos=(1,0),char='▶') + +class _MatchTreeWidgetItem(TTKodeFileWidgetItem): + __slots__ = ('_match','_line','_file') + _match:str + def __init__(self, *args, match:str, **kwargs): + self._match = match + super().__init__(*args, **kwargs) class FindWidget(ttk.TTkContainer): + __slots__ = ( + '_runId' + '_results_tree', + '_search_le','_replace_le','_files_inc_le','_files_exc_le') + _runId:int + _results_tree:ttk.TTkTreeWidget + _search_le:ttk.TTkLineEdit + _replace_le:ttk.TTkLineEdit + _files_inc_le:ttk.TTkLineEdit + _files_exc_le:ttk.TTkLineEdit def __init__(self, **kwargs): + self._runId = 0 super().__init__(**kwargs) self.setLayout(layout:=ttk.TTkGridLayout()) + searchLayout = ttk.TTkGridLayout() - searchLayout.addWidget(expandReplace:=ttk.TTkButton(text=">", maxWidth=3, checkable=True), 0, 0) - searchLayout.addWidget(ttk.TTkLineEdit(), 0, 1) - searchLayout.addWidget(replace:=ttk.TTkLineEdit(), 1, 0, 1, 2) + searchLayout.addWidget(expandReplace:=_ExpandButton(), 0, 0) + searchLayout.addWidget(search :=ttk.TTkLineEdit(hint='Search'), 0, 1) + searchLayout.addWidget(repl__l:=ttk.TTkLabel(visible=False, text='sub:'), 1, 0) + searchLayout.addWidget(ft_in_l:=ttk.TTkLabel(visible=False, text='inc:'), 2, 0) + searchLayout.addWidget(ft_ex_l:=ttk.TTkLabel(visible=False, text='exc:'), 3, 0) + searchLayout.addWidget(replace:=ttk.TTkLineEdit(visible=False, hint='Replace'), 1, 1) + searchLayout.addWidget(ft_incl:=ttk.TTkLineEdit(visible=False, hint='Files to include'), 2, 1) + searchLayout.addWidget(ft_excl:=ttk.TTkLineEdit(visible=False, hint='Files to exclude'), 3, 1) + layout.addItem(searchLayout, 0, 0) - layout.addWidget(ttk.TTkButton(text="Find", border=False), 1,0) - layout.addWidget(ttk.TTkButton(text="Find", border=False), 2,0) - layout.addWidget(ttk.TTkButton(text="Find", border=True), 3,0) + layout.addWidget(btn_search:=ttk.TTkButton(text="Search", border=False), 4,0) + layout.addWidget(res_tree:=ttk.TTkTree(dragDropMode=ttk.TTkK.DragDropMode.AllowDrag), 5,0) + res_tree.setHeaderLabels(["Results"]) + res_tree.setColumnWidth(0,100) + expandReplace.toggled.connect(replace.setVisible) - replace.setVisible(False) \ No newline at end of file + expandReplace.toggled.connect(repl__l.setVisible) + expandReplace.toggled.connect(ft_incl.setVisible) + expandReplace.toggled.connect(ft_excl.setVisible) + expandReplace.toggled.connect(ft_in_l.setVisible) + expandReplace.toggled.connect(ft_ex_l.setVisible) + + self._results_tree = res_tree + self._search_le = search + self._replace_le = replace + self._files_inc_le = ft_incl + self._files_exc_le = ft_excl + + btn_search.clicked.connect(self._search) + search.returnPressed.connect(self._search) + res_tree.itemActivated.connect(self._activated) + + @ttk.pyTTkSlot(ttk.TTkTreeWidgetItem, int) + def _activated(self, item:ttk.TTkTreeWidgetItem, _): + if isinstance(item, _MatchTreeWidgetItem): + file = item.path() + line = item.lineNumber() + ttkodeProxy.ttkode()._openFile(file, line) + + + @ttk.pyTTkSlot() + def _search(self): + self._runId += 1 + search_pattern = str(self._search_le.text()) + if not search_pattern: + return + def _search_threading(): + self._results_tree.clear() + group = [] + groupSize = 1 + for (file,root,matches) in self._search_files('.',str(search_pattern),self._runId): + ttk.TTkLog.debug((file,matches)) + item = ttk.TTkTreeWidgetItem([ + ttk.TTkString( + ttk.TTkCfg.theme.fileIcon.getIcon(file), + ttk.TTkCfg.theme.fileIconColor) + " " + + ttk.TTkString(f" {file} ", ttk.TTkColor.YELLOW+ttk.TTkColor.BOLD) + + ttk.TTkString(f" {root} ", ttk.TTkColor.fg("#888888")) + ],expanded=True) + for num,line in matches: + item.addChild( + _MatchTreeWidgetItem([ + ttk.TTkString(str(num)+" ",ttk.TTkColor.CYAN) + + ttk.TTkString(line.replace('\n','')).completeColor( + match=search_pattern, + color=ttk.TTkColor.GREEN) + ], + match=line, + lineNumber=num, + path=os.path.join(root,file))) + group.append(item) + if len(group) > groupSize: + self._results_tree.addTopLevelItems(group) + group = [] + groupSize <<= 1 + # self._results_tree.addTopLevelItem(item) + if group: + self._results_tree.addTopLevelItems(group) + Thread(target=_search_threading).start() + + def _search_files(self, root_folder, match, runId): + matches = [] + for root, file in _custom_walk(root_folder): + if runId != self._runId: + return + if True: # file.endswith('.py'): # file.endswith(file_extension): + file_path = os.path.join(root, file) + if not is_text_file(file_path): + continue + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + line_matches = [(i, line.split('\n')[0]) for i, line in enumerate(lines) if match in line] + if line_matches: + yield (file, root, line_matches) + + def _search_files_re(self, root_folder, file_extension, search_pattern): + matches = [] + regex = re.compile(search_pattern) + for root, dirs, files in os.walk(root_folder): + for file in files: + if True: # file.endswith(file_extension): + file_path = os.path.join(root, file) + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + line_matches = [i + 1 for i, line in enumerate(lines) if regex.search(line)] + if line_matches: + yield (file_path, line_matches) +