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.
@@ -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]