Skip to content

Commit cc3b357

Browse files
committed
feat: enhance clipboard history with fuzzy search and paste simulation
- Introduced `fuzzy_search` utility for improved search functionality. - Replaced manual filtering logic in `update_filtered_items` with `fuzzy_search`. - Added `with_paste_simulation` parameter to `copy_selected_item_to_clipboard` and `on_row_activated` for optional paste simulation. - Improved keyboard navigation logic for list rows, including support for Page Up/Down. - Updated help window to include "Shift + Enter" for copy and paste functionality. - Adjusted logging levels for better debugging and reduced verbosity. - Enhanced error handling and user feedback during copy operations.
1 parent 636dfa6 commit cc3b357

File tree

4 files changed

+206
-69
lines changed

4 files changed

+206
-69
lines changed

clipse_gui/controller.py

Lines changed: 87 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .image_handler import ImageHandler
2525
from .ui_components import create_list_row_widget, show_help_window, show_preview_window
2626
from .ui_builder import build_main_window_content
27+
from .utils import fuzzy_search
2728

2829
from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
2930

@@ -48,7 +49,7 @@ def __init__(self, application_window: Gtk.ApplicationWindow):
4849
self._search_timer_id = None
4950
self._vadjustment_handler_id = None
5051
self._is_wayland = "wayland" in os.environ.get("XDG_SESSION_TYPE", "").lower()
51-
log.info(f"Detected session type: {'Wayland' if self._is_wayland else 'X11'}")
52+
log.debug(f"Detected session type: {'Wayland' if self._is_wayland else 'X11'}")
5253

5354
self.data_manager = DataManager(update_callback=self._on_history_updated)
5455
self.image_handler = ImageHandler(IMAGE_CACHE_MAX_SIZE or 50)
@@ -151,24 +152,15 @@ def _focus_first_item(self):
151152

152153
def update_filtered_items(self):
153154
"""Filters master list based on search and pin status, then updates UI."""
154-
self.filtered_items = []
155-
search_term_lower = self.search_term.lower()
156-
157-
for index, item in enumerate(self.items):
158-
is_pinned = item.get("pinned", False)
159-
if self.show_only_pinned and not is_pinned:
160-
continue
161-
162-
if search_term_lower:
163-
item_value = item.get("value", "").lower()
164-
match = search_term_lower in item_value
165-
if not match and item.get("filePath"):
166-
match = search_term_lower in item.get("filePath", "").lower()
167-
if not match:
168-
continue
169-
170-
self.filtered_items.append({"original_index": index, "item": item})
171155

156+
self.filtered_items = fuzzy_search(
157+
items=self.items,
158+
search_term=self.search_term,
159+
value_key="value",
160+
path_key="filePath",
161+
pinned_key="pinned",
162+
show_only_pinned=self.show_only_pinned,
163+
)
172164
self.populate_list_view()
173165
self.update_status_label()
174166
GLib.idle_add(self.check_load_more)
@@ -716,7 +708,7 @@ def copy_image_to_clipboard(self, image_path):
716708
self.flash_status(f"Error copying image: {str(e)[:100]}")
717709
return False
718710

719-
def copy_selected_item_to_clipboard(self):
711+
def copy_selected_item_to_clipboard(self, with_paste_simulation=False):
720712
"""Copies the selected item to the system clipboard and closes the window."""
721713
selected_row = self.list_box.get_selected_row()
722714
exit_timeout = 150
@@ -768,15 +760,19 @@ def close_window_callback(window):
768760
self.flash_status("Cannot copy null text value.")
769761

770762
if copy_successful:
771-
if ENTER_TO_PASTE:
763+
if ENTER_TO_PASTE or with_paste_simulation:
772764
log.debug("Hiding window and scheduling paste simulation.")
773765
self.window.hide()
774-
GLib.timeout_add(
775-
PASTE_SIMULATION_DELAY_MS or 150,
776-
self._trigger_paste_simulation_and_quit,
777-
)
766+
GLib.timeout_add(
767+
PASTE_SIMULATION_DELAY_MS or 150,
768+
self._trigger_paste_simulation_and_quit,
769+
)
770+
else:
771+
GLib.timeout_add(100, self._quit_application)
778772
else:
779-
GLib.timeout_add(100, self._quit_application)
773+
log.error("Copy operation failed.")
774+
self.flash_status("Error: Copy operation failed.")
775+
GLib.timeout_add(exit_timeout, close_window_callback, self.window)
780776

781777
except Exception as e:
782778
log.error(f"Unexpected error during copy selection: {e}", exc_info=True)
@@ -808,7 +804,6 @@ def _quit_application(self):
808804

809805
def paste_from_clipboard_simulated(self):
810806
"""Pastes FROM the clipboard by simulating key presses (Ctrl+V)."""
811-
print(self._is_wayland, "Is Wayland")
812807
if self._is_wayland:
813808
cmd_str = str(PASTE_SIMULATION_CMD_WAYLAND)
814809
tool_name = "wtype"
@@ -1030,49 +1025,80 @@ def on_key_press(self, widget, event):
10301025
return True
10311026

10321027
if keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down]:
1033-
if len(self.list_box.get_children()) > 0:
1034-
self.list_box.grab_focus()
1028+
focusable_elements = self.list_box.get_children()
1029+
if not focusable_elements:
1030+
return False
10351031

1036-
if keyval == Gdk.KEY_Down:
1037-
current = self.list_box.get_selected_row()
1038-
if current:
1039-
index = current.get_index()
1040-
next_row = self.list_box.get_row_at_index(index + 1)
1041-
if next_row:
1042-
self.list_box.select_row(next_row)
1043-
else:
1044-
first_row = self.list_box.get_row_at_index(0)
1045-
if first_row:
1046-
self.list_box.select_row(first_row)
1047-
elif keyval == Gdk.KEY_Up:
1048-
current = self.list_box.get_selected_row()
1049-
if current:
1050-
index = current.get_index()
1051-
if index > 0:
1052-
prev_row = self.list_box.get_row_at_index(index - 1)
1053-
if prev_row:
1054-
self.list_box.select_row(prev_row)
1055-
1056-
if keyval in [Gdk.KEY_Up, Gdk.KEY_Down]:
1057-
return True
1032+
current_focus = self.window.get_focus()
1033+
current_index = (
1034+
focusable_elements.index(current_focus)
1035+
if current_focus in focusable_elements
1036+
else -1
1037+
)
10581038

1059-
return False
1060-
return True
1039+
target_index = current_index
1040+
if keyval == Gdk.KEY_Down:
1041+
target_index = 0 if current_index == -1 else current_index + 1
1042+
elif keyval == Gdk.KEY_Up:
1043+
target_index = (
1044+
len(focusable_elements) - 1
1045+
if current_index == -1
1046+
else current_index - 1
1047+
)
1048+
elif keyval == Gdk.KEY_Page_Down:
1049+
target_index = (
1050+
0
1051+
if current_index == -1
1052+
else min(current_index + 5, len(focusable_elements) - 1)
1053+
)
1054+
elif keyval == Gdk.KEY_Page_Up:
1055+
target_index = (
1056+
len(focusable_elements) - 1
1057+
if current_index == -1
1058+
else max(current_index - 5, 0)
1059+
)
1060+
1061+
if 0 <= target_index < len(focusable_elements):
1062+
row = focusable_elements[target_index]
1063+
self.list_box.select_row(row)
1064+
row.grab_focus()
1065+
allocation = row.get_allocation()
1066+
adj = self.scrolled_window.get_vadjustment()
1067+
if adj:
1068+
adj.set_value(
1069+
min(allocation.y, adj.get_upper() - adj.get_page_size())
1070+
)
1071+
return True
1072+
1073+
return False
10611074

10621075
selected_row = self.list_box.get_selected_row()
10631076

1077+
if keyval == Gdk.KEY_Return:
1078+
if selected_row:
1079+
self.on_row_activated(self.list_box, shift and not ENTER_TO_PASTE)
1080+
elif self.list_box.get_children():
1081+
first_row = self.list_box.get_row_at_index(0)
1082+
if first_row:
1083+
self.list_box.select_row(first_row)
1084+
first_row.grab_focus()
1085+
self.on_row_activated(self.list_box)
1086+
else:
1087+
self.search_entry.grab_focus()
1088+
return True
1089+
10641090
# Navigation Aliases
10651091
if keyval == Gdk.KEY_k:
1066-
return self.list_box.emit(
1067-
"move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1, False
1068-
)
1092+
return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1)
10691093
if keyval == Gdk.KEY_j:
1070-
return self.list_box.emit(
1071-
"move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1, False
1072-
)
1094+
return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1)
10731095

10741096
# Actions
1075-
if keyval == Gdk.KEY_slash or keyval == Gdk.KEY_f:
1097+
if (
1098+
keyval == Gdk.KEY_slash
1099+
or keyval == Gdk.KEY_f
1100+
and not self.search_entry.has_focus()
1101+
):
10761102
self.search_entry.grab_focus()
10771103
self.search_entry.select_region(0, -1)
10781104
return True
@@ -1128,19 +1154,12 @@ def on_key_press(self, widget, event):
11281154
self.update_zoom()
11291155
return True
11301156

1131-
# Enter/Activation (let row-activated signal handle)
1132-
if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
1133-
if selected_row:
1134-
return False
1135-
else:
1136-
return True
1137-
11381157
return False
11391158

1140-
def on_row_activated(self, list_box, row):
1159+
def on_row_activated(self, row, with_paste_simulation=False):
11411160
"""Handles double-click or Enter on a list row."""
11421161
log.debug(f"Row activated: original_index={getattr(row, 'item_index', 'N/A')}")
1143-
self.copy_selected_item_to_clipboard()
1162+
self.copy_selected_item_to_clipboard(with_paste_simulation)
11441163

11451164
def on_search_changed(self, entry):
11461165
"""Handles changes in the search entry, debounced."""

clipse_gui/data_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def check_for_changes():
158158
changed = True
159159

160160
if changed:
161-
log.info(
161+
log.debug(
162162
f"History file change detected ({self.file_path}). Reloading..."
163163
)
164164
self._last_mtime = current_mtime

clipse_gui/ui_components.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def show_help_window(parent_window, close_cb):
150150
("Home", "Go to Top"),
151151
("End", "Go to Bottom (of loaded items)"),
152152
("Enter", "Copy selected item to clipboard"),
153+
("Shift + Enter", "Copy & Paste selected item in current app"),
153154
("Space", "Show full item preview"),
154155
("p", "Toggle pin status for selected item"),
155156
("x / Del", "Delete selected item"),

clipse_gui/utils.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,120 @@ def format_date(date_str):
3636
except Exception as e:
3737
print(f"Date formatting error for '{date_str}': {e}")
3838
return date_str # Return original string if format fails
39+
40+
41+
def fuzzy_search(
42+
items,
43+
search_term,
44+
value_key="value",
45+
path_key="filePath",
46+
pinned_key="pinned",
47+
show_only_pinned=False,
48+
):
49+
"""
50+
Performs a fuzzy search on a list of dictionary items.
51+
52+
Args:
53+
items (list): List of dictionaries containing items to search
54+
search_term (str): The search query string
55+
value_key (str): Dictionary key for the primary text to search within items
56+
path_key (str): Dictionary key for secondary text to search (like file paths)
57+
pinned_key (str): Dictionary key for pinned status
58+
show_only_pinned (bool): Whether to show only pinned items
59+
60+
Returns:
61+
list: Filtered items as dicts with format {"original_index": index, "item": item, "match_quality": score}
62+
"""
63+
filtered_items = []
64+
search_term_lower = search_term.lower() if search_term else ""
65+
66+
if not search_term_lower or (show_only_pinned and not search_term_lower):
67+
for index, item in enumerate(items):
68+
is_pinned = item.get(pinned_key, False)
69+
if show_only_pinned and not is_pinned:
70+
continue
71+
filtered_items.append({"original_index": index, "item": item})
72+
else:
73+
search_tokens = search_term_lower.split()
74+
75+
for index, item in enumerate(items):
76+
is_pinned = item.get(pinned_key, False)
77+
if show_only_pinned and not is_pinned:
78+
continue
79+
80+
item_value = item.get(value_key, "").lower()
81+
file_path = item.get(path_key, "").lower()
82+
83+
# Simple token matching
84+
all_tokens_match = True
85+
match_quality = 0
86+
87+
for token in search_tokens:
88+
# Perfect match gets highest score
89+
if token in item_value or token in file_path:
90+
match_quality += 100
91+
continue
92+
93+
# Check for partial matches (beginning of words)
94+
words_in_value = item_value.split()
95+
words_in_path = file_path.split() if file_path else []
96+
97+
partial_match = False
98+
for word in words_in_value + words_in_path:
99+
if word.startswith(token):
100+
match_quality += 75
101+
partial_match = True
102+
break
103+
elif token.startswith(word) and len(word) >= 3:
104+
match_quality += 60
105+
partial_match = True
106+
break
107+
108+
# Check for close matches (levenshtein-like approach)
109+
if not partial_match:
110+
# Simple character-level similarity
111+
best_similarity = 0
112+
for word in words_in_value + words_in_path:
113+
if len(word) > 2: # Only consider meaningful words
114+
# Calculate similarity by checking character overlap
115+
similarity = _calculate_similarity(word, token)
116+
best_similarity = max(best_similarity, similarity)
117+
118+
if best_similarity > 0.7: # 70% similarity threshold
119+
match_quality += int(best_similarity * 50)
120+
partial_match = True
121+
122+
# If this token doesn't match at all, item doesn't match search
123+
if not partial_match:
124+
all_tokens_match = False
125+
break
126+
127+
if all_tokens_match and match_quality > 0:
128+
filtered_items.append(
129+
{
130+
"original_index": index,
131+
"item": item,
132+
"match_quality": match_quality,
133+
}
134+
)
135+
136+
# Sort results by match quality
137+
filtered_items.sort(key=lambda x: x.get("match_quality", 0), reverse=True)
138+
139+
return filtered_items
140+
141+
142+
def _calculate_similarity(str1, str2):
143+
"""Calculate a simple character-based similarity between two strings."""
144+
set1, set2 = set(str1), set(str2)
145+
intersection = set1.intersection(set2)
146+
union = set1.union(set2)
147+
if not union:
148+
return 0.0
149+
basic_score = len(intersection) / len(union)
150+
len_ratio = (
151+
min(len(str1), len(str2)) / max(len(str1), len(str2))
152+
if max(len(str1), len(str2)) > 0
153+
else 0
154+
)
155+
return (basic_score * 0.7) + (len_ratio * 0.3)

0 commit comments

Comments
 (0)