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/gui/app.py ADDED
@@ -0,0 +1,252 @@
1
+ """
2
+ Gtk.Application entry point for Siftcode.
3
+
4
+ Creates the application, loads CSS, and opens the main window.
5
+ All metadata comes from siftcode._version.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import platform
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import gi
16
+
17
+ gi.require_version("Gtk", "4.0")
18
+ gi.require_version("Adw", "1")
19
+
20
+ from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402
21
+
22
+ from siftcode._version import ( # noqa: E402
23
+ APP_ID,
24
+ APP_NAME,
25
+ AUTHOR_NAME,
26
+ COPYRIGHT,
27
+ DESCRIPTION,
28
+ HOMEPAGE_URL,
29
+ ISSUES_URL,
30
+ __status__,
31
+ __version__,
32
+ )
33
+ from siftcode.gui.window import MainWindow # noqa: E402
34
+
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Paths
41
+ # ---------------------------------------------------------------------------
42
+
43
+ DATA_DIR = Path(__file__).parent.parent / "data"
44
+ LOGO_PATH = DATA_DIR / "siftcode-logo.png"
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # CSS
49
+ # ---------------------------------------------------------------------------
50
+
51
+ CSS = """
52
+ .welcome-title {
53
+ font-size: 24pt;
54
+ font-weight: 300;
55
+ margin-bottom: 12px;
56
+ }
57
+
58
+ .welcome-subtitle {
59
+ font-size: 12pt;
60
+ color: alpha(currentColor, 0.6);
61
+ }
62
+
63
+ .welcome-icon {
64
+ color: alpha(currentColor, 0.4);
65
+ margin-bottom: 24px;
66
+ }
67
+
68
+ .stat-value {
69
+ font-size: 20pt;
70
+ font-weight: bold;
71
+ }
72
+
73
+ .stat-label {
74
+ font-size: 10pt;
75
+ color: alpha(currentColor, 0.6);
76
+ }
77
+
78
+ /* Log view colors */
79
+ .log-info { color: #5e5c64; }
80
+ .log-success { color: #26a269; }
81
+ .log-warning { color: #e5a50a; }
82
+ .log-error { color: #c01c28; }
83
+
84
+ /* Logo in About dialog */
85
+ .about-logo {
86
+ border-radius: 16px;
87
+ }
88
+ """
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Application
93
+ # ---------------------------------------------------------------------------
94
+
95
+ class SiftcodeApplication(Adw.Application):
96
+ """The main Gtk.Application for Siftcode."""
97
+
98
+ def __init__(self) -> None:
99
+ super().__init__(
100
+ application_id=APP_ID,
101
+ flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
102
+ )
103
+ self.window: MainWindow | None = None
104
+ self._about_dialog: Adw.AboutDialog | None = None
105
+
106
+ # ------------------------------------------------------------------
107
+ # Application lifecycle
108
+ # ------------------------------------------------------------------
109
+
110
+ def do_startup(self) -> None:
111
+ Adw.Application.do_startup(self)
112
+ logger.info(f"{APP_NAME} {__version__} ({__status__}) starting")
113
+
114
+ self._load_css()
115
+
116
+ # Actions
117
+ about_action = Gio.SimpleAction.new("about", None)
118
+ about_action.connect("activate", self._on_about)
119
+ self.add_action(about_action)
120
+
121
+ quit_action = Gio.SimpleAction.new("quit", None)
122
+ quit_action.connect("activate", lambda *_: self.quit())
123
+ self.add_action(quit_action)
124
+
125
+ # Keyboard shortcuts
126
+ self.set_accels_for_action("app.quit", ["<Ctrl>q"])
127
+ self.set_accels_for_action("app.about", ["F1"])
128
+
129
+ def do_activate(self) -> None:
130
+ win = self.props.active_window
131
+ if not win:
132
+ win = MainWindow(application=self)
133
+ win.present()
134
+
135
+ # ------------------------------------------------------------------
136
+ # CSS
137
+ # ------------------------------------------------------------------
138
+
139
+ def _load_css(self) -> None:
140
+ provider = Gtk.CssProvider()
141
+ provider.load_from_string(CSS)
142
+ Gtk.StyleContext.add_provider_for_display(
143
+ Gdk.Display.get_default(),
144
+ provider,
145
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
146
+ )
147
+
148
+ # ------------------------------------------------------------------
149
+ # Logo lookup
150
+ # ------------------------------------------------------------------
151
+
152
+ def _find_logo(self) -> Path | None:
153
+ """Find the Siftcode logo PNG (packaged or source tree)."""
154
+ try:
155
+ import siftcode.data as data_pkg
156
+ packaged = Path(data_pkg.__file__).parent / "siftcode-logo.png"
157
+ if packaged.exists():
158
+ return packaged
159
+ except ImportError:
160
+ pass
161
+
162
+ source = Path(__file__).parent.parent / "data" / "siftcode-logo.png"
163
+ if source.exists():
164
+ return source
165
+
166
+ legacy = Path(__file__).parent / "assets" / "siftcode-logo.png"
167
+ if legacy.exists():
168
+ return legacy
169
+
170
+ return None
171
+
172
+ def _register_logo_icon_theme(self) -> None:
173
+ """Register the logo's directory as an icon theme search path."""
174
+ logo_path = self._find_logo()
175
+ if logo_path is None:
176
+ return
177
+ try:
178
+ display = Gdk.Display.get_default()
179
+ theme = Gtk.IconTheme.get_for_display(display)
180
+ theme.add_search_path(str(logo_path.parent))
181
+ logger.debug(f"Added icon search path: {logo_path.parent}")
182
+ except Exception as e:
183
+ logger.warning(f"Could not register icon theme path: {e}")
184
+
185
+ # ------------------------------------------------------------------
186
+ # Actions
187
+ # ------------------------------------------------------------------
188
+
189
+ def _on_about(self, _action, _param) -> None:
190
+ # Avoid opening multiple About dialogs
191
+ if self._about_dialog is not None:
192
+ return
193
+
194
+ # Register the logo directory so "siftcode-logo" is available
195
+ self._register_logo_icon_theme()
196
+
197
+ about = Adw.AboutDialog(
198
+ application_name=APP_NAME,
199
+ application_icon="siftcode-logo",
200
+ version=__version__,
201
+ developer_name=AUTHOR_NAME,
202
+ license_type=Gtk.License.GPL_3_0,
203
+ comments=DESCRIPTION,
204
+ website=HOMEPAGE_URL,
205
+ issue_url=ISSUES_URL,
206
+ copyright=COPYRIGHT,
207
+ )
208
+
209
+ # "Built with" acknowledgement section
210
+ about.add_acknowledgement_section(
211
+ "Built with",
212
+ [
213
+ "GTK4 https://gtk.org",
214
+ "libadwaita https://gnome.pages.gitlab.gnome.org/libadwaita/",
215
+ "Ollama https://ollama.com",
216
+ "Python https://python.org",
217
+ ],
218
+ )
219
+
220
+ # Runtime info section
221
+ about.add_legal_section(
222
+ title="Runtime",
223
+ copyright=f"Python {platform.python_version()}",
224
+ license_type=Gtk.License.UNKNOWN,
225
+ )
226
+
227
+ self._about_dialog = about
228
+ about.connect("closed", self._on_about_closed)
229
+ about.present(self.props.active_window)
230
+
231
+ def _on_about_closed(self, _dialog) -> None:
232
+ """Called when the About dialog is closed."""
233
+ self._about_dialog = None
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Entry point for `siftcode-gui`
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def main(argv: list[str] | None = None) -> int:
241
+ """Launch the GTK application."""
242
+ logging.basicConfig(
243
+ level=logging.INFO,
244
+ format="%(asctime)s - %(levelname)s - %(message)s",
245
+ datefmt="%H:%M:%S",
246
+ )
247
+ app = SiftcodeApplication()
248
+ return app.run(argv if argv is not None else sys.argv)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ """
2
+ GuideWindow — a standalone window that renders a Markdown guide
3
+ using the MarkdownView widget.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import gi
12
+
13
+ gi.require_version("Gtk", "4.0")
14
+ gi.require_version("Adw", "1")
15
+
16
+ from gi.repository import Adw, Gtk # noqa: E402
17
+
18
+ from siftcode.gui.widgets.markdown_view import MarkdownView # noqa: E402
19
+
20
+
21
+ class GuideWindow(Adw.Window):
22
+ """A window that displays a Markdown file with a preview."""
23
+
24
+ def __init__(self, parent, title: str, guide_path: Path) -> None:
25
+ super().__init__()
26
+ self.set_transient_for(parent)
27
+ self.set_modal(False)
28
+ self.set_title(title)
29
+ self.set_default_size(900, 700)
30
+
31
+ self._guide_path = guide_path
32
+
33
+ toolbar = Adw.ToolbarView()
34
+ header = Adw.HeaderBar()
35
+
36
+ subtitle = guide_path.parent.name if guide_path.parent else ""
37
+ header.set_title_widget(
38
+ Adw.WindowTitle(title=title, subtitle=subtitle)
39
+ )
40
+
41
+ # "Open in external editor" button
42
+ open_btn = Gtk.Button.new_from_icon_name("document-open-symbolic")
43
+ open_btn.set_tooltip_text("Open in external editor")
44
+ open_btn.connect("clicked", self._on_open_external)
45
+ header.pack_end(open_btn)
46
+
47
+ toolbar.add_top_bar(header)
48
+
49
+ # Content
50
+ self._view = MarkdownView()
51
+ try:
52
+ self._view.load_file(guide_path)
53
+ except Exception as e:
54
+ error_label = Gtk.Label(label=f"Could not load guide:\n{e}")
55
+ error_label.set_margin_top(24)
56
+ error_label.set_margin_start(24)
57
+ error_label.set_margin_end(24)
58
+ toolbar.set_content(error_label)
59
+ self.set_content(toolbar)
60
+ return
61
+
62
+ toolbar.set_content(self._view)
63
+ self.set_content(toolbar)
64
+
65
+ # Close on Escape
66
+ key_controller = Gtk.EventControllerKey()
67
+ key_controller.connect("key-pressed", self._on_key_pressed)
68
+ self.add_controller(key_controller)
69
+
70
+ # ------------------------------------------------------------------
71
+
72
+ def _on_key_pressed(self, _ctrl, keyval, _keycode, _state) -> bool:
73
+ # Escape closes the window
74
+ if keyval == 0xFF1B: # GDK_KEY_Escape
75
+ self.close()
76
+ return True
77
+ return False
78
+
79
+ def _on_open_external(self, _button) -> None:
80
+ """Open the guide in the system's default Markdown viewer."""
81
+ try:
82
+ subprocess.Popen(
83
+ ["xdg-open", str(self._guide_path)],
84
+ stdout=subprocess.DEVNULL,
85
+ stderr=subprocess.DEVNULL,
86
+ )
87
+ except Exception:
88
+ pass
@@ -0,0 +1,160 @@
1
+ """
2
+ Project list view — shows all saved projects and lets the user add new ones.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ import gi
11
+
12
+ gi.require_version("Gtk", "4.0")
13
+ gi.require_version("Adw", "1")
14
+
15
+ from gi.repository import Adw, Gio, Gtk # noqa: E402
16
+
17
+ from siftcode.core.config import (
18
+ ProjectConfig,
19
+ delete_project,
20
+ list_projects,
21
+ save_project,
22
+ )
23
+ from siftcode.gui.widgets.project_row import ProjectRow # noqa: E402
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class ProjectListView(Adw.Bin):
30
+ """A scrollable list of saved projects, plus a button to add new ones."""
31
+
32
+ def __init__(self) -> None:
33
+ super().__init__()
34
+ self._on_project_activated = None
35
+ self._on_project_deleted = None
36
+
37
+ # Root layout: an Adw.StatusPage when empty, or a ListBox when full
38
+ self._stack = Gtk.Stack()
39
+ self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
40
+
41
+ # Empty state
42
+ self._empty_page = Adw.StatusPage(
43
+ title="No projects yet",
44
+ description="Click the + button in the header to add a project.",
45
+ icon_name="folder-open-symbolic",
46
+ )
47
+ self._stack.add_named(self._empty_page, "empty")
48
+
49
+ # List view
50
+ self._list_box = Gtk.ListBox()
51
+ self._list_box.set_selection_mode(Gtk.SelectionMode.SINGLE)
52
+ self._list_box.add_css_class("boxed-list")
53
+ self._list_box.set_margin_top(12)
54
+ self._list_box.set_margin_bottom(12)
55
+ self._list_box.set_margin_start(12)
56
+ self._list_box.set_margin_end(12)
57
+ self._list_box.connect("row-activated", self._on_row_activated)
58
+
59
+ scrolled = Gtk.ScrolledWindow()
60
+ scrolled.set_vexpand(True)
61
+ scrolled.set_child(self._list_box)
62
+ self._stack.add_named(scrolled, "list")
63
+
64
+ self.set_child(self._stack)
65
+
66
+ # Load projects on construction
67
+ self.refresh()
68
+
69
+ # ------------------------------------------------------------------
70
+ # Public API
71
+ # ------------------------------------------------------------------
72
+
73
+ def set_on_project_activated(self, callback) -> None:
74
+ """Set a callback invoked when a project row is activated.
75
+
76
+ The callback receives the ProjectConfig of the activated row.
77
+ """
78
+ self._on_project_activated = callback
79
+
80
+ def set_on_project_deleted(self, callback) -> None:
81
+ """Set a callback invoked after a project is deleted.
82
+
83
+ Receives the deleted ProjectConfig.
84
+ """
85
+ self._on_project_deleted = callback
86
+
87
+ def refresh(self) -> None:
88
+ """Reload the project list from disk."""
89
+ # Clear existing rows
90
+ while (row := self._list_box.get_row_at_index(0)) is not None:
91
+ self._list_box.remove(row)
92
+
93
+ projects = list_projects()
94
+
95
+ if not projects:
96
+ self._stack.set_visible_child_name("empty")
97
+ return
98
+
99
+ for config in projects:
100
+ row = ProjectRow(config)
101
+ self._list_box.append(row)
102
+
103
+ self._stack.set_visible_child_name("list")
104
+
105
+ def activate_row(self, row) -> None:
106
+ """Programmatically activate a row (same as double-click)."""
107
+ if self._on_project_activated:
108
+ config = getattr(row, "config", None)
109
+ if config:
110
+ self._on_project_activated(config)
111
+
112
+ def request_delete_row(self, row) -> None:
113
+ """Ask the user to confirm, then delete the row's project."""
114
+ config = getattr(row, "config", None)
115
+ if config is None:
116
+ return
117
+
118
+ dialog = Adw.MessageDialog.new(
119
+ self.get_root(),
120
+ "Delete project?",
121
+ f"Are you sure you want to delete '{config.name}'?\n\n"
122
+ f"This removes the saved configuration, not the project files.",
123
+ )
124
+ dialog.add_response("cancel", "Cancel")
125
+ dialog.add_response("delete", "Delete")
126
+ dialog.set_response_appearance(
127
+ "delete", Adw.ResponseAppearance.DESTRUCTIVE
128
+ )
129
+ dialog.set_default_response("cancel")
130
+ dialog.set_close_response("cancel")
131
+
132
+ dialog.connect("response", self._on_delete_response, config)
133
+ dialog.present()
134
+
135
+ # ------------------------------------------------------------------
136
+ # Signals
137
+ # ------------------------------------------------------------------
138
+
139
+ def _on_row_activated(self, _list_box, row) -> None:
140
+ config = getattr(row, "config", None)
141
+ if config and self._on_project_activated:
142
+ self._on_project_activated(config)
143
+
144
+ def _on_delete_response(self, _dialog, response_id: str, config) -> None:
145
+ if response_id != "delete":
146
+ return
147
+
148
+ if delete_project(config.name):
149
+ logger.info(f"Deleted project: {config.name}")
150
+ self.refresh()
151
+ if self._on_project_deleted:
152
+ self._on_project_deleted(config)
153
+ else:
154
+ error = Adw.MessageDialog.new(
155
+ self.get_root(),
156
+ "Could not delete",
157
+ f"Project '{config.name}' was not found on disk.",
158
+ )
159
+ error.add_response("ok", "OK")
160
+ error.present()