clipsy 1.7.1__py3-none-any.whl → 1.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clipsy/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "1.7.1"
1
+ __version__ = "1.9.0"
2
2
  __app_name__ = "Clipsy"
clipsy/app.py CHANGED
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  import rumps
6
6
 
7
7
  from clipsy import __version__
8
- from clipsy.config import DB_PATH, IMAGE_DIR, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
8
+ from clipsy.config import DB_PATH, IMAGE_DIR, MAX_PINNED_ENTRIES, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
9
9
  from clipsy.models import ClipboardEntry, ContentType
10
10
  from clipsy.monitor import ClipboardMonitor
11
11
  from clipsy.storage import StorageManager
@@ -34,10 +34,24 @@ class ClipsyApp(rumps.App):
34
34
  None, # separator
35
35
  ]
36
36
 
37
- entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
38
37
  self._entry_ids.clear()
39
38
 
40
- if not entries:
39
+ # Add pinned submenu if there are pinned entries
40
+ pinned_entries = self._storage.get_pinned()
41
+ if pinned_entries:
42
+ pinned_menu = rumps.MenuItem("📌 Pinned")
43
+ for entry in pinned_entries:
44
+ pinned_menu.add(self._create_entry_menu_item(entry))
45
+ pinned_menu.add(None) # separator
46
+ pinned_menu.add(rumps.MenuItem("Clear Pinned", callback=self._on_clear_pinned))
47
+ self.menu.add(pinned_menu)
48
+ self.menu.add(None) # separator
49
+
50
+ entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
51
+ # Filter out pinned entries from recent list
52
+ entries = [e for e in entries if not e.pinned]
53
+
54
+ if not entries and not pinned_entries:
41
55
  self.menu.add(rumps.MenuItem("(No clipboard history)", callback=None))
42
56
  else:
43
57
  for entry in entries:
@@ -118,8 +132,20 @@ class ClipsyApp(rumps.App):
118
132
  if entry is None:
119
133
  return
120
134
 
135
+ # Check if Option key is held (for pin toggle)
136
+ try:
137
+ from AppKit import NSAlternateKeyMask, NSEvent
138
+
139
+ modifier_flags = NSEvent.modifierFlags()
140
+ if modifier_flags & NSAlternateKeyMask:
141
+ self._on_pin_toggle(entry)
142
+ return
143
+ except Exception:
144
+ pass # If we can't check modifiers, proceed with normal copy
145
+
121
146
  try:
122
147
  from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString
148
+ from Foundation import NSData
123
149
 
124
150
  pb = NSPasteboard.generalPasteboard()
125
151
 
@@ -128,11 +154,18 @@ class ClipsyApp(rumps.App):
128
154
  if entry.content_type == ContentType.TEXT and entry.text_content:
129
155
  pb.clearContents()
130
156
  pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
157
+ if entry.rtf_data:
158
+ rtf_ns_data = NSData.dataWithBytes_length_(entry.rtf_data, len(entry.rtf_data))
159
+ if rtf_ns_data:
160
+ pb.setData_forType_(rtf_ns_data, "public.rtf")
161
+ if entry.html_data:
162
+ html_ns_data = NSData.dataWithBytes_length_(entry.html_data, len(entry.html_data))
163
+ if html_ns_data:
164
+ pb.setData_forType_(html_ns_data, "public.html")
131
165
  self._monitor.sync_change_count()
132
166
  copied = True
133
167
 
134
168
  elif entry.content_type == ContentType.IMAGE and entry.image_path:
135
- from Foundation import NSData
136
169
  img_data = NSData.dataWithContentsOfFile_(entry.image_path)
137
170
  if img_data:
138
171
  pb.clearContents()
@@ -153,6 +186,32 @@ class ClipsyApp(rumps.App):
153
186
  except Exception:
154
187
  logger.exception("Error copying entry to clipboard")
155
188
 
189
+ def _on_pin_toggle(self, entry: ClipboardEntry) -> None:
190
+ """Toggle pin status for an entry."""
191
+ if entry.pinned:
192
+ # Unpin
193
+ self._storage.toggle_pin(entry.id)
194
+ rumps.notification("Clipsy", "", "Unpinned", sound=False)
195
+ else:
196
+ # Check if we can pin (not sensitive, under limit)
197
+ if entry.is_sensitive:
198
+ rumps.notification("Clipsy", "", "Cannot pin sensitive data", sound=False)
199
+ return
200
+
201
+ if self._storage.count_pinned() >= MAX_PINNED_ENTRIES:
202
+ rumps.notification("Clipsy", "", f"Maximum {MAX_PINNED_ENTRIES} pinned items", sound=False)
203
+ return
204
+
205
+ self._storage.toggle_pin(entry.id)
206
+ rumps.notification("Clipsy", "", "Pinned", sound=False)
207
+
208
+ self._refresh_menu()
209
+
210
+ def _on_clear_pinned(self, _sender) -> None:
211
+ """Clear all pinned entries."""
212
+ self._storage.clear_pinned()
213
+ self._refresh_menu()
214
+
156
215
  def _on_search(self, _sender) -> None:
157
216
  response = rumps.Window(
158
217
  message="Search clipboard history:",
clipsy/config.py CHANGED
@@ -11,6 +11,18 @@ MAX_ENTRIES = 500 # auto-purge threshold
11
11
  MAX_TEXT_SIZE = 1_000_000 # 1MB text limit
12
12
  MAX_IMAGE_SIZE = 10_000_000 # 10MB image limit
13
13
  PREVIEW_LENGTH = 60 # characters shown in menu item
14
- MENU_DISPLAY_COUNT = 10 # items shown in dropdown
14
+ def _parse_menu_display_count() -> int:
15
+ raw = os.environ.get("CLIPSY_MENU_DISPLAY_COUNT")
16
+ if raw is None:
17
+ return 10
18
+ try:
19
+ value = int(raw)
20
+ except ValueError:
21
+ return 10
22
+ return max(5, min(50, value))
23
+
24
+
25
+ MENU_DISPLAY_COUNT = _parse_menu_display_count()
15
26
  THUMBNAIL_SIZE = (32, 32) # pixels, for menu icon display
16
27
  REDACT_SENSITIVE = True # mask sensitive data in preview (API keys, passwords, etc.)
28
+ MAX_PINNED_ENTRIES = 5 # maximum number of pinned entries allowed
clipsy/models.py CHANGED
@@ -24,3 +24,5 @@ class ClipboardEntry:
24
24
  thumbnail_path: str | None = None
25
25
  is_sensitive: bool = False
26
26
  masked_preview: str | None = None
27
+ rtf_data: bytes | None = None
28
+ html_data: bytes | None = None
clipsy/monitor.py CHANGED
@@ -5,6 +5,16 @@ from pathlib import Path
5
5
 
6
6
  from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF, NSPasteboardTypeString, NSFilenamesPboardType
7
7
 
8
+ try:
9
+ from AppKit import NSPasteboardTypeRTF
10
+ except ImportError:
11
+ NSPasteboardTypeRTF = "public.rtf"
12
+
13
+ try:
14
+ from AppKit import NSPasteboardTypeHTML
15
+ except ImportError:
16
+ NSPasteboardTypeHTML = "public.html"
17
+
8
18
  from clipsy.config import IMAGE_DIR, MAX_IMAGE_SIZE, MAX_TEXT_SIZE, PREVIEW_LENGTH, REDACT_SENSITIVE, THUMBNAIL_SIZE
9
19
  from clipsy.models import ClipboardEntry, ContentType
10
20
  from clipsy.redact import detect_sensitive, mask_text
@@ -57,84 +67,122 @@ class ClipboardMonitor:
57
67
  return None
58
68
 
59
69
  if NSPasteboardTypeString in types:
60
- text = self._pasteboard.stringForType_(NSPasteboardTypeString)
61
- if text:
62
- text_bytes = text.encode("utf-8")
63
- if len(text_bytes) <= MAX_TEXT_SIZE:
64
- content_hash = compute_hash(text_bytes)
65
- preview = truncate_text(text, PREVIEW_LENGTH)
66
-
67
- # Detect sensitive data
68
- is_sensitive = False
69
- masked_preview = None
70
- if REDACT_SENSITIVE:
71
- matches = detect_sensitive(text)
72
- if matches:
73
- is_sensitive = True
74
- masked_preview = truncate_text(mask_text(text, matches), PREVIEW_LENGTH)
75
-
76
- return ClipboardEntry(
77
- id=None,
78
- content_type=ContentType.TEXT,
79
- text_content=text,
80
- image_path=None,
81
- preview=preview,
82
- content_hash=content_hash,
83
- byte_size=len(text_bytes),
84
- created_at=datetime.now(),
85
- is_sensitive=is_sensitive,
86
- masked_preview=masked_preview,
87
- )
70
+ entry = self._read_text()
71
+ if entry:
72
+ return entry
88
73
 
89
74
  for img_type in (NSPasteboardTypePNG, NSPasteboardTypeTIFF):
90
75
  if img_type in types:
91
- data = self._pasteboard.dataForType_(img_type)
92
- if data is None:
93
- continue
94
- img_bytes = bytes(data)
95
- if len(img_bytes) > MAX_IMAGE_SIZE:
96
- logger.warning("Image too large (%d bytes), skipping", len(img_bytes))
97
- return None
98
- content_hash = compute_hash(img_bytes)
99
- is_png = img_type == NSPasteboardTypePNG
100
- image_path, thumbnail_path = self._save_image(img_bytes, content_hash, is_png)
101
- width, height = get_image_dimensions(img_bytes)
102
- preview = f"[Image: {width}x{height}]" if width > 0 else "[Image]"
103
- return ClipboardEntry(
104
- id=None,
105
- content_type=ContentType.IMAGE,
106
- text_content=None,
107
- image_path=str(image_path),
108
- preview=preview,
109
- content_hash=content_hash,
110
- byte_size=len(img_bytes),
111
- created_at=datetime.now(),
112
- thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
113
- )
76
+ entry = self._read_image(img_type)
77
+ if entry:
78
+ return entry
114
79
 
115
80
  if NSFilenamesPboardType in types:
116
- filenames = self._pasteboard.propertyListForType_(NSFilenamesPboardType)
117
- if filenames:
118
- file_list = list(filenames)
119
- text = "\n".join(file_list)
120
- content_hash = compute_hash(text)
121
- if len(file_list) == 1:
122
- preview = truncate_text(Path(file_list[0]).name, PREVIEW_LENGTH)
123
- else:
124
- preview = truncate_text(f"{len(file_list)} files: {Path(file_list[0]).name}, ...", PREVIEW_LENGTH)
125
- return ClipboardEntry(
126
- id=None,
127
- content_type=ContentType.FILE,
128
- text_content=text,
129
- image_path=None,
130
- preview=preview,
131
- content_hash=content_hash,
132
- byte_size=len(text.encode("utf-8")),
133
- created_at=datetime.now(),
134
- )
81
+ return self._read_files()
135
82
 
136
83
  return None
137
84
 
85
+ def _read_text(self) -> ClipboardEntry | None:
86
+ text = self._pasteboard.stringForType_(NSPasteboardTypeString)
87
+ if not text:
88
+ return None
89
+
90
+ text_bytes = text.encode("utf-8")
91
+ if len(text_bytes) > MAX_TEXT_SIZE:
92
+ return None
93
+
94
+ content_hash = compute_hash(text_bytes)
95
+ preview = truncate_text(text, PREVIEW_LENGTH)
96
+
97
+ rtf_data = None
98
+ html_data = None
99
+ types = self._pasteboard.types()
100
+ if types:
101
+ if NSPasteboardTypeRTF in types:
102
+ data = self._pasteboard.dataForType_(NSPasteboardTypeRTF)
103
+ if data:
104
+ rtf_data = bytes(data)
105
+ if NSPasteboardTypeHTML in types:
106
+ data = self._pasteboard.dataForType_(NSPasteboardTypeHTML)
107
+ if data:
108
+ html_data = bytes(data)
109
+
110
+ is_sensitive = False
111
+ masked_preview = None
112
+ if REDACT_SENSITIVE:
113
+ matches = detect_sensitive(text)
114
+ if matches:
115
+ is_sensitive = True
116
+ masked_preview = truncate_text(mask_text(text, matches), PREVIEW_LENGTH)
117
+
118
+ return ClipboardEntry(
119
+ id=None,
120
+ content_type=ContentType.TEXT,
121
+ text_content=text,
122
+ image_path=None,
123
+ preview=preview,
124
+ content_hash=content_hash,
125
+ byte_size=len(text_bytes),
126
+ created_at=datetime.now(),
127
+ is_sensitive=is_sensitive,
128
+ masked_preview=masked_preview,
129
+ rtf_data=rtf_data,
130
+ html_data=html_data,
131
+ )
132
+
133
+ def _read_image(self, img_type) -> ClipboardEntry | None:
134
+ data = self._pasteboard.dataForType_(img_type)
135
+ if data is None:
136
+ return None
137
+
138
+ img_bytes = bytes(data)
139
+ if len(img_bytes) > MAX_IMAGE_SIZE:
140
+ logger.warning("Image too large (%d bytes), skipping", len(img_bytes))
141
+ return None
142
+
143
+ content_hash = compute_hash(img_bytes)
144
+ is_png = img_type == NSPasteboardTypePNG
145
+ image_path, thumbnail_path = self._save_image(img_bytes, content_hash, is_png)
146
+ width, height = get_image_dimensions(img_bytes)
147
+ preview = f"[Image: {width}x{height}]" if width > 0 else "[Image]"
148
+
149
+ return ClipboardEntry(
150
+ id=None,
151
+ content_type=ContentType.IMAGE,
152
+ text_content=None,
153
+ image_path=str(image_path),
154
+ preview=preview,
155
+ content_hash=content_hash,
156
+ byte_size=len(img_bytes),
157
+ created_at=datetime.now(),
158
+ thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
159
+ )
160
+
161
+ def _read_files(self) -> ClipboardEntry | None:
162
+ filenames = self._pasteboard.propertyListForType_(NSFilenamesPboardType)
163
+ if not filenames:
164
+ return None
165
+
166
+ file_list = list(filenames)
167
+ text = "\n".join(file_list)
168
+ content_hash = compute_hash(text)
169
+
170
+ if len(file_list) == 1:
171
+ preview = truncate_text(Path(file_list[0]).name, PREVIEW_LENGTH)
172
+ else:
173
+ preview = truncate_text(f"{len(file_list)} files: {Path(file_list[0]).name}, ...", PREVIEW_LENGTH)
174
+
175
+ return ClipboardEntry(
176
+ id=None,
177
+ content_type=ContentType.FILE,
178
+ text_content=text,
179
+ image_path=None,
180
+ preview=preview,
181
+ content_hash=content_hash,
182
+ byte_size=len(text.encode("utf-8")),
183
+ created_at=datetime.now(),
184
+ )
185
+
138
186
  def _save_image(self, img_bytes: bytes, content_hash: str, is_png: bool) -> tuple[Path, Path | None]:
139
187
  ext = ".png" if is_png else ".tiff"
140
188
  filename = content_hash[:12] + ext
clipsy/storage.py CHANGED
@@ -20,7 +20,9 @@ CREATE TABLE IF NOT EXISTS clipboard_entries (
20
20
  source_app TEXT,
21
21
  thumbnail_path TEXT,
22
22
  is_sensitive INTEGER NOT NULL DEFAULT 0,
23
- masked_preview TEXT
23
+ masked_preview TEXT,
24
+ rtf_data BLOB,
25
+ html_data BLOB
24
26
  );
25
27
 
26
28
  CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
@@ -70,12 +72,24 @@ class StorageManager:
70
72
  self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0")
71
73
  if "masked_preview" not in columns:
72
74
  self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN masked_preview TEXT")
75
+ if "rtf_data" not in columns:
76
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN rtf_data BLOB")
77
+ if "html_data" not in columns:
78
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN html_data BLOB")
79
+
80
+ @staticmethod
81
+ def _delete_files(image_path: str | None, thumbnail_path: str | None) -> None:
82
+ for file_path in (image_path, thumbnail_path):
83
+ if file_path:
84
+ p = Path(file_path)
85
+ if p.exists():
86
+ p.unlink()
73
87
 
74
88
  def add_entry(self, entry: ClipboardEntry) -> int:
75
89
  cursor = self._conn.execute(
76
90
  """INSERT INTO clipboard_entries
77
- (content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview)
78
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
91
+ (content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview, rtf_data, html_data)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
79
93
  (
80
94
  entry.content_type.value,
81
95
  entry.text_content,
@@ -89,6 +103,8 @@ class StorageManager:
89
103
  entry.thumbnail_path,
90
104
  int(entry.is_sensitive),
91
105
  entry.masked_preview,
106
+ entry.rtf_data,
107
+ entry.html_data,
92
108
  ),
93
109
  )
94
110
  self._conn.commit()
@@ -124,14 +140,7 @@ class StorageManager:
124
140
  def delete_entry(self, entry_id: int) -> None:
125
141
  entry = self.get_entry(entry_id)
126
142
  if entry:
127
- if entry.image_path:
128
- path = Path(entry.image_path)
129
- if path.exists():
130
- path.unlink()
131
- if entry.thumbnail_path:
132
- thumb = Path(entry.thumbnail_path)
133
- if thumb.exists():
134
- thumb.unlink()
143
+ self._delete_files(entry.image_path, entry.thumbnail_path)
135
144
  self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (entry_id,))
136
145
  self._conn.commit()
137
146
 
@@ -169,14 +178,7 @@ class StorageManager:
169
178
 
170
179
  deleted = 0
171
180
  for row in rows:
172
- if row["image_path"]:
173
- path = Path(row["image_path"])
174
- if path.exists():
175
- path.unlink()
176
- if row["thumbnail_path"]:
177
- thumb = Path(row["thumbnail_path"])
178
- if thumb.exists():
179
- thumb.unlink()
181
+ self._delete_files(row["image_path"], row["thumbnail_path"])
180
182
  self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (row["id"],))
181
183
  deleted += 1
182
184
 
@@ -201,14 +203,7 @@ class StorageManager:
201
203
  "SELECT image_path, thumbnail_path FROM clipboard_entries WHERE image_path IS NOT NULL OR thumbnail_path IS NOT NULL"
202
204
  ).fetchall()
203
205
  for row in rows:
204
- if row["image_path"]:
205
- path = Path(row["image_path"])
206
- if path.exists():
207
- path.unlink()
208
- if row["thumbnail_path"]:
209
- thumb = Path(row["thumbnail_path"])
210
- if thumb.exists():
211
- thumb.unlink()
206
+ self._delete_files(row["image_path"], row["thumbnail_path"])
212
207
  self._conn.execute("DELETE FROM clipboard_entries")
213
208
  self._conn.commit()
214
209
 
@@ -216,6 +211,21 @@ class StorageManager:
216
211
  row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries").fetchone()
217
212
  return row["cnt"]
218
213
 
214
+ def get_pinned(self) -> list[ClipboardEntry]:
215
+ rows = self._conn.execute(
216
+ "SELECT * FROM clipboard_entries WHERE pinned = 1 ORDER BY created_at DESC"
217
+ ).fetchall()
218
+ return [self._row_to_entry(r) for r in rows]
219
+
220
+ def count_pinned(self) -> int:
221
+ row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries WHERE pinned = 1").fetchone()
222
+ return row["cnt"]
223
+
224
+ def clear_pinned(self) -> None:
225
+ """Unpin all pinned entries."""
226
+ self._conn.execute("UPDATE clipboard_entries SET pinned = 0 WHERE pinned = 1")
227
+ self._conn.commit()
228
+
219
229
  def close(self) -> None:
220
230
  self._conn.close()
221
231
 
@@ -241,6 +251,8 @@ class StorageManager:
241
251
  thumbnail_path = row["thumbnail_path"] if "thumbnail_path" in keys else None
242
252
  is_sensitive = bool(row["is_sensitive"]) if "is_sensitive" in keys else False
243
253
  masked_preview = row["masked_preview"] if "masked_preview" in keys else None
254
+ rtf_data = bytes(row["rtf_data"]) if "rtf_data" in keys and row["rtf_data"] is not None else None
255
+ html_data = bytes(row["html_data"]) if "html_data" in keys and row["html_data"] is not None else None
244
256
  return ClipboardEntry(
245
257
  id=row["id"],
246
258
  content_type=ContentType(row["content_type"]),
@@ -255,4 +267,6 @@ class StorageManager:
255
267
  thumbnail_path=thumbnail_path,
256
268
  is_sensitive=is_sensitive,
257
269
  masked_preview=masked_preview,
270
+ rtf_data=rtf_data,
271
+ html_data=html_data,
258
272
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clipsy
3
- Version: 1.7.1
3
+ Version: 1.9.0
4
4
  Summary: Lightweight clipboard history manager for macOS
5
5
  Author-email: Brendan Conrad <brendan.conrad@gmail.com>
6
6
  License: MIT
@@ -46,6 +46,8 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
46
46
  - **Image thumbnails** — Visual previews for copied images in the menu
47
47
  - **Sensitive data masking** — Auto-detects API keys, passwords, SSNs, credit cards, private keys, and tokens; displays masked previews with 🔒 icon
48
48
  - **Search** — Full-text search across all clipboard entries (SQLite FTS5)
49
+ - **Rich text preservation** — Preserves RTF and HTML formatting when re-copying from history (e.g., bold, italic, links from web pages)
50
+ - **Pin favorites** — Option-click to pin up to 5 frequently-used snippets (sensitive data cannot be pinned)
49
51
  - **Click to re-copy** — Click any entry in the menu to put it back on your clipboard
50
52
  - **Deduplication** — Copying the same content twice bumps it to the top instead of creating a duplicate
51
53
  - **Auto-purge** — Keeps the most recent 500 entries, automatically cleans up old ones
@@ -59,7 +61,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
59
61
 
60
62
  ## Installation
61
63
 
62
- ### Via pipx (recommended)
64
+ ### Via Homebrew (recommended)
65
+
66
+ ```bash
67
+ brew install brencon/clipsy/clipsy
68
+ clipsy
69
+ ```
70
+
71
+ ### Via pipx
63
72
 
64
73
  ```bash
65
74
  brew install pipx
@@ -96,11 +105,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
96
105
  ├── ──────────────────
97
106
  ├── Search...
98
107
  ├── ──────────────────
108
+ ├── 📌 Pinned ►
109
+ │ ├── "my-api-endpoint.com/v1..."
110
+ │ └── "SELECT * FROM users..."
111
+ ├── ──────────────────
99
112
  ├── "Meeting notes for Q3 plan..."
100
113
  ├── "https://github.com/example..."
101
114
  ├── 🔒 "password=••••••••"
102
115
  ├── [thumbnail] "[Image: 1920x1080]"
103
- ├── ... (up to 10 items)
116
+ ├── ... (up to 10 items, configurable)
104
117
  ├── ──────────────────
105
118
  ├── Clear History
106
119
  ├── ──────────────────
@@ -109,6 +122,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
109
122
  └── Quit Clipsy
110
123
  ```
111
124
 
125
+ **Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
126
+
112
127
  ## Commands
113
128
 
114
129
  ```bash
@@ -118,6 +133,17 @@ clipsy uninstall # Remove from login items
118
133
  clipsy run # Run in foreground (for debugging)
119
134
  ```
120
135
 
136
+ ## Configuration
137
+
138
+ | Variable | Default | Range | Description |
139
+ |----------|---------|-------|-------------|
140
+ | `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
141
+
142
+ ```bash
143
+ # Example: show 20 entries in the menu
144
+ export CLIPSY_MENU_DISPLAY_COUNT=20
145
+ ```
146
+
121
147
  ## Data Storage
122
148
 
123
149
  All data is stored in `~/.local/share/clipsy/`:
@@ -0,0 +1,15 @@
1
+ clipsy/__init__.py,sha256=AnsrZyNwA76HMWFc_Q7iDYH8Citiqm2lXLiZFzPs7uQ,46
2
+ clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
3
+ clipsy/app.py,sha256=scM-po0Kw1SOg_pDlMxsgbzhDQWS4BAvv5Iq64GLJEc,9861
4
+ clipsy/config.py,sha256=NOElM7jJ73AWQ1sf09dxg82-1nGErXd1zT63v2MPGpA,980
5
+ clipsy/models.py,sha256=9wuaq1t9HhaH8QYuieN7fEVM_RTYEvv6ZUCM_InOMKk,632
6
+ clipsy/monitor.py,sha256=PBTxK53arFm5VVCHIYlxz8eoYQrgTwWsIWNE49IE7oU,6904
7
+ clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
8
+ clipsy/storage.py,sha256=WE3EeOLcSgw-0it1qPk5rS-vDRC3-XDILaMZquBFR5Y,10659
9
+ clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
10
+ clipsy-1.9.0.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
11
+ clipsy-1.9.0.dist-info/METADATA,sha256=tJ8uQW6-pRU2JAyAnO3dpr_ix9zFUpS6NvizCYZAD4Y,6680
12
+ clipsy-1.9.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ clipsy-1.9.0.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
14
+ clipsy-1.9.0.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
15
+ clipsy-1.9.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- clipsy/__init__.py,sha256=SiyI4J0QW-zHfQ-u7WyAEHr27xsi2W5hxIJPHCKqMq4,46
2
- clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
3
- clipsy/app.py,sha256=_IKuZgA-DvAE3KDsDN00S0aaMIxwBpiv_P1fpzwQFqU,7299
4
- clipsy/config.py,sha256=E9NCsGMeT2H0YXYCq5xYwbd27VkksV4S9txZSMX0RmE,672
5
- clipsy/models.py,sha256=i3KjD4Dcp7y_gTXkIBr2xZxFIIC6kHaMWmv6cSIDABM,563
6
- clipsy/monitor.py,sha256=UUKZToYcZ5pzu7LfiuJ_nnE4AA-d3uLW-WdQU_XrDME,6288
7
- clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
8
- clipsy/storage.py,sha256=QNox2-spkVx_gMPSWqKgF_yj-SMpRJ4UAT6yd9l6rVY,9794
9
- clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
10
- clipsy-1.7.1.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
11
- clipsy-1.7.1.dist-info/METADATA,sha256=o3ccHVdbPvMkh49ZfyqM4WHnCn8YJoEolpQDJ0uj74A,5811
12
- clipsy-1.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
- clipsy-1.7.1.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
14
- clipsy-1.7.1.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
15
- clipsy-1.7.1.dist-info/RECORD,,
File without changes