Last active
May 21, 2026 09:15
-
-
Save Hammer2900/6807b471cbcd6ad88c5fc020a82335f7 to your computer and use it in GitHub Desktop.
tk filemanger widget
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import os | |
| import sys | |
| import shutil | |
| import platform | |
| import subprocess | |
| from datetime import datetime | |
| import tkinter as tk | |
| from tkinter import ttk, messagebox, simpledialog | |
| def resource_path(file_name): | |
| base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) | |
| return os.path.join(base_path, file_name) | |
| class FileMateApp: | |
| # --- НАСТРОЙКА КНОПОК КЛАССИЧЕСКОГО МЕНЕДЖЕРА --- | |
| # Формат: (Клавиша, Текст кнопки, Метод или Терминальная команда) | |
| # Если команда — строка, она выполнится в терминале, а вместо {file} подставится путь. | |
| BOTTOM_ACTIONS = [ | |
| ('F3', 'F3 View', 'internal_view'), # Встроенный просмотрщик файлов | |
| ('F4', 'F4 Edit', 'internal_edit'), # Открыть в системном редакторе | |
| ('F5', 'F5 Copy', 'internal_copy'), # Скопировать файл/папку | |
| ('F6', 'F6 Move', 'internal_move'), # Переместить/Переименовать | |
| ('F7', 'F7 MkDir', 'create_folder'), # Создать папку | |
| ('F8', 'F8 Delete', 'delete_item'), # Удалить | |
| ] | |
| def __init__(self, root): | |
| self.root = root | |
| self.root.title('FileMate') | |
| self.root.geometry('1100x750') | |
| self.root.minsize(900, 600) | |
| # Переменные состояния | |
| self.dark_mode_var = tk.BooleanVar(value=False) | |
| self.current_path_var = tk.StringVar(value=os.path.expanduser('~')) | |
| self.status_var = tk.StringVar(value='Ready') | |
| self.filter_var = tk.StringVar(value='') | |
| # Кэш списка файлов для быстрого поиска на лету | |
| self.cached_folders = [] # Список кортежей (имя, дата_изменения) | |
| self.cached_files = [] # Список кортежей (имя, размер, дата_изменения) | |
| # Стилизация | |
| self.style = ttk.Style() | |
| # Строим UI | |
| self.create_widgets() | |
| self.apply_theme() | |
| # Читаем файлы | |
| self.refresh_file_list() | |
| def set_status(self, msg): | |
| """Обновляет текст в статус-баре.""" | |
| self.status_var.set(msg) | |
| self.root.update_idletasks() | |
| def create_widgets(self): | |
| # Главный контейнер | |
| self.main_frame = ttk.Frame(self.root, padding=15) | |
| self.main_frame.pack(expand=True, fill='both') | |
| # 1. Верхняя панель (Toolbar) | |
| self.toolbar = ttk.Frame(self.main_frame) | |
| self.toolbar.pack(fill='x', pady=(0, 10)) | |
| self.btn_up = ttk.Button(self.toolbar, text='▲ Up (Backspace)', command=self.go_up) | |
| self.btn_up.pack(side='left', padx=(0, 5)) | |
| self.btn_refresh = ttk.Button(self.toolbar, text='↻ Refresh (F5)', command=self.refresh_file_list) | |
| self.btn_refresh.pack(side='left', padx=5) | |
| self.theme_check = ttk.Checkbutton( | |
| self.toolbar, | |
| text='Dark Theme', | |
| variable=self.dark_mode_var, | |
| command=self.apply_theme, | |
| style='Switch.TCheckbutton', | |
| ) | |
| self.theme_check.pack(side='right', padx=5) | |
| # 2. Адресная строка | |
| self.path_frame = ttk.Frame(self.main_frame) | |
| self.path_frame.pack(fill='x', pady=(0, 10)) | |
| self.lbl_path = ttk.Label(self.path_frame, text='Path:', font=('Segoe UI', 10, 'bold')) | |
| self.lbl_path.pack(side='left', padx=(0, 5)) | |
| self.entry_path = ttk.Entry(self.path_frame, textvariable=self.current_path_var, font=('Segoe UI', 10)) | |
| self.entry_path.pack(side='left', expand=True, fill='x', padx=5) | |
| self.entry_path.bind('<Return>', lambda event: self.refresh_file_list(self.current_path_var.get())) | |
| self.btn_go = ttk.Button( | |
| self.path_frame, text='Go →', command=lambda: self.refresh_file_list(self.current_path_var.get()), width=8 | |
| ) | |
| self.btn_go.pack(side='left', padx=(5, 0)) | |
| # 3. Скрытая по умолчанию панель фильтрации (показывается по нажатию "F") | |
| self.filter_frame = ttk.Frame(self.main_frame) | |
| # Отрисовывается динамически при активации | |
| self.lbl_filter = ttk.Label(self.filter_frame, text='🔍 Filter:', font=('Segoe UI', 10, 'bold')) | |
| self.lbl_filter.pack(side='left', padx=(0, 5)) | |
| self.entry_filter = ttk.Entry(self.filter_frame, textvariable=self.filter_var, font=('Segoe UI', 10)) | |
| self.entry_filter.pack(side='left', expand=True, fill='x', padx=5) | |
| self.entry_filter.bind('<KeyRelease>', lambda event: self.apply_filter()) | |
| self.entry_filter.bind('<Escape>', lambda event: self.deactivate_filter()) | |
| # 4. Дерево файлов | |
| self.file_frame = ttk.Frame(self.main_frame) | |
| self.file_frame.pack(expand=True, fill='both', pady=(0, 10)) | |
| columns = ('name', 'type', 'size', 'modified') | |
| self.tree = ttk.Treeview(self.file_frame, columns=columns, show='headings', selectmode='browse') | |
| self.tree.heading('name', text='Name', anchor='w') | |
| self.tree.heading('type', text='Type', anchor='w') | |
| self.tree.heading('size', text='Size', anchor='e') | |
| self.tree.heading('modified', text='Date Modified', anchor='w') | |
| self.tree.column('name', stretch=True, width=400, anchor='w') | |
| self.tree.column('type', stretch=False, width=120, anchor='w') | |
| self.tree.column('size', stretch=False, width=100, anchor='e') | |
| self.tree.column('modified', stretch=False, width=150, anchor='w') | |
| self.scrollbar = ttk.Scrollbar(self.file_frame, orient='vertical', command=self.tree.yview) | |
| self.tree.configure(yscrollcommand=self.scrollbar.set) | |
| self.tree.pack(side='left', expand=True, fill='both') | |
| self.scrollbar.pack(side='right', fill='y') | |
| # 5. Классические кнопки действий снизу (Classic Commander Buttons) | |
| self.bottom_bar = ttk.Frame(self.main_frame) | |
| self.bottom_bar.pack(fill='x', pady=(5, 0)) | |
| self.build_bottom_buttons() | |
| # 6. Статус-бар | |
| self.status_bar = ttk.Label( | |
| self.root, textvariable=self.status_var, relief='sunken', anchor='w', padding=(10, 5) | |
| ) | |
| self.status_bar.pack(side='bottom', fill='x') | |
| # Контекстное меню по правому клику | |
| self.context_menu = tk.Menu(self.root, tearoff=0) | |
| self.context_menu.add_command(label='Open', command=self.open_selected) | |
| self.context_menu.add_command(label='Rename (F2)', command=self.rename_item) | |
| self.context_menu.add_separator() | |
| self.context_menu.add_command(label='Delete (Delete)', command=self.delete_item) | |
| # Глобальные бинды клавиатуры | |
| self.tree.bind('<Double-1>', lambda event: self.open_selected()) | |
| self.tree.bind('<Button-3>', self.show_context_menu) | |
| self.tree.bind('<Return>', lambda event: self.open_selected()) | |
| self.tree.bind('<BackSpace>', lambda event: self.go_up()) | |
| # Активация фильтрации по клавише F или / | |
| self.tree.bind('<Key>', self.handle_tree_keypress) | |
| def build_bottom_buttons(self): | |
| """Динамически генерирует нижние кнопки на основе конфигурации BOTTOM_ACTIONS.""" | |
| for widget in self.bottom_bar.winfo_children(): | |
| widget.destroy() | |
| # Равномерно распределяем кнопки по сетке grid | |
| self.bottom_bar.columnconfigure(list(range(len(self.BOTTOM_ACTIONS))), weight=1) | |
| for idx, (key, label, command) in enumerate(self.BOTTOM_ACTIONS): | |
| btn_callback = self.make_action_callback(command) | |
| # Кнопка UI | |
| btn = ttk.Button(self.bottom_bar, text=label, command=btn_callback) | |
| btn.grid(row=0, column=idx, padx=2, sticky='ew') | |
| # Привязываем горячую клавишу (F3, F4, ...) | |
| self.root.bind(f'<{key}>', lambda event, cb=btn_callback: cb()) | |
| def make_action_callback(self, command): | |
| """Определяет, запустить ли метод класса или исполнить внешнюю shell-команду.""" | |
| if hasattr(self, command): | |
| return getattr(self, command) | |
| # Если это внешняя shell-команда | |
| def shell_callback(): | |
| path = self.get_selected_path() | |
| if not path: | |
| self.set_status('No file selected for command!') | |
| return | |
| formatted_cmd = command.replace('{file}', f'"{path}"') | |
| try: | |
| subprocess.Popen(formatted_cmd, shell=True) | |
| self.set_status(f'Executed: {formatted_cmd}') | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Failed to execute command:\n{e}') | |
| return shell_callback | |
| # --- СИСТЕМА ФИЛЬТРАЦИИ --- | |
| def handle_tree_keypress(self, event): | |
| """Перехватывает нажатия букв на дереве для быстрого поиска.""" | |
| if event.char in ('f', 'F', '/') and not event.state & 0x4: # 0x4 - Ctrl | |
| self.activate_filter() | |
| return 'break' | |
| def activate_filter(self): | |
| """Показывает панель фильтра и передает туда фокус.""" | |
| self.filter_frame.pack(after=self.path_frame, fill='x', pady=(0, 10)) | |
| self.entry_filter.focus_set() | |
| self.entry_filter.select_range(0, tk.END) | |
| self.set_status('Filter active. Type to search, Press Esc to close.') | |
| def deactivate_filter(self): | |
| """Закрывает фильтрацию и возвращает фокус на дерево файлов.""" | |
| self.filter_var.set('') | |
| self.filter_frame.pack_forget() | |
| self.apply_filter() | |
| self.tree.focus_set() | |
| self.set_status('Filter closed.') | |
| def apply_filter(self): | |
| """Обновляет элементы в дереве на основе поискового запроса (без чтения диска).""" | |
| query = self.filter_var.get().lower().strip() | |
| # Очищаем таблицу | |
| for item in self.tree.get_children(): | |
| self.tree.delete(item) | |
| # Выводим отфильтрованные папки | |
| for folder, mod_time in self.cached_folders: | |
| if not query or query in folder.lower(): | |
| self.tree.insert('', 'end', values=(f'📁 {folder}', 'Folder', '', mod_time)) | |
| # Выводим отфильтрованные файлы | |
| for file, size, mod_time in self.cached_files: | |
| if not query or query in file.lower(): | |
| ext = os.path.splitext(file)[1].upper() or 'File' | |
| self.tree.insert('', 'end', values=(f'📄 {file}', ext, size, mod_time)) | |
| # --- РАБОТА С ФАЙЛОВОЙ СИСТЕМОЙ --- | |
| def refresh_file_list(self, path=None): | |
| """Читает файлы с диска и сохраняет в локальный кэш.""" | |
| path = path or self.current_path_var.get() | |
| if not os.path.exists(path): | |
| messagebox.showerror('Error', f'Path does not exist:\n{path}') | |
| return | |
| self.current_path_var.set(path) | |
| self.cached_folders.clear() | |
| self.cached_files.clear() | |
| try: | |
| entries = os.listdir(path) | |
| except PermissionError: | |
| messagebox.showerror('Error', 'Permission denied.') | |
| return | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Could not read directory:\n{e}') | |
| return | |
| for item in entries: | |
| full_path = os.path.join(path, item) | |
| try: | |
| stat = os.stat(full_path) | |
| mod_time = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M') | |
| if os.path.isdir(full_path): | |
| self.cached_folders.append((item, mod_time)) | |
| else: | |
| self.cached_files.append((item, stat.st_size, mod_time)) | |
| except Exception: | |
| if os.path.isdir(full_path): | |
| self.cached_folders.append((item, 'Unknown')) | |
| else: | |
| self.cached_files.append((item, 0, 'Unknown')) | |
| # Сортируем кэш | |
| self.cached_folders.sort(key=lambda x: x[0].lower()) | |
| self.cached_files.sort(key=lambda x: x[0].lower()) | |
| # Форматируем размеры перед кэшированием показа | |
| formatted_files = [] | |
| for file, size, mod_time in self.cached_files: | |
| size_str = self.format_size(size) if size > 0 else '0 B' | |
| formatted_files.append((file, size_str, mod_time)) | |
| self.cached_files = formatted_files | |
| # Отрисовываем | |
| self.apply_filter() | |
| self.set_status(f'Loaded {len(self.cached_folders)} folders, {len(self.cached_files)} files.') | |
| # --- РЕАЛИЗАЦИЯ КЛАССИЧЕСКИХ ДЕЙСТВИЙ (F3-F8) --- | |
| def internal_view(self): | |
| """F3: Быстрый внутренний просмотрщик текстовых файлов.""" | |
| path = self.get_selected_path() | |
| if not path or os.path.isdir(path): | |
| self.set_status('Cannot view a directory.') | |
| return | |
| try: | |
| with open(path, 'r', encoding='utf-8', errors='replace') as f: | |
| content = f.read(50000) # Ограничение в 50КБ | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Could not read file:\n{e}') | |
| return | |
| # Окно быстрого просмотра | |
| viewer = tk.Toplevel(self.root) | |
| viewer.title(f'Quick View - {os.path.basename(path)}') | |
| viewer.geometry('800x600') | |
| txt_area = tk.Text(viewer, wrap='word', font=('Consolas', 10)) | |
| txt_area.insert('1.0', content) | |
| txt_area.configure(state='disabled') | |
| txt_area.pack(expand=True, fill='both') | |
| view_scroll = ttk.Scrollbar(txt_area, orient='vertical', command=txt_area.yview) | |
| txt_area.configure(yscrollcommand=view_scroll.set) | |
| view_scroll.pack(side='right', fill='y') | |
| def internal_edit(self): | |
| """F4: Открытие файла в системном текстовом редакторе.""" | |
| path = self.get_selected_path() | |
| if not path or os.path.isdir(path): | |
| return | |
| try: | |
| if sys.platform == 'win32': | |
| os.startfile(path) | |
| elif sys.platform == 'darwin': | |
| subprocess.call(['open', '-e', path]) | |
| else: | |
| subprocess.call(['xdg-open', path]) | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Failed to edit file:\n{e}') | |
| def internal_copy(self): | |
| """F5: Копирование элемента.""" | |
| path = self.get_selected_path() | |
| if not path: | |
| return | |
| dest_dir = simpledialog.askstring('Copy', 'Enter destination path:', initialvalue=self.current_path_var.get()) | |
| if not dest_dir: | |
| return | |
| dest_path = os.path.join(dest_dir, os.path.basename(path)) | |
| try: | |
| if os.path.isdir(path): | |
| shutil.copytree(path, dest_path) | |
| else: | |
| shutil.copy2(path, dest_path) | |
| self.refresh_file_list() | |
| self.set_status(f'Copied to {dest_path}') | |
| except Exception as e: | |
| messagebox.showerror('Copy Error', str(e)) | |
| def internal_move(self): | |
| """F6: Перемещение / Переименование.""" | |
| path = self.get_selected_path() | |
| if not path: | |
| return | |
| dest_dir = simpledialog.askstring( | |
| 'Move / Rename', 'Enter destination path or new name:', initialvalue=self.current_path_var.get() | |
| ) | |
| if not dest_dir: | |
| return | |
| if not os.path.isabs(dest_dir) and not dest_dir.startswith('.'): | |
| dest_path = os.path.join(self.current_path_var.get(), dest_dir) | |
| else: | |
| dest_path = os.path.join(dest_dir, os.path.basename(path)) | |
| try: | |
| shutil.move(path, dest_path) | |
| self.refresh_file_list() | |
| self.set_status(f'Moved to {dest_path}') | |
| except Exception as e: | |
| messagebox.showerror('Move Error', str(e)) | |
| # --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- | |
| def format_size(self, size_in_bytes): | |
| for unit in ['B', 'KB', 'MB', 'GB', 'TB']: | |
| if size_in_bytes < 1024.0: | |
| return f'{size_in_bytes:.1f} {unit}' | |
| size_in_bytes /= 1024.0 | |
| return f'{size_in_bytes:.1f} PB' | |
| def get_selected_path(self): | |
| selected_item = self.tree.selection() | |
| if not selected_item: | |
| return None | |
| values = self.tree.item(selected_item[0], 'values') | |
| if not values: | |
| return None | |
| raw_name = values[0][2:] | |
| return os.path.join(self.current_path_var.get(), raw_name) | |
| def open_selected(self): | |
| path = self.get_selected_path() | |
| if not path: | |
| return | |
| if os.path.isdir(path): | |
| self.deactivate_filter() | |
| self.refresh_file_list(path) | |
| else: | |
| try: | |
| self.set_status(f'Opening {os.path.basename(path)}...') | |
| if sys.platform == 'win32': | |
| os.startfile(path) | |
| elif sys.platform == 'darwin': | |
| subprocess.call(['open', path]) | |
| else: | |
| subprocess.call(['xdg-open', path]) | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Cannot open file:\n{e}') | |
| def go_up(self): | |
| parent = os.path.dirname(self.current_path_var.get()) | |
| if parent != self.current_path_var.get(): | |
| self.deactivate_filter() | |
| self.refresh_file_list(parent) | |
| def create_folder(self): | |
| folder_name = simpledialog.askstring('Create Folder (F7)', 'Enter folder name:') | |
| if folder_name: | |
| path = os.path.join(self.current_path_var.get(), folder_name) | |
| try: | |
| os.makedirs(path, exist_ok=False) | |
| self.refresh_file_list() | |
| self.set_status(f"Folder '{folder_name}' created") | |
| except FileExistsError: | |
| messagebox.showerror('Error', 'Folder already exists.') | |
| except Exception as e: | |
| messagebox.showerror('Error', str(e)) | |
| def delete_item(self): | |
| path = self.get_selected_path() | |
| if not path: | |
| return | |
| item_name = os.path.basename(path) | |
| if messagebox.askyesno( | |
| 'Confirm Delete (F8)', f"Are you sure you want to delete '{item_name}'?\nThis action cannot be undone." | |
| ): | |
| try: | |
| if os.path.isdir(path): | |
| shutil.rmtree(path) | |
| else: | |
| os.remove(path) | |
| self.refresh_file_list() | |
| self.set_status(f"Deleted '{item_name}'") | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Failed to delete item:\n{e}') | |
| def rename_item(self): | |
| path = self.get_selected_path() | |
| if not path: | |
| return | |
| old_name = os.path.basename(path) | |
| new_name = simpledialog.askstring('Rename', f"Enter new name for '{old_name}':", initialvalue=old_name) | |
| if new_name and new_name != old_name: | |
| new_path = os.path.join(self.current_path_var.get(), new_name) | |
| try: | |
| os.rename(path, new_path) | |
| self.refresh_file_list() | |
| self.set_status(f"Renamed to '{new_name}'") | |
| except Exception as e: | |
| messagebox.showerror('Error', f'Failed to rename:\n{e}') | |
| def show_context_menu(self, event): | |
| item = self.tree.identify_row(event.y) | |
| if item: | |
| self.tree.selection_set(item) | |
| self.context_menu.post(event.x_root, event.y_root) | |
| def apply_theme(self): | |
| dark = self.dark_mode_var.get() | |
| if dark: | |
| bg_color = '#1e1e1e' | |
| fg_color = '#ffffff' | |
| field_bg = '#2d2d2d' | |
| select_bg = '#007acc' | |
| text_color = '#e0e0e0' | |
| self.root.configure(bg=bg_color) | |
| self.style.theme_use('clam') | |
| self.style.configure('.', background=bg_color, foreground=fg_color, borderwidth=0) | |
| self.style.configure('TFrame', background=bg_color) | |
| self.style.configure('TLabel', background=bg_color, foreground=fg_color, font=('Segoe UI', 10)) | |
| self.style.configure( | |
| 'TEntry', fieldbackground=field_bg, foreground=fg_color, insertcolor=fg_color, borderwidth=1 | |
| ) | |
| self.style.configure( | |
| 'Treeview', | |
| background=field_bg, | |
| fieldbackground=field_bg, | |
| foreground=text_color, | |
| rowheight=25, | |
| font=('Segoe UI', 10), | |
| ) | |
| self.style.map('Treeview', background=[('selected', select_bg)], foreground=[('selected', '#ffffff')]) | |
| self.style.configure( | |
| 'Treeview.Heading', | |
| background='#3c3c3c', | |
| foreground=fg_color, | |
| relief='flat', | |
| font=('Segoe UI', 10, 'bold'), | |
| ) | |
| self.style.map('Treeview.Heading', background=[('active', '#4e4e4e')]) | |
| self.style.configure( | |
| 'TButton', background='#3c3c3c', foreground=fg_color, borderwidth=1, focuscolor=select_bg | |
| ) | |
| self.style.map('TButton', background=[('active', '#4e4e4e')]) | |
| self.context_menu.configure( | |
| bg=field_bg, fg=fg_color, activebackground=select_bg, activeforeground='#ffffff' | |
| ) | |
| self.set_status('Theme: Dark') | |
| else: | |
| bg_color = '#f5f5f7' | |
| fg_color = '#000000' | |
| field_bg = '#ffffff' | |
| select_bg = '#0078d4' | |
| text_color = '#333333' | |
| self.root.configure(bg=bg_color) | |
| self.style.theme_use('clam') | |
| self.style.configure('.', background=bg_color, foreground=fg_color, borderwidth=0) | |
| self.style.configure('TFrame', background=bg_color) | |
| self.style.configure('TLabel', background=bg_color, foreground=fg_color, font=('Segoe UI', 10)) | |
| self.style.configure( | |
| 'TEntry', fieldbackground=field_bg, foreground=fg_color, insertcolor=fg_color, borderwidth=1 | |
| ) | |
| self.style.configure( | |
| 'Treeview', | |
| background=field_bg, | |
| fieldbackground=field_bg, | |
| foreground=text_color, | |
| rowheight=25, | |
| font=('Segoe UI', 10), | |
| ) | |
| self.style.map('Treeview', background=[('selected', select_bg)], foreground=[('selected', '#ffffff')]) | |
| self.style.configure( | |
| 'Treeview.Heading', | |
| background='#e1e1e1', | |
| foreground=fg_color, | |
| relief='flat', | |
| font=('Segoe UI', 10, 'bold'), | |
| ) | |
| self.style.map('Treeview.Heading', background=[('active', '#cfcfcf')]) | |
| self.style.configure( | |
| 'TButton', background='#e1e1e1', foreground=fg_color, borderwidth=1, focuscolor=select_bg | |
| ) | |
| self.style.map('TButton', background=[('active', '#d0d0d0')]) | |
| self.context_menu.configure( | |
| bg=field_bg, fg=fg_color, activebackground=select_bg, activeforeground='#ffffff' | |
| ) | |
| self.set_status('Theme: Light') | |
| if __name__ == '__main__': | |
| root = tk.Tk() | |
| app = FileMateApp(root) | |
| root.mainloop() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment