siftcode 0.3.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.
- siftcode/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LogView widget — a scrollable text view for showing log messages.
|
|
3
|
+
|
|
4
|
+
Provides a simple append(text, level) API and automatic scrolling
|
|
5
|
+
to the bottom on new messages. The log is bounded to MAX_LINES so
|
|
6
|
+
long analyses don't grow it unbounded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import gi
|
|
14
|
+
|
|
15
|
+
gi.require_version("Gtk", "4.0")
|
|
16
|
+
|
|
17
|
+
from gi.repository import GLib, Gtk # noqa: E402
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LogView(Gtk.ScrolledWindow):
|
|
21
|
+
"""A scrollable, read-only log display."""
|
|
22
|
+
|
|
23
|
+
# Keep the log bounded so long analyses don't grow it forever
|
|
24
|
+
MAX_LINES = 500
|
|
25
|
+
|
|
26
|
+
# CSS class names per log level
|
|
27
|
+
LEVEL_CLASSES = {
|
|
28
|
+
"info": "log-info",
|
|
29
|
+
"success": "log-success",
|
|
30
|
+
"warning": "log-warning",
|
|
31
|
+
"error": "log-error",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
37
|
+
self.set_vexpand(True)
|
|
38
|
+
self.set_min_content_height(200)
|
|
39
|
+
|
|
40
|
+
self._buffer = Gtk.TextBuffer()
|
|
41
|
+
|
|
42
|
+
# Tags for color coding
|
|
43
|
+
self._buffer.create_tag("info", foreground="#5e5c64")
|
|
44
|
+
self._buffer.create_tag("success", foreground="#26a269")
|
|
45
|
+
self._buffer.create_tag("warning", foreground="#e5a50a")
|
|
46
|
+
self._buffer.create_tag("error", foreground="#c01c28")
|
|
47
|
+
self._buffer.create_tag("timestamp", foreground="#9a9996")
|
|
48
|
+
|
|
49
|
+
self._text_view = Gtk.TextView(buffer=self._buffer)
|
|
50
|
+
self._text_view.set_editable(False)
|
|
51
|
+
self._text_view.set_cursor_visible(False)
|
|
52
|
+
self._text_view.set_monospace(True)
|
|
53
|
+
self._text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
54
|
+
self._text_view.set_left_margin(12)
|
|
55
|
+
self._text_view.set_right_margin(12)
|
|
56
|
+
self._text_view.set_top_margin(8)
|
|
57
|
+
self._text_view.set_bottom_margin(8)
|
|
58
|
+
|
|
59
|
+
self.set_child(self._text_view)
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public API
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def append(self, text: str, level: str = "info") -> None:
|
|
66
|
+
"""Append a line of text with the given log level."""
|
|
67
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
68
|
+
|
|
69
|
+
end_iter = self._buffer.get_end_iter()
|
|
70
|
+
|
|
71
|
+
# Timestamp
|
|
72
|
+
self._buffer.insert_with_tags_by_name(
|
|
73
|
+
end_iter, f"[{timestamp}] ", "timestamp"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Message
|
|
77
|
+
end_iter = self._buffer.get_end_iter()
|
|
78
|
+
tag_name = level if level in ("info", "success", "warning", "error") else "info"
|
|
79
|
+
self._buffer.insert_with_tags_by_name(end_iter, f"{text}\n", tag_name)
|
|
80
|
+
|
|
81
|
+
# Trim oldest lines if exceeding MAX_LINES
|
|
82
|
+
line_count = self._buffer.get_line_count()
|
|
83
|
+
if line_count > self.MAX_LINES:
|
|
84
|
+
start = self._buffer.get_start_iter()
|
|
85
|
+
end = self._buffer.get_iter_at_line(line_count - self.MAX_LINES)
|
|
86
|
+
self._buffer.delete(start, end)
|
|
87
|
+
|
|
88
|
+
# Scroll to bottom
|
|
89
|
+
self._scroll_to_bottom()
|
|
90
|
+
|
|
91
|
+
def clear(self) -> None:
|
|
92
|
+
"""Remove all text from the log."""
|
|
93
|
+
self._buffer.set_text("")
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Internal
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _scroll_to_bottom(self) -> None:
|
|
100
|
+
"""Scroll the view to the last line."""
|
|
101
|
+
# Defer to idle so the layout has time to update
|
|
102
|
+
GLib.idle_add(self._do_scroll)
|
|
103
|
+
|
|
104
|
+
def _do_scroll(self) -> bool:
|
|
105
|
+
adj = self.get_vadjustment()
|
|
106
|
+
if adj is not None:
|
|
107
|
+
adj.set_value(adj.get_upper() - adj.get_page_size())
|
|
108
|
+
return False # don't repeat
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MarkdownView widget — renders a subset of Markdown using Pango
|
|
3
|
+
markup inside a Gtk.TextView. No external dependencies beyond
|
|
4
|
+
PyGObject.
|
|
5
|
+
|
|
6
|
+
Supported syntax:
|
|
7
|
+
# H1, ## H2, ### H3, #### H4
|
|
8
|
+
**bold**, *italic*, `code`
|
|
9
|
+
- unordered list items
|
|
10
|
+
1. ordered list items
|
|
11
|
+
```fenced code blocks```
|
|
12
|
+
> blockquotes
|
|
13
|
+
--- horizontal rules
|
|
14
|
+
[text](url) — shown as "text (url)"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
import gi
|
|
22
|
+
|
|
23
|
+
gi.require_version("Gtk", "4.0")
|
|
24
|
+
|
|
25
|
+
from gi.repository import Gtk # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MarkdownView(Gtk.ScrolledWindow):
|
|
29
|
+
"""A scrollable, read-only Markdown renderer based on Pango markup."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
34
|
+
self.set_vexpand(True)
|
|
35
|
+
|
|
36
|
+
self._buffer = Gtk.TextBuffer()
|
|
37
|
+
|
|
38
|
+
# Text tags (Pango-based styling)
|
|
39
|
+
self._buffer.create_tag(
|
|
40
|
+
"h1", scale=1.8, weight=700,
|
|
41
|
+
pixels_above_lines=18, pixels_below_lines=8,
|
|
42
|
+
)
|
|
43
|
+
self._buffer.create_tag(
|
|
44
|
+
"h2", scale=1.5, weight=700,
|
|
45
|
+
pixels_above_lines=14, pixels_below_lines=6,
|
|
46
|
+
)
|
|
47
|
+
self._buffer.create_tag(
|
|
48
|
+
"h3", scale=1.25, weight=700,
|
|
49
|
+
pixels_above_lines=10, pixels_below_lines=4,
|
|
50
|
+
)
|
|
51
|
+
self._buffer.create_tag(
|
|
52
|
+
"h4", scale=1.1, weight=700,
|
|
53
|
+
pixels_above_lines=8, pixels_below_lines=3,
|
|
54
|
+
)
|
|
55
|
+
self._buffer.create_tag("bold", weight=700)
|
|
56
|
+
self._buffer.create_tag("italic", style=2) # PANGO_STYLE_ITALIC
|
|
57
|
+
self._buffer.create_tag(
|
|
58
|
+
"code", family="monospace",
|
|
59
|
+
background="#f6f5f4", foreground="#a51d2d",
|
|
60
|
+
)
|
|
61
|
+
self._buffer.create_tag(
|
|
62
|
+
"codeblock", family="monospace", background="#f6f5f4",
|
|
63
|
+
foreground="#1c1c1c",
|
|
64
|
+
pixels_above_lines=2, pixels_below_lines=2,
|
|
65
|
+
left_margin=16, right_margin=16,
|
|
66
|
+
)
|
|
67
|
+
self._buffer.create_tag("list", left_margin=24)
|
|
68
|
+
self._buffer.create_tag(
|
|
69
|
+
"blockquote", style=2, left_margin=24,
|
|
70
|
+
foreground="#5e5c64",
|
|
71
|
+
)
|
|
72
|
+
self._buffer.create_tag(
|
|
73
|
+
"hr", foreground="#c0bfbc",
|
|
74
|
+
pixels_above_lines=6, pixels_below_lines=6,
|
|
75
|
+
)
|
|
76
|
+
self._buffer.create_tag("paragraph", pixels_below_lines=6)
|
|
77
|
+
|
|
78
|
+
self._text_view = Gtk.TextView(buffer=self._buffer)
|
|
79
|
+
self._text_view.set_editable(False)
|
|
80
|
+
self._text_view.set_cursor_visible(False)
|
|
81
|
+
self._text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
82
|
+
self._text_view.set_left_margin(24)
|
|
83
|
+
self._text_view.set_right_margin(24)
|
|
84
|
+
self._text_view.set_top_margin(16)
|
|
85
|
+
self._text_view.set_bottom_margin(16)
|
|
86
|
+
|
|
87
|
+
self.set_child(self._text_view)
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Public API
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def load_markdown(self, text: str) -> None:
|
|
94
|
+
"""Render the given Markdown text."""
|
|
95
|
+
self._buffer.set_text("")
|
|
96
|
+
self._render(text)
|
|
97
|
+
|
|
98
|
+
def load_file(self, path) -> None:
|
|
99
|
+
"""Load and render a Markdown file from disk."""
|
|
100
|
+
text = path.read_text(encoding="utf-8")
|
|
101
|
+
self.load_markdown(text)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Rendering
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _render(self, text: str) -> None:
|
|
108
|
+
"""Parse Markdown and insert it with Pango tags."""
|
|
109
|
+
in_code_block = False
|
|
110
|
+
|
|
111
|
+
for raw_line in text.split("\n"):
|
|
112
|
+
line = raw_line.rstrip()
|
|
113
|
+
|
|
114
|
+
# Fenced code blocks
|
|
115
|
+
if line.startswith("```"):
|
|
116
|
+
in_code_block = not in_code_block
|
|
117
|
+
if not in_code_block:
|
|
118
|
+
# Add a small blank line after the block
|
|
119
|
+
self._insert("\n")
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if in_code_block:
|
|
123
|
+
self._insert(line + "\n", "codeblock")
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Headings
|
|
127
|
+
if line.startswith("#### "):
|
|
128
|
+
self._insert(line[5:] + "\n", "h4")
|
|
129
|
+
elif line.startswith("### "):
|
|
130
|
+
self._insert(line[4:] + "\n", "h3")
|
|
131
|
+
elif line.startswith("## "):
|
|
132
|
+
self._insert(line[3:] + "\n", "h2")
|
|
133
|
+
elif line.startswith("# "):
|
|
134
|
+
self._insert(line[2:] + "\n", "h1")
|
|
135
|
+
|
|
136
|
+
# Horizontal rule
|
|
137
|
+
elif line.strip() in ("---", "***", "___"):
|
|
138
|
+
self._insert("─" * 60 + "\n", "hr")
|
|
139
|
+
|
|
140
|
+
# Blockquote
|
|
141
|
+
elif line.startswith("> "):
|
|
142
|
+
self._insert_inline(line[2:])
|
|
143
|
+
self._insert("\n", "blockquote")
|
|
144
|
+
|
|
145
|
+
# Unordered list
|
|
146
|
+
elif line.startswith("- ") or line.startswith("* "):
|
|
147
|
+
self._insert(" • ", "list")
|
|
148
|
+
self._insert_inline(line[2:])
|
|
149
|
+
self._insert("\n", "list")
|
|
150
|
+
|
|
151
|
+
# Ordered list
|
|
152
|
+
elif re.match(r"^\d+\.\s", line):
|
|
153
|
+
prefix = re.match(r"^(\d+\.)\s", line).group(1)
|
|
154
|
+
self._insert(f" {prefix} ", "list")
|
|
155
|
+
self._insert_inline(line[len(prefix) + 1:])
|
|
156
|
+
self._insert("\n", "list")
|
|
157
|
+
|
|
158
|
+
# Empty line
|
|
159
|
+
elif not line:
|
|
160
|
+
self._insert("\n")
|
|
161
|
+
|
|
162
|
+
# Regular paragraph
|
|
163
|
+
else:
|
|
164
|
+
self._insert_inline(line)
|
|
165
|
+
self._insert("\n", "paragraph")
|
|
166
|
+
|
|
167
|
+
def _insert(self, text: str, tag: str | None = None) -> None:
|
|
168
|
+
"""Insert text with an optional tag."""
|
|
169
|
+
end = self._buffer.get_end_iter()
|
|
170
|
+
if tag:
|
|
171
|
+
self._buffer.insert_with_tags_by_name(end, text, tag)
|
|
172
|
+
else:
|
|
173
|
+
self._buffer.insert(end, text)
|
|
174
|
+
|
|
175
|
+
def _insert_inline(self, text: str) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Insert text handling inline Markdown:
|
|
178
|
+
**bold**, *italic*, `code`, and [text](url).
|
|
179
|
+
"""
|
|
180
|
+
pattern = re.compile(
|
|
181
|
+
r"(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))"
|
|
182
|
+
)
|
|
183
|
+
pos = 0
|
|
184
|
+
for match in pattern.finditer(text):
|
|
185
|
+
if match.start() > pos:
|
|
186
|
+
self._insert(text[pos:match.start()])
|
|
187
|
+
|
|
188
|
+
token = match.group(0)
|
|
189
|
+
|
|
190
|
+
if token.startswith("**") and token.endswith("**"):
|
|
191
|
+
self._insert(token[2:-2], "bold")
|
|
192
|
+
elif token.startswith("*") and token.endswith("*"):
|
|
193
|
+
self._insert(token[1:-1], "italic")
|
|
194
|
+
elif token.startswith("`") and token.endswith("`"):
|
|
195
|
+
self._insert(token[1:-1], "code")
|
|
196
|
+
elif token.startswith("["):
|
|
197
|
+
m = re.match(r"\[([^\]]+)\]\(([^)]+)\)", token)
|
|
198
|
+
if m:
|
|
199
|
+
self._insert(f"{m.group(1)} ", "bold")
|
|
200
|
+
self._insert(f"({m.group(2)})", "italic")
|
|
201
|
+
|
|
202
|
+
pos = match.end()
|
|
203
|
+
|
|
204
|
+
if pos < len(text):
|
|
205
|
+
self._insert(text[pos:])
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProjectRow widget — a single row in the project list.
|
|
3
|
+
|
|
4
|
+
Displays one saved project with its name, path, model, and last-run
|
|
5
|
+
timestamp. Shows a status icon depending on whether the project path
|
|
6
|
+
still exists on disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import gi
|
|
14
|
+
|
|
15
|
+
gi.require_version("Gtk", "4.0")
|
|
16
|
+
gi.require_version("Adw", "1")
|
|
17
|
+
|
|
18
|
+
from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402
|
|
19
|
+
|
|
20
|
+
from siftcode.core.config import ProjectConfig # noqa: E402
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProjectRow(Gtk.ListBoxRow):
|
|
24
|
+
"""A single row showing a saved project."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: ProjectConfig) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.config = config
|
|
29
|
+
|
|
30
|
+
# --- Content layout: icon | text column | meta column ------------
|
|
31
|
+
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
|
32
|
+
box.set_margin_top(10)
|
|
33
|
+
box.set_margin_bottom(10)
|
|
34
|
+
box.set_margin_start(12)
|
|
35
|
+
box.set_margin_end(12)
|
|
36
|
+
|
|
37
|
+
status_icon = Gtk.Image.new_from_icon_name(
|
|
38
|
+
"folder-symbolic" if config.path.is_dir()
|
|
39
|
+
else "dialog-warning-symbolic"
|
|
40
|
+
)
|
|
41
|
+
status_icon.set_pixel_size(24)
|
|
42
|
+
status_icon.set_valign(Gtk.Align.CENTER)
|
|
43
|
+
if not config.path.is_dir():
|
|
44
|
+
status_icon.add_css_class("warning")
|
|
45
|
+
box.append(status_icon)
|
|
46
|
+
|
|
47
|
+
text_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
48
|
+
text_col.set_hexpand(True)
|
|
49
|
+
text_col.set_valign(Gtk.Align.CENTER)
|
|
50
|
+
|
|
51
|
+
name_label = Gtk.Label(label=config.name, xalign=0)
|
|
52
|
+
name_label.add_css_class("heading")
|
|
53
|
+
text_col.append(name_label)
|
|
54
|
+
|
|
55
|
+
path_label = Gtk.Label(label=_shorten_path(config.path), xalign=0)
|
|
56
|
+
path_label.add_css_class("dim-label")
|
|
57
|
+
path_label.add_css_class("caption")
|
|
58
|
+
text_col.append(path_label)
|
|
59
|
+
|
|
60
|
+
box.append(text_col)
|
|
61
|
+
|
|
62
|
+
meta_col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
63
|
+
meta_col.set_valign(Gtk.Align.CENTER)
|
|
64
|
+
meta_col.set_halign(Gtk.Align.END)
|
|
65
|
+
|
|
66
|
+
model_label = Gtk.Label(label=config.model.name, xalign=1)
|
|
67
|
+
model_label.add_css_class("caption")
|
|
68
|
+
meta_col.append(model_label)
|
|
69
|
+
|
|
70
|
+
last_label = Gtk.Label(label=_format_last_run(config.last_run), xalign=1)
|
|
71
|
+
last_label.add_css_class("dim-label")
|
|
72
|
+
last_label.add_css_class("caption")
|
|
73
|
+
meta_col.append(last_label)
|
|
74
|
+
|
|
75
|
+
box.append(meta_col)
|
|
76
|
+
|
|
77
|
+
self.set_child(box)
|
|
78
|
+
|
|
79
|
+
self._setup_context_menu()
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Context menu — window-level actions
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _setup_context_menu(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Attach a right-click menu.
|
|
88
|
+
|
|
89
|
+
The actions reference 'window.edit-row' and 'window.delete-row',
|
|
90
|
+
which are registered on the MainWindow. This is required because
|
|
91
|
+
Gtk.PopoverMenu lives in its own popup window and cannot see
|
|
92
|
+
actions registered on this row's local action group.
|
|
93
|
+
"""
|
|
94
|
+
menu = Gio.Menu()
|
|
95
|
+
menu.append("Edit settings", "window.edit-row")
|
|
96
|
+
menu.append("Open latest guide", "window.open-latest-guide")
|
|
97
|
+
menu.append("Open output folder", "window.open-output-folder")
|
|
98
|
+
menu.append("Delete project", "window.delete-row")
|
|
99
|
+
|
|
100
|
+
self._menu = Gtk.PopoverMenu.new_from_model(menu)
|
|
101
|
+
self._menu.set_parent(self)
|
|
102
|
+
self._menu.set_has_arrow(False)
|
|
103
|
+
|
|
104
|
+
gesture = Gtk.GestureClick.new()
|
|
105
|
+
gesture.set_button(3)
|
|
106
|
+
gesture.connect("pressed", self._on_right_click)
|
|
107
|
+
self.add_controller(gesture)
|
|
108
|
+
|
|
109
|
+
def _on_right_click(self, _gesture, _n_press, x, y) -> None:
|
|
110
|
+
# Register this row as the active row on the window.
|
|
111
|
+
# The window-level actions will use it when they fire.
|
|
112
|
+
window = self.get_root()
|
|
113
|
+
if window is not None and hasattr(window, "_active_row"):
|
|
114
|
+
window._active_row = self
|
|
115
|
+
|
|
116
|
+
rect = Gdk.Rectangle()
|
|
117
|
+
rect.x = int(x)
|
|
118
|
+
rect.y = int(y)
|
|
119
|
+
rect.width = 1
|
|
120
|
+
rect.height = 1
|
|
121
|
+
self._menu.set_pointing_to(rect)
|
|
122
|
+
self._menu.popup()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Helpers
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _shorten_path(path: Path) -> str:
|
|
130
|
+
"""Replace home directory with ~ for display."""
|
|
131
|
+
try:
|
|
132
|
+
return "~/" + str(path.relative_to(Path.home()))
|
|
133
|
+
except ValueError:
|
|
134
|
+
return str(path)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _format_last_run(timestamp: str) -> str:
|
|
138
|
+
"""Format an ISO timestamp as a short human-readable string."""
|
|
139
|
+
if not timestamp:
|
|
140
|
+
return "never run"
|
|
141
|
+
return timestamp[:10]
|