Skip to content

Commit 873360d

Browse files
committed
feat(ui): integrate new widgets, services, and bridges
1 parent 43b5095 commit 873360d

File tree

10 files changed

+1390
-0
lines changed

10 files changed

+1390
-0
lines changed

ui/bridges/__init__.py

Whitespace-only changes.

ui/bridges/app_bridge.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
from PySide6.QtCore import QObject, Signal, Property, Slot, QUrl, QMimeData, Qt
2+
from PySide6.QtWidgets import QMenu
3+
from PySide6.QtGui import QCursor, QIcon, QDrag
4+
from ui.services.conflict_resolver import ConflictResolver
5+
from ui.dialogs.conflicts import ConflictAction
6+
from core.search_worker import SearchWorker
7+
from ui.models.shortcuts import ShortcutAction
8+
from core.gio_bridge.desktop import open_with_default_app as _open_file
9+
10+
11+
class AppBridge(QObject):
12+
pathChanged = Signal(str)
13+
targetCellWidthChanged = Signal(int)
14+
renameRequested = Signal(str)
15+
cutPathsChanged = Signal() # Emitted when clipboard cut state changes
16+
17+
# Search signals
18+
searchResultsFound = Signal(list) # Batch of paths
19+
searchFinished = Signal(int) # Total count
20+
searchError = Signal(str) # Error message
21+
22+
def __init__(self, main_window):
23+
super().__init__()
24+
self.mw = main_window
25+
self._target_cell_width = 75
26+
self._pending_select_paths = [] # Paths to select after next directory refresh
27+
self._pending_rename_path = None # Path to trigger rename after createFolder completes
28+
29+
# Initialize search worker
30+
self._search_worker = SearchWorker(self)
31+
self._search_worker.resultsFound.connect(self.searchResultsFound)
32+
self._search_worker.searchFinished.connect(self.searchFinished)
33+
self._search_worker.searchError.connect(self.searchError)
34+
35+
# Connect to clipboard changes
36+
self.mw.clipboard.clipboardChanged.connect(self._on_clipboard_changed)
37+
38+
def _on_clipboard_changed(self):
39+
self.cutPathsChanged.emit()
40+
41+
@Property(list, notify=cutPathsChanged)
42+
def cutPaths(self):
43+
"""Returns list of paths currently in cut state."""
44+
# Delegated to FileManager
45+
return self.mw.file_manager.get_cut_paths()
46+
47+
@Slot(list)
48+
def startDrag(self, paths):
49+
"""
50+
Initiates a system drag-and-drop operation for the given paths.
51+
This is a blocking call (exec) until drag ends.
52+
"""
53+
if not paths:
54+
return
55+
56+
drag = QDrag(self.mw)
57+
mime_data = QMimeData()
58+
59+
# Format as text/uri-list
60+
urls = [QUrl.fromLocalFile(p) for p in paths]
61+
mime_data.setUrls(urls)
62+
63+
drag.setMimeData(mime_data)
64+
65+
# VISUAL FEEDBACK: Create a pixmap that looks like a file/stack of files
66+
# We grab a standard icon from the theme
67+
icon = QIcon.fromTheme("text-x-generic")
68+
pixmap = icon.pixmap(64, 64)
69+
drag.setPixmap(pixmap)
70+
drag.setHotSpot(pixmap.rect().center())
71+
72+
# Execute Drag
73+
# Qt.MoveAction | Qt.CopyAction allows both. default to Copy.
74+
drag.exec(Qt.CopyAction | Qt.MoveAction, Qt.CopyAction)
75+
76+
@Slot(list, str)
77+
def handleDrop(self, urls, dest_dir=None):
78+
"""
79+
Handles files dropped onto the view or a folder.
80+
Delegated to FileManager.
81+
"""
82+
self.mw.file_manager.handle_drop(urls, dest_dir)
83+
84+
@Slot(str)
85+
def openPath(self, path):
86+
self.mw.navigate_to(path)
87+
88+
@Slot(list)
89+
def showContextMenu(self, paths):
90+
"""
91+
Shows a native QMenu using ActionManager actions.
92+
"""
93+
if not paths: return
94+
95+
menu = QMenu(self.mw)
96+
am = self.mw.action_manager
97+
98+
is_single = len(paths) == 1
99+
100+
# Open (single only) - we don't have an action for "Open" in AM yet (it's logic here)
101+
# But we can keep specific logic or move it.
102+
# For now, let's keep Open logic or create a one-off action.
103+
if is_single:
104+
act_open = menu.addAction(QIcon.fromTheme("document-open"), "Open")
105+
act_open.triggered.connect(lambda checked=False, p=paths[0]: _open_file(p))
106+
menu.addSeparator()
107+
108+
menu.addAction(am.get_action(ShortcutAction.COPY))
109+
menu.addAction(am.get_action(ShortcutAction.CUT))
110+
111+
# Paste needs enabled check
112+
act_paste = am.get_action(ShortcutAction.PASTE)
113+
act_paste.setEnabled(self.mw.file_manager.get_clipboard_files() != [])
114+
menu.addAction(act_paste)
115+
116+
menu.addSeparator()
117+
118+
if is_single:
119+
menu.addAction(am.get_action(ShortcutAction.RENAME))
120+
121+
menu.addSeparator()
122+
menu.addAction(am.get_action(ShortcutAction.TRASH))
123+
124+
menu.exec(QCursor.pos())
125+
126+
@Slot()
127+
def showBackgroundContextMenu(self):
128+
"""Shows a context menu for empty space."""
129+
menu = QMenu(self.mw)
130+
am = self.mw.action_manager
131+
132+
act_paste = am.get_action(ShortcutAction.PASTE)
133+
act_paste.setEnabled(self.mw.file_manager.get_clipboard_files() != [])
134+
menu.addAction(act_paste)
135+
136+
menu.addSeparator()
137+
menu.addAction(am.get_action(ShortcutAction.NEW_FOLDER))
138+
139+
menu.exec(QCursor.pos())
140+
141+
# _create_new_folder moved to FileManager
142+
143+
@Slot(str, result=str)
144+
def getThumbnailPath(self, path: str) -> str:
145+
"""
146+
[DEPRECATED] Check if a native GNOME thumbnail exists for the file.
147+
Returns direct file:// URL if cached, else fallback to image:// provider.
148+
149+
NOTE: This method is no longer called from QML during scroll.
150+
Thumbnail URL resolution has been moved to RowBuilder._resolve_thumbnail_url()
151+
which pre-computes the URL at load time, eliminating blocking I/O from
152+
the render path. Kept for backward compatibility.
153+
"""
154+
import hashlib
155+
import urllib.parse
156+
from pathlib import Path
157+
158+
try:
159+
# 1. Construct canonical URI (file:///path/to/file)
160+
# Must be quote-encoded (e.g. " " -> "%20")
161+
uri = "file://" + urllib.parse.quote(path)
162+
163+
# 2. GNOME Thumbnail Spec: MD5 of URI
164+
md5_hash = hashlib.md5(uri.encode('utf-8')).hexdigest()
165+
166+
# 3. Check Large Cache (256px)
167+
# The standard location is ~/.cache/thumbnails/large/
168+
cache_dir = Path.home() / ".cache" / "thumbnails" / "large"
169+
thumb_path = cache_dir / f"{md5_hash}.png"
170+
171+
if thumb_path.exists():
172+
# SUCCESS: Return direct path to bypass Python loader
173+
return f"file://{thumb_path}"
174+
175+
except Exception as e:
176+
print(f"[AppBridge] Thumbnail lookup failed: {e}")
177+
178+
# FALLBACK: Ask the generator to make one
179+
return f"image://thumbnail/{path}"
180+
181+
@Slot(str, str)
182+
def renameFile(self, old_path, new_name):
183+
"""
184+
Renames a file. Called from QML after user finishes editing.
185+
"""
186+
if not old_path or not new_name:
187+
return
188+
189+
from gi.repository import Gio
190+
gfile = Gio.File.parse_name(old_path)
191+
parent = gfile.get_parent()
192+
if not parent:
193+
return
194+
195+
new_gfile = parent.get_child(new_name)
196+
new_path = new_gfile.get_path() or new_gfile.get_uri()
197+
198+
# Check if name actually changed
199+
if old_path == new_path:
200+
return
201+
202+
print(f"[AppBridge] Renaming '{old_path}' -> '{new_name}'")
203+
204+
# Creates a single-use resolver for this rename op
205+
resolver = ConflictResolver(self.mw)
206+
action, final_dest = resolver.resolve_rename(old_path, new_path)
207+
208+
if action == ConflictAction.CANCEL or action == ConflictAction.SKIP:
209+
return
210+
211+
# Overwrite or Rename logic
212+
final_gfile = Gio.File.parse_name(final_dest)
213+
final_name = final_gfile.get_basename()
214+
self.mw.file_ops.rename(old_path, final_name)
215+
216+
@Slot()
217+
def paste(self):
218+
self.mw.file_manager.paste_to_current()
219+
220+
def queueSelectionAfterRefresh(self, paths):
221+
"""Queue paths to be selected after the next directory refresh."""
222+
self._pending_select_paths = paths
223+
224+
def selectPendingPaths(self):
225+
"""Called after directory refresh to select queued files. Returns and clears the queue."""
226+
paths = self._pending_select_paths
227+
self._pending_select_paths = []
228+
return paths
229+
230+
@Property(int, notify=targetCellWidthChanged)
231+
def targetCellWidth(self):
232+
return self._target_cell_width
233+
234+
@targetCellWidth.setter
235+
def targetCellWidth(self, val):
236+
if self._target_cell_width != val:
237+
self._target_cell_width = val
238+
self.targetCellWidthChanged.emit(val)
239+
240+
@Slot(int)
241+
def zoom(self, delta):
242+
# FIX: Wheel Up (Positive) should Zoom In
243+
self.mw.view_manager.zoom_in() if delta > 0 else self.mw.view_manager.zoom_out()
244+
245+
# -------------------------------------------------------------------------
246+
# SEARCH API
247+
# -------------------------------------------------------------------------
248+
249+
@Slot(str, str, bool)
250+
def startSearch(self, directory: str, pattern: str, recursive: bool = True):
251+
"""
252+
Start a file search in the background.
253+
254+
Results are emitted via searchResultsFound signal in batches.
255+
"""
256+
self._search_worker.start_search(directory, pattern, recursive)
257+
258+
@Slot()
259+
def cancelSearch(self):
260+
"""Cancel the current search."""
261+
self._search_worker.cancel()
262+
263+
@Property(str, constant=True)
264+
def searchEngineName(self) -> str:
265+
"""Returns the name of the current search engine (fd or scandir)."""
266+
return self._search_worker.engine_name
267+

ui/services/__init__.py

Whitespace-only changes.

ui/services/properties_logic.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Properties Dialog & Logic
3+
4+
Self-contained module for File Properties.
5+
Consolidates:
6+
- FilePropertiesModel (Logic)
7+
- PropertiesDialog (UI - to be implemented/refactored if needed, currently just logic stub)
8+
"""
9+
10+
from PySide6.QtCore import QObject, Signal, Slot
11+
from core.gio_bridge.properties_worker import PropertiesWorker
12+
from core.metadata_utils import format_size
13+
14+
15+
class PropertiesLogic(QObject):
16+
"""
17+
Reads detailed file properties asynchronously.
18+
19+
Uses PropertiesWorker (QThreadPool) to avoid blocking the UI
20+
on network drives (FTP, SMB, MTP).
21+
"""
22+
propertiesReady = Signal(str, dict) # (path, properties_dict)
23+
24+
def __init__(self, parent=None):
25+
super().__init__(parent)
26+
self._worker = PropertiesWorker(self)
27+
self._worker.propertiesReady.connect(self._on_result)
28+
29+
def _on_result(self, path: str, props: dict):
30+
"""Forward the worker result to the UI."""
31+
self.propertiesReady.emit(path, props)
32+
33+
@Slot(str)
34+
def request_properties(self, path: str):
35+
"""Request properties for a single file (async, non-blocking)."""
36+
self._worker.enqueue(path)
37+
38+
@Slot(list)
39+
def request_properties_batch(self, paths: list):
40+
"""Request properties for multiple files (async, non-blocking)."""
41+
self._worker.enqueue_batch(paths)
42+
43+
@Slot(int, result=str)
44+
def format_size(self, size_bytes: int) -> str:
45+
return format_size(size_bytes)

0 commit comments

Comments
 (0)