diff --git a/Makefile b/Makefile index 99b1a38..905aaff 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ APP_NAME := graptos APP_ID := io.github.graptos.Editor -VERSION := 0.23.73 +VERSION := 0.23.74 PREFIX ?= /usr/local CC ?= cc BUILD_DIR := build diff --git a/README.md b/README.md index 28c35b3..ab377c6 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ ### γραπτός: Grap-Tos; adj, written or inscribed +![linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) [![C CI](https://github.com/Logic-gate/Graptos/actions/workflows/c-cpp.yml/badge.svg)](https://github.com/Logic-gate/Graptos/actions/workflows/c-cpp.yml) ![C](https://img.shields.io/badge/language-C-blue) -![Open Source Friendly](https://img.shields.io/badge/open--source-friendly-22c55e) +![Open Source](https://img.shields.io/badge/open-source-22c55e) ![Pull Requests Welcome](https://img.shields.io/badge/pull%20requests-welcome-2563eb) @@ -48,11 +49,13 @@ costs. * Tabbed editing * Project folder sidebar with live refresh and Git file indicators * Optional split and tiled editing for up to three tabs +* Per-line and multi-line notes with gutter indicators and comment conversion * Toggleable minimap and scroll preview * Markdown and LaTeX previews that can be resized or detached * Project-wide literal search and replacement * Manual completion with `Ctrl+Space` * Optional automatic completion +* Native path autocomplete plugin with `Ctrl+Alt+P` * LSP completion, diagnostics, references, and go-to-definition when a configured language server is available * Graptoς YAML fallback completion, references, import completion, and project @@ -64,7 +67,7 @@ costs. * Regex tester popover for selected regex-like text * Integrated terminal with single-terminal and dynamic-directory modes * Init Project dialog for generating projects from declarative templates -* Live theme editor with previews and persistent `config.ini` support +* Live CSS theme editor with previews and persistent `config.ini` support * Config migration that adds new keys without replacing existing values * Bundled themes including Dracula, GitHub Dark, Apprentice, Gruvbox Dark, Tokyo Night, Srcery, and Moonfly @@ -72,6 +75,7 @@ costs. and per-turn change review * Git status, diffs, staging, commits, pulls, pushes, and credential-helper support +* Native plugin ABI for commands, shortcuts, and completion providers * YAML-configurable highlighting, completion, formatting, imports, LSP commands, and project indexing @@ -80,10 +84,6 @@ costs. Graptoς is still early software. Some parts are incomplete or deliberately limited: -* The theme editor is live and persistent, but not every widget can be themed - yet. -* Plugin support is not available. Graptoς can currently be extended through - configuration files, but there is no stable plugin API. * Some UI details still need work, especially around edge cases, panel behavior, and less common GTK themes. * Automated testing is growing, but it does not yet cover every editor workflow. @@ -147,15 +147,22 @@ make install PREFIX="$HOME/.local" ## Theme and preferences Open `File > Theme` to change Graptoς colors and fonts. Changes are previewed -inside the dialog before they are saved. The theme editor writes to: +inside the dialog before they are saved. Graptoς stores preferences in: ```text ~/.config/graptos/config.ini ``` -The file can also be edited manually. Use `Reload config.ini` in the Theme -dialog to apply manual changes without restarting Graptoς. The current config -keys are documented in [docs/PREFERENCES.md](docs/PREFERENCES.md). +CSS themes are the active theme format. `config.ini` stores the active CSS theme +path, and the default user theme is: + +```text +~/.config/graptos/theme.css +``` + +The files can also be edited manually. Use `Reload config.ini` in the Theme +dialog to apply manual preference changes without restarting Graptoς. The +current config keys and CSS theme roles are documented in [docs/PREFERENCES.md](docs/PREFERENCES.md). When a newer build introduces config keys, Graptoς adds only the missing keys to an existing `config.ini`. Existing values are not changed. If the file does not @@ -184,6 +191,8 @@ Open `File > Shortcuts` for the full in-app list. Common shortcuts include: | `Ctrl+G` | Go to line | | `Ctrl+J` | Format selected code or surrounding block | | `Ctrl+Space` | Show completion | +| `Ctrl+Alt+P` | Show path completion | +| `Ctrl+Shift+M` | Add note | ## Git support @@ -390,6 +399,17 @@ Python completion sources may include: Press `Ctrl+Space` to request suggestions manually. +## Notes + +Notes can be attached to a line or a multi-line selection. Note indicators are +shown in the editor gutter, and hovering over an indicator shows the note text. +Right-click a note indicator to edit, delete, or convert the note into a +language comment using the active syntax's `line_comment` setting. + +Notes are stored as local text databases outside the source file, keyed by the +source filename, so they can be shared separately from source edits when a +project chooses to include them. + ## Project search Open a project folder, then select `Edit > Project Search`. @@ -490,9 +510,13 @@ index: false ## Plugins Graptoς plugins are local folders with a `plugin.yaml` manifest. The plugin -layer is the boundary between Graptoς internals and extensions: plugins declare -what they contribute, and Graptoς decides how those contributions are loaded and -executed. +layer is the boundary between Graptoς internals and extensions. YAML manifests +declare identity, permissions, native library paths, and static asset +contributions. Native `.so` plugins can register commands, shortcut-backed +commands, and completion providers through the opaque Graptoς plugin ABI. + +The source tree includes example native plugins for path autocomplete and +running the active file. See [docs/PLUGINS.md](docs/PLUGINS.md) for more information. diff --git a/data/logos/graptos-logo-dark.png b/data/logos/graptos-logo-dark.png index a3bab2c..db41a06 100644 Binary files a/data/logos/graptos-logo-dark.png and b/data/logos/graptos-logo-dark.png differ diff --git a/data/logos/graptos-logo-light.png b/data/logos/graptos-logo-light.png index a3bab2c..b682f05 100644 Binary files a/data/logos/graptos-logo-light.png and b/data/logos/graptos-logo-light.png differ diff --git a/data/plugins/full-demo/plugin.yaml b/data/plugins/full-demo/plugin.yaml index da4aeec..a4cdeba 100644 --- a/data/plugins/full-demo/plugin.yaml +++ b/data/plugins/full-demo/plugin.yaml @@ -3,7 +3,7 @@ name: Full Demo Plugin version: 1.0 description: Shows every current permission in one manifest. graptos_api_version: 1 -enabled: false +enabled: true native: lib/libgraptos_full_demo.so permissions: diff --git a/data/plugins/path-autocomplete/path_autocomplete.c b/data/plugins/path-autocomplete/path_autocomplete.c new file mode 100644 index 0000000..9e3a090 --- /dev/null +++ b/data/plugins/path-autocomplete/path_autocomplete.c @@ -0,0 +1,275 @@ +/** + * @file path_autocomplete.c + * @brief Native plugin that completes filesystem paths. + * @details Path detection and directory listing live in the plugin. Graptoς + * only provides the cursor context and renders the completion popup. + */ + +#include "plugin_api.h" + +#include +#include + +#define PATH_COMPLETION_MAX_RESULTS 64u + +/** + * @brief Return whether a character separates path tokens. + * @param ch Character to inspect. + * @return TRUE when the character ends the current path token. + */ +static gboolean path_token_separator(gunichar ch) { + return g_unichar_isspace(ch) || + ch == (gunichar)'"' || + ch == (gunichar)'\'' || + ch == (gunichar)'`' || + ch == (gunichar)'(' || + ch == (gunichar)'[' || + ch == (gunichar)'{' || + ch == (gunichar)'<' || + ch == (gunichar)'='; +} + +/** + * @brief Extract the path-like token before the cursor. + * @param line_prefix Text before the cursor on the active line. + * @return Owned path token, or NULL when no path-like token is present. + */ +static char *path_token_before_cursor(const char *line_prefix) { + if (!line_prefix || !line_prefix[0]) return NULL; + const char *start = line_prefix; + const char *p = line_prefix; + while (*p) { + const char *next = g_utf8_next_char(p); + if (path_token_separator(g_utf8_get_char(p))) start = next; + p = next; + } + if (!start || !start[0]) return NULL; + char *token = g_strdup(start); + if (!token) return NULL; + if (g_str_has_prefix(token, "/") || + g_str_has_prefix(token, "~/") || + g_strcmp0(token, "~") == 0 || + g_str_has_prefix(token, "./") || + g_str_has_prefix(token, "../") || + strchr(token, '/')) { + return token; + } + g_free(token); + return NULL; +} + +/** + * @brief Expand a token path for filesystem access. + * @param token Path token from the editor. + * @return Owned expanded path. + */ +static char *expand_token_path(const char *token) { + if (!token) return NULL; + if (g_strcmp0(token, "~") == 0) return g_strdup(g_get_home_dir()); + if (g_str_has_prefix(token, "~/")) { + return g_build_filename(g_get_home_dir(), token + 2, NULL); + } + return g_strdup(token); +} + +/** + * @brief Split a path token into directory and name prefix. + * @param token Editor path token. + * @param expanded_dir_out Owned filesystem directory path. + * @param display_dir_out Owned editor-visible directory prefix. + * @param name_prefix_out Owned filename prefix. + * @return TRUE when the token can be completed. + */ +static gboolean split_path_token(const char *token, + char **expanded_dir_out, + char **display_dir_out, + char **name_prefix_out) { + if (!token || !expanded_dir_out || !display_dir_out || !name_prefix_out) { + return FALSE; + } + const char *slash = strrchr(token, '/'); + char *display_dir = NULL; + char *name_prefix = NULL; + if (slash) { + display_dir = g_strndup(token, (gsize)(slash - token) + 1u); + name_prefix = g_strdup(slash + 1); + } else if (g_strcmp0(token, "~") == 0) { + display_dir = g_strdup("~/"); + name_prefix = g_strdup(""); + } else { + return FALSE; + } + + char *expanded_display_dir = expand_token_path(display_dir); + if (!expanded_display_dir || !g_file_test(expanded_display_dir, G_FILE_TEST_IS_DIR)) { + g_free(display_dir); + g_free(name_prefix); + g_free(expanded_display_dir); + return FALSE; + } + + *expanded_dir_out = expanded_display_dir; + *display_dir_out = display_dir; + *name_prefix_out = name_prefix; + return TRUE; +} + +/** + * @brief Return whether a candidate already exists. + * @param items Candidate array. + * @param value Candidate text. + * @return TRUE when value is already present. + */ +static gboolean candidate_exists(GPtrArray *items, const char *value) { + if (!items || !value) return FALSE; + for (guint i = 0u; i < items->len; i++) { + if (g_strcmp0(g_ptr_array_index(items, i), value) == 0) return TRUE; + } + return FALSE; +} + +/** + * @brief Add one path completion candidate. + * @param items Candidate array. + * @param display_dir Editor-visible directory prefix. + * @param expanded_dir Filesystem directory path. + * @param name Directory entry name. + */ +static void add_path_candidate(GPtrArray *items, + const char *display_dir, + const char *expanded_dir, + const char *name) { + if (!items || !display_dir || !expanded_dir || !name || name[0] == '.') { + return; + } + char *candidate = g_strconcat(display_dir, name, NULL); + char *child = g_build_filename(expanded_dir, name, NULL); + if (g_file_test(child, G_FILE_TEST_IS_DIR)) { + char *with_slash = g_strconcat(candidate, "/", NULL); + g_free(candidate); + candidate = with_slash; + } + if (!candidate_exists(items, candidate)) g_ptr_array_add(items, candidate); + else g_free(candidate); + g_free(child); +} + +/** + * @brief Build path completion candidates for the cursor. + * @param context Command context supplied by Graptoς. + * @param replace_prefix_out Owned replacement prefix returned to Graptoς. + * @param user_data Plugin data supplied during registration. + * @return GPtrArray of owned char* candidates, or NULL. + */ +static GPtrArray *path_completion_candidates(GraptosPluginCommandContext *context, + char **replace_prefix_out, + gpointer user_data) { + (void)user_data; + if (replace_prefix_out) *replace_prefix_out = NULL; + char *line_prefix = graptos_plugin_context_line_prefix(context); + char *token = path_token_before_cursor(line_prefix); + g_free(line_prefix); + if (!token) return NULL; + + char *expanded_dir = NULL; + char *display_dir = NULL; + char *name_prefix = NULL; + if (!split_path_token(token, &expanded_dir, &display_dir, &name_prefix)) { + g_free(token); + return NULL; + } + if (!g_path_is_absolute(expanded_dir) && !g_str_has_prefix(display_dir, "~")) { + char *file_path = graptos_plugin_context_file_path(context); + if (file_path) { + char *base_dir = g_path_get_dirname(file_path); + char *resolved = g_build_filename(base_dir, expanded_dir, NULL); + g_free(expanded_dir); + expanded_dir = resolved; + g_free(base_dir); + g_free(file_path); + } + } + + GDir *dir = g_dir_open(expanded_dir, 0, NULL); + if (!dir) { + g_free(token); + g_free(expanded_dir); + g_free(display_dir); + g_free(name_prefix); + return NULL; + } + + GPtrArray *items = g_ptr_array_new_with_free_func(g_free); + const char *name = NULL; + while (items && items->len < PATH_COMPLETION_MAX_RESULTS && + (name = g_dir_read_name(dir)) != NULL) { + if (name_prefix[0] != '\0' && !g_str_has_prefix(name, name_prefix)) { + continue; + } + add_path_candidate(items, display_dir, expanded_dir, name); + } + g_dir_close(dir); + + if (!items || items->len == 0u) { + if (items) g_ptr_array_free(items, TRUE); + items = NULL; + } else if (replace_prefix_out) { + *replace_prefix_out = g_strdup(token); + } + + g_free(token); + g_free(expanded_dir); + g_free(display_dir); + g_free(name_prefix); + return items; +} + +/** + * @brief Show path completions for the shortcut command. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void show_path_completion(GraptosPluginCommandContext *context, + gpointer user_data) { + char *replace_prefix = NULL; + GPtrArray *items = path_completion_candidates(context, + &replace_prefix, + user_data); + if (!items || items->len == 0u || !replace_prefix) { + graptos_plugin_context_set_status(context, "No path completions"); + g_free(replace_prefix); + if (items) g_ptr_array_free(items, TRUE); + return; + } + graptos_plugin_context_show_completions(context, + replace_prefix, + "Paths", + items); + g_free(replace_prefix); + g_ptr_array_free(items, TRUE); +} + +/** + * @brief Register this plugin with Graptoς. + * @param host Host capability object supplied by Graptoς. + * @return TRUE when registration succeeds. + */ +gboolean graptos_plugin_register(GraptosPluginHost *host) { + if (graptos_plugin_host_api_version(host) != GRAPTOS_PLUGIN_API_VERSION) { + return FALSE; + } + gboolean ok = graptos_plugin_host_register_completion_provider(host, + "path-autocomplete", + "Paths", + path_completion_candidates, + NULL, + NULL); + ok = graptos_plugin_host_register_editor_line_command_with_shortcut(host, + "path-autocomplete-show", + "Path Autocomplete", + "Ctrl+Alt+P", + show_path_completion, + NULL, + NULL) && ok; + return ok; +} diff --git a/data/plugins/path-autocomplete/plugin.yaml b/data/plugins/path-autocomplete/plugin.yaml new file mode 100644 index 0000000..aa0735f --- /dev/null +++ b/data/plugins/path-autocomplete/plugin.yaml @@ -0,0 +1,12 @@ +id: path-autocomplete +name: Path Autocomplete +version: 1.0 +description: Completes filesystem paths from a native completion provider. +graptos_api_version: 1 +enabled: true +native: lib/libgraptos_path_autocomplete.so + +permissions: + - native + - editor.read + - ui diff --git a/data/plugins/permission-command/plugin.yaml b/data/plugins/permission-command/plugin.yaml index 507a5b1..efb616b 100644 --- a/data/plugins/permission-command/plugin.yaml +++ b/data/plugins/permission-command/plugin.yaml @@ -3,7 +3,7 @@ name: External Command Demo version: 1.0 description: Demonstrates the command permission shape for future external process commands. graptos_api_version: 1 -enabled: false +enabled: true permissions: - command diff --git a/data/plugins/permission-editor-read/plugin.yaml b/data/plugins/permission-editor-read/plugin.yaml index 4e60b3f..ab4af74 100644 --- a/data/plugins/permission-editor-read/plugin.yaml +++ b/data/plugins/permission-editor-read/plugin.yaml @@ -3,7 +3,7 @@ name: Editor Read Demo version: 1.0 description: Adds a line word-count action that only reads editor text. graptos_api_version: 1 -enabled: false +enabled: true permissions: - editor.read diff --git a/data/plugins/permission-editor-write/plugin.yaml b/data/plugins/permission-editor-write/plugin.yaml index 3a55a33..46e8151 100644 --- a/data/plugins/permission-editor-write/plugin.yaml +++ b/data/plugins/permission-editor-write/plugin.yaml @@ -3,7 +3,7 @@ name: Editor Write Demo version: 1.0 description: Adds an action that inserts a section banner above the clicked line. graptos_api_version: 1 -enabled: false +enabled: true permissions: - editor.write diff --git a/data/plugins/permission-git/plugin.yaml b/data/plugins/permission-git/plugin.yaml index 970aca1..896bdbd 100644 --- a/data/plugins/permission-git/plugin.yaml +++ b/data/plugins/permission-git/plugin.yaml @@ -3,7 +3,7 @@ name: Git Blame Demo version: 1.0 description: Adds Git blame to the right-click editor line menu. graptos_api_version: 1 -enabled: false +enabled: true permissions: - editor.read diff --git a/data/plugins/permission-project-read/plugin.yaml b/data/plugins/permission-project-read/plugin.yaml index 9274ad8..28e3225 100644 --- a/data/plugins/permission-project-read/plugin.yaml +++ b/data/plugins/permission-project-read/plugin.yaml @@ -3,7 +3,7 @@ name: Project Read Demo version: 1.0 description: Shows a small project summary without writing files. graptos_api_version: 1 -enabled: false +enabled: true permissions: - project.read diff --git a/data/plugins/permission-project-write/plugin.yaml b/data/plugins/permission-project-write/plugin.yaml index 371eb54..dda48c4 100644 --- a/data/plugins/permission-project-write/plugin.yaml +++ b/data/plugins/permission-project-write/plugin.yaml @@ -3,7 +3,7 @@ name: Project Write Demo version: 1.0 description: Demonstrates the project.write permission shape for future scaffolding commands. graptos_api_version: 1 -enabled: false +enabled: true permissions: - project.write diff --git a/data/plugins/permission-syntax/plugin.yaml b/data/plugins/permission-syntax/plugin.yaml index 8136fd7..2fe44aa 100644 --- a/data/plugins/permission-syntax/plugin.yaml +++ b/data/plugins/permission-syntax/plugin.yaml @@ -3,7 +3,7 @@ name: QML Syntax Demo version: 1.0 description: Contributes a small QML syntax definition. graptos_api_version: 1 -enabled: false +enabled: true permissions: - syntax diff --git a/data/plugins/permission-template/plugin.yaml b/data/plugins/permission-template/plugin.yaml index 490ea41..40d90a5 100644 --- a/data/plugins/permission-template/plugin.yaml +++ b/data/plugins/permission-template/plugin.yaml @@ -3,7 +3,7 @@ name: Template Demo version: 1.0 description: Contributes a tiny project template. graptos_api_version: 1 -enabled: false +enabled: true permissions: - template diff --git a/data/plugins/permission-terminal/plugin.yaml b/data/plugins/permission-terminal/plugin.yaml index 4e0a35d..f001861 100644 --- a/data/plugins/permission-terminal/plugin.yaml +++ b/data/plugins/permission-terminal/plugin.yaml @@ -3,7 +3,7 @@ name: Terminal Demo version: 1.0 description: Demonstrates the terminal permission shape for future terminal-backed commands. graptos_api_version: 1 -enabled: false +enabled: true permissions: - terminal diff --git a/data/plugins/permission-ui/plugin.yaml b/data/plugins/permission-ui/plugin.yaml index d316833..d64b884 100644 --- a/data/plugins/permission-ui/plugin.yaml +++ b/data/plugins/permission-ui/plugin.yaml @@ -3,7 +3,7 @@ name: UI Demo version: 1.0 description: Adds a plugin information dialog through the editor context menu. graptos_api_version: 1 -enabled: false +enabled: true permissions: - ui diff --git a/src/editor_tab.h b/src/editor_tab.h index 1230d4c..93b7cb2 100644 --- a/src/editor_tab.h +++ b/src/editor_tab.h @@ -358,6 +358,21 @@ void editor_tab_select_all(EditorTab *tab); * @param manual The manual supplied by the caller. */ void editor_tab_show_completion(EditorTab *tab, gboolean manual); +/** + * @brief Show plugin supplied literal completions. + * @details Plugins pass the exact prefix to replace, so completion can handle + * non-word tokens such as filesystem paths. + * @param tab The editor tab whose completion popup should be shown. + * @param replace_prefix Text immediately before the cursor to replace. + * @param source_label Visible source heading. + * @param candidates GPtrArray of char* insertable candidates. + * @param manual TRUE when triggered by an explicit shortcut. + */ +void editor_tab_show_plugin_completion(EditorTab *tab, + const char *replace_prefix, + const char *source_label, + GPtrArray *candidates, + gboolean manual); /** * @brief Editor tab hide completion. * @details Editor code runs in response to fast input, delayed timeouts, and background language work. The notes here mark the boundary between immediate GTK state and deferred refresh paths so latency fixes do not turn into stale-widget bugs. diff --git a/src/editor_tab_completion.c b/src/editor_tab_completion.c index 26bd18b..88b02ab 100644 --- a/src/editor_tab_completion.c +++ b/src/editor_tab_completion.c @@ -29,7 +29,8 @@ static void completion_add_source_header(EditorTab *tab, const char *label_text) */ typedef enum { COMPLETION_SOURCE_GRAPTOS, /**< Graptoς YAML/index/import source. */ - COMPLETION_SOURCE_LSP /**< Language Server Protocol source. */ + COMPLETION_SOURCE_LSP, /**< Language Server Protocol source. */ + COMPLETION_SOURCE_PLUGIN /**< Native plugin completion source. */ } CompletionSource; /** @@ -456,7 +457,9 @@ static void completion_update_popup_size(EditorTab *tab, * @param tab The editor tab whose buffer or widgets are being inspected. * @param candidates The candidates supplied by the caller. */ -static void completion_populate_rows(EditorTab *tab, GPtrArray *candidates) { +static void completion_populate_rows(EditorTab *tab, + GPtrArray *candidates, + const char *plugin_source_label) { completion_clear_rows(tab); if (!candidates) { completion_update_popup_size(tab, 0u, FALSE); @@ -473,6 +476,8 @@ static void completion_populate_rows(EditorTab *tab, GPtrArray *candidates) { completion_add_source_header(tab, candidate->source == COMPLETION_SOURCE_LSP ? "LSP" + : candidate->source == COMPLETION_SOURCE_PLUGIN + ? (plugin_source_label ? plugin_source_label : "Plugins") : "Graptoς"); last_source = candidate->source; } @@ -578,11 +583,19 @@ void completion_clear_rows(EditorTab *tab) { void completion_insert_word(EditorTab *tab, const char *word) { if (!tab || tab->locked || !word || word[0] == '\0') return; - GtkTextIter start; GtkTextIter cursor; - char *prefix = completion_prefix_at_cursor(tab->buffer, &start, &cursor); + GtkTextIter start; + GtkTextMark *insert = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &cursor, insert); + char *prefix = tab->completion_prefix + ? g_strdup(tab->completion_prefix) + : completion_prefix_at_cursor(tab->buffer, &start, &cursor); if (!prefix) return; + start = cursor; + gint chars = (gint)g_utf8_strlen(prefix, -1); + while (chars > 0 && gtk_text_iter_backward_char(&start)) chars--; + gint start_offset = gtk_text_iter_get_offset(&start); gtk_text_buffer_begin_user_action(tab->buffer); if (!gtk_text_iter_equal(&start, &cursor)) { @@ -690,6 +703,49 @@ void completion_add_row(EditorTab *tab, const char *word) { completion_add_row_with_detail(tab, word, NULL); } +/** + * @brief Show plugin supplied literal completions. + * @details Native completion providers return the exact prefix to replace, so + * path-like values containing `/`, `.`, or `~` can use the same popup + * as normal word completion without exposing GTK internals. + * @param tab The editor tab whose completion popup should be shown. + * @param replace_prefix Text immediately before the cursor to replace. + * @param source_label Visible source heading. + * @param candidates GPtrArray of char* insertable candidates. + * @param manual TRUE when triggered by an explicit shortcut. + */ +void editor_tab_show_plugin_completion(EditorTab *tab, + const char *replace_prefix, + const char *source_label, + GPtrArray *candidates, + gboolean manual) { + if (!tab || !replace_prefix || replace_prefix[0] == '\0' || + !candidates || candidates->len == 0u) { + return; + } + GtkTextIter cursor; + GtkTextMark *insert = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &cursor, insert); + + GPtrArray *items = g_ptr_array_new_with_free_func(completion_candidate_free); + if (!items) return; + for (guint i = 0u; i < candidates->len; i++) { + const char *candidate = g_ptr_array_index(candidates, i); + completion_add_sourced(items, candidate, COMPLETION_SOURCE_PLUGIN); + } + if (items->len == 0u) { + g_ptr_array_free(items, TRUE); + return; + } + + completion_populate_rows(tab, items, source_label); + g_ptr_array_free(items, TRUE); + g_free(tab->completion_prefix); + tab->completion_prefix = g_strdup(replace_prefix); + tab->completion_manual = manual; + completion_place_popover(tab, &cursor, manual); +} + /** * @brief Editor tab show completion. * @details Editor code runs in response to fast input, delayed timeouts, and background language work. The notes here mark the boundary between immediate GTK state and deferred refresh paths so latency fixes do not turn into stale-widget bugs. @@ -706,6 +762,26 @@ void editor_tab_show_completion(EditorTab *tab, gboolean manual) { &cursor); if (!prefix) return; + g_autofree char *plugin_replace_prefix = NULL; + g_autofree char *plugin_source_label = NULL; + GPtrArray *plugin_items = tab->win && tab->win->plugins + ? graptos_plugin_registry_completion_candidates(tab->win->plugins, + tab, + &plugin_replace_prefix, + &plugin_source_label) + : NULL; + if (plugin_items && plugin_items->len > 0u && plugin_replace_prefix) { + editor_tab_show_plugin_completion(tab, + plugin_replace_prefix, + plugin_source_label, + plugin_items, + manual); + g_ptr_array_free(plugin_items, TRUE); + g_free(prefix); + return; + } + if (plugin_items) g_ptr_array_free(plugin_items, TRUE); + gboolean is_import = import_completion_tab_is_import_context(tab); MemberCompletionContext member_ctx = {0}; gboolean is_member = member_completion_context_at_cursor(tab->buffer, @@ -741,7 +817,7 @@ void editor_tab_show_completion(EditorTab *tab, gboolean manual) { return; } - completion_populate_rows(tab, candidates); + completion_populate_rows(tab, candidates, NULL); g_ptr_array_free(candidates, TRUE); g_free(tab->completion_prefix); tab->completion_prefix = prefix; diff --git a/src/editor_tab_keys.c b/src/editor_tab_keys.c index 8fe618d..b99149c 100644 --- a/src/editor_tab_keys.c +++ b/src/editor_tab_keys.c @@ -733,6 +733,17 @@ gboolean on_text_view_key_pressed(GtkEventControllerKey *controller, if (debug_typing) g_message("Typing: key-handled action=add-note"); return TRUE; } + if (ctrl && alt && key == GDK_KEY_p) { + gboolean handled = tab->win && tab->win->plugins + ? graptos_plugin_registry_run_shortcut(tab->win->plugins, + tab, + "Ctrl+Alt+P", + 0u) + : FALSE; + if (!handled) editor_tab_show_completion(tab, TRUE); + if (debug_typing) g_message("Typing: key-handled action=path-completion"); + return TRUE; + } if (alt || keyval == GDK_KEY_Alt_L || keyval == GDK_KEY_Alt_R) { tab->inspect_reference_active = TRUE; diff --git a/src/plugin.c b/src/plugin.c index 6701ff7..d00ddee 100644 --- a/src/plugin.c +++ b/src/plugin.c @@ -51,6 +51,7 @@ typedef struct { GraptosPlugin *plugin; /**< Plugin that owns the handler. */ char *command; /**< Command id. */ char *label; /**< Optional visible command label. */ + char *shortcut; /**< Optional stable shortcut id. */ GraptosPluginCommandFunc callback; /**< Native command callback. */ gpointer user_data; /**< Plugin-owned callback data. */ GraptosPluginDestroyFunc destroy; /**< Optional user data destroy hook. */ @@ -67,9 +68,35 @@ static void native_command_free(gpointer data) { if (command->destroy) command->destroy(command->user_data); g_free(command->command); g_free(command->label); + g_free(command->shortcut); g_free(command); } +/** + * @brief Registered native completion provider. + */ +typedef struct { + GraptosPlugin *plugin; /**< Plugin that owns the provider. */ + char *provider_id; /**< Provider id. */ + char *label; /**< Visible source label. */ + GraptosPluginCompletionFunc callback; /**< Completion callback. */ + gpointer user_data; /**< Plugin-owned callback data. */ + GraptosPluginDestroyFunc destroy; /**< Optional user data destroy hook. */ +} GraptosNativeCompletionProvider; + +/** + * @brief Free a native completion provider. + * @param data Native completion provider. + */ +static void native_completion_provider_free(gpointer data) { + GraptosNativeCompletionProvider *provider = data; + if (!provider) return; + if (provider->destroy) provider->destroy(provider->user_data); + g_free(provider->provider_id); + g_free(provider->label); + g_free(provider); +} + GQuark graptos_plugin_error_quark(void) { return g_quark_from_static_string("graptos-plugin-error-quark"); } @@ -99,6 +126,7 @@ const char *graptos_plugin_host_plugin_id(GraptosPluginHost *host) { static gboolean plugin_register_native_command(GraptosPluginHost *host, const char *command_id, const char *label, + const char *shortcut, gboolean editor_line, GraptosPluginCommandFunc callback, gpointer user_data, @@ -119,6 +147,7 @@ static gboolean plugin_register_native_command(GraptosPluginHost *host, command->plugin = plugin; command->command = g_strdup(command_id); command->label = label && label[0] ? g_strdup(label) : NULL; + command->shortcut = shortcut && shortcut[0] ? g_strdup(shortcut) : NULL; command->callback = callback; command->user_data = user_data; command->destroy = destroy; @@ -137,6 +166,7 @@ gboolean graptos_plugin_host_register_command(GraptosPluginHost *host, return plugin_register_native_command(host, command_id, NULL, + NULL, FALSE, callback, user_data, @@ -152,12 +182,57 @@ gboolean graptos_plugin_host_register_editor_line_command(GraptosPluginHost *hos return plugin_register_native_command(host, command_id, label, + NULL, + TRUE, + callback, + user_data, + destroy); +} + +gboolean graptos_plugin_host_register_editor_line_command_with_shortcut(GraptosPluginHost *host, + const char *command_id, + const char *label, + const char *shortcut, + GraptosPluginCommandFunc callback, + gpointer user_data, + GraptosPluginDestroyFunc destroy) { + return plugin_register_native_command(host, + command_id, + label, + shortcut, TRUE, callback, user_data, destroy); } +gboolean graptos_plugin_host_register_completion_provider(GraptosPluginHost *host, + const char *provider_id, + const char *label, + GraptosPluginCompletionFunc callback, + gpointer user_data, + GraptosPluginDestroyFunc destroy) { + if (!host || !host->plugin || !provider_id || !provider_id[0] || + !callback) { + return FALSE; + } + GraptosPlugin *plugin = host->plugin; + if (!plugin->native_completion_providers) { + plugin->native_completion_providers = + g_ptr_array_new_with_free_func(native_completion_provider_free); + } + GraptosNativeCompletionProvider *provider = g_new0(GraptosNativeCompletionProvider, 1); + if (!provider) return FALSE; + provider->plugin = plugin; + provider->provider_id = g_strdup(provider_id); + provider->label = g_strdup(label && label[0] ? label : "Plugin"); + provider->callback = callback; + provider->user_data = user_data; + provider->destroy = destroy; + g_ptr_array_add(plugin->native_completion_providers, provider); + return TRUE; +} + /** * @brief Strip a line comment. * @details Plugin manifests do not support quoted hashes yet. Matching the @@ -273,6 +348,8 @@ static GraptosPlugin *plugin_new(void) { g_str_equal, g_free, native_command_free); + plugin->native_completion_providers = + g_ptr_array_new_with_free_func(native_completion_provider_free); return plugin; } @@ -293,6 +370,9 @@ void graptos_plugin_free(GraptosPlugin *plugin) { if (plugin->commands) g_ptr_array_free(plugin->commands, TRUE); if (plugin->menus) g_ptr_array_free(plugin->menus, TRUE); if (plugin->native_commands) g_hash_table_destroy(plugin->native_commands); + if (plugin->native_completion_providers) { + g_ptr_array_free(plugin->native_completion_providers, TRUE); + } g_free(plugin); } @@ -580,6 +660,12 @@ typedef struct { guint line; /**< One-based editor line. */ } GraptosPluginEditorMenuAction; +static guint plugin_tab_cursor_line(EditorTab *tab); +static void plugin_run_native_command(GraptosPlugin *plugin, + GraptosNativeCommand *command, + EditorTab *tab, + guint line); + /** * @brief Free a plugin editor menu action. * @param data Menu action data. @@ -670,6 +756,19 @@ char *graptos_plugin_context_line_text(GraptosPluginCommandContext *context, return plugin_editor_line_text(context->tab, line); } +char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context) { + if (!context || !context->tab || !context->tab->buffer) return g_strdup(""); + GtkTextIter cursor; + GtkTextMark *insert = gtk_text_buffer_get_insert(context->tab->buffer); + gtk_text_buffer_get_iter_at_mark(context->tab->buffer, &cursor, insert); + GtkTextIter start = cursor; + gtk_text_iter_set_line_offset(&start, 0); + return gtk_text_buffer_get_text(context->tab->buffer, + &start, + &cursor, + FALSE); +} + gboolean graptos_plugin_context_insert_text(GraptosPluginCommandContext *context, const char *text) { if (!context || !context->tab || !context->tab->buffer || !text) return FALSE; @@ -712,6 +811,18 @@ void graptos_plugin_context_set_status(GraptosPluginCommandContext *context, app_window_set_status(context->tab->win, text ? text : ""); } +void graptos_plugin_context_show_completions(GraptosPluginCommandContext *context, + const char *replace_prefix, + const char *source_label, + GPtrArray *candidates) { + if (!context || !context->tab) return; + editor_tab_show_plugin_completion(context->tab, + replace_prefix, + source_label, + candidates, + TRUE); +} + guint graptos_plugin_context_tab_count(GraptosPluginCommandContext *context) { return context && context->tab && context->tab->win ? app_window_tab_count(context->tab->win) @@ -783,12 +894,7 @@ static void plugin_run_editor_command(GraptosPluginEditorMenuAction *action) { ? g_hash_table_lookup(action->plugin->native_commands, action->command) : NULL; if (native && native->callback) { - GraptosPluginCommandContext context = {0}; - context.plugin = action->plugin; - context.tab = tab; - context.command = action->command; - context.line = action->line; - native->callback(&context, native->user_data); + plugin_run_native_command(action->plugin, native, tab, action->line); return; } if (g_strcmp0(action->command, "git-blame-line") == 0) { @@ -1015,6 +1121,38 @@ static guint plugin_append_editor_line_specs(GraptosPluginRegistry *registry, } return added; } + +/** + * @brief Return active cursor line for plugin command context. + * @param tab Editor tab to inspect. + * @return One-based cursor line. + */ +static guint plugin_tab_cursor_line(EditorTab *tab) { + if (!tab || !tab->buffer) return 1u; + GtkTextIter iter; + GtkTextMark *insert = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &iter, insert); + return (guint)gtk_text_iter_get_line(&iter) + 1u; +} + +/** + * @brief Run one native command against a tab. + * @param plugin Plugin owning the command. + * @param command Native command to run. + * @param tab Editor tab receiving the command. + */ +static void plugin_run_native_command(GraptosPlugin *plugin, + GraptosNativeCommand *command, + EditorTab *tab, + guint line) { + if (!plugin || !command || !command->callback || !tab) return; + GraptosPluginCommandContext context = {0}; + context.plugin = plugin; + context.tab = tab; + context.command = command->command; + context.line = line ? line : plugin_tab_cursor_line(tab); + command->callback(&context, command->user_data); +} #else const char *graptos_plugin_context_plugin_id(GraptosPluginCommandContext *context) { return context && context->plugin ? context->plugin->id : NULL; @@ -1050,6 +1188,11 @@ char *graptos_plugin_context_line_text(GraptosPluginCommandContext *context, return g_strdup(""); } +char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context) { + (void)context; + return g_strdup(""); +} + gboolean graptos_plugin_context_insert_text(GraptosPluginCommandContext *context, const char *text) { (void)context; @@ -1080,6 +1223,16 @@ void graptos_plugin_context_set_status(GraptosPluginCommandContext *context, (void)text; } +void graptos_plugin_context_show_completions(GraptosPluginCommandContext *context, + const char *replace_prefix, + const char *source_label, + GPtrArray *candidates) { + (void)context; + (void)replace_prefix; + (void)source_label; + (void)candidates; +} + guint graptos_plugin_context_tab_count(GraptosPluginCommandContext *context) { (void)context; return 0u; @@ -1248,3 +1401,87 @@ guint graptos_plugin_append_editor_tool_items(GraptosPluginRegistry *registry, TRUE); #endif } + +gboolean graptos_plugin_registry_run_shortcut(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *shortcut, + guint line) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)shortcut; + (void)line; + return FALSE; +#else + if (!registry || !registry->plugins || !tab || !shortcut) return FALSE; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->enabled || !plugin->native_commands) continue; + GHashTableIter iter; + gpointer value = NULL; + g_hash_table_iter_init(&iter, plugin->native_commands); + while (g_hash_table_iter_next(&iter, NULL, &value)) { + GraptosNativeCommand *command = value; + if (!command || g_strcmp0(command->shortcut, shortcut) != 0) { + continue; + } + plugin_run_native_command(plugin, command, tab, line); + return TRUE; + } + } + return FALSE; +#endif +} + +GPtrArray *graptos_plugin_registry_completion_candidates(GraptosPluginRegistry *registry, + EditorTab *tab, + char **replace_prefix_out, + char **source_label_out) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)replace_prefix_out; + (void)source_label_out; + return NULL; +#else + if (replace_prefix_out) *replace_prefix_out = NULL; + if (source_label_out) *source_label_out = NULL; + if (!registry || !registry->plugins || !tab) return NULL; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->enabled || + !plugin->native_completion_providers) { + continue; + } + for (guint j = 0u; j < plugin->native_completion_providers->len; j++) { + GraptosNativeCompletionProvider *provider = + g_ptr_array_index(plugin->native_completion_providers, j); + if (!provider || !provider->callback) continue; + GraptosPluginCommandContext context = {0}; + context.plugin = plugin; + context.tab = tab; + context.command = provider->provider_id; + context.line = plugin_tab_cursor_line(tab); + g_autofree char *replace_prefix = NULL; + GPtrArray *items = provider->callback(&context, + &replace_prefix, + provider->user_data); + if (!items || items->len == 0u || !replace_prefix || + replace_prefix[0] == '\0') { + if (items) g_ptr_array_free(items, TRUE); + continue; + } + if (replace_prefix_out) { + *replace_prefix_out = g_steal_pointer(&replace_prefix); + } + if (source_label_out) { + *source_label_out = g_strdup(provider->label + ? provider->label + : "Plugin"); + } + return items; + } + } + return NULL; +#endif +} diff --git a/src/plugin.h b/src/plugin.h index 050ed49..7a66c4f 100644 --- a/src/plugin.h +++ b/src/plugin.h @@ -64,6 +64,7 @@ typedef struct { GPtrArray *menus; /**< char*. */ gpointer native_handle; /**< Private GModule handle. */ GHashTable *native_commands; /**< char* to native command handler. */ + GPtrArray *native_completion_providers; /**< Native completion providers. */ } GraptosPlugin; /** @@ -169,5 +170,33 @@ guint graptos_plugin_append_editor_tool_items(GraptosPluginRegistry *registry, EditorTab *tab, GtkWidget *box, guint line); +/** + * @brief Run a plugin shortcut command. + * @details Keyboard handling passes stable shortcut ids here so plugins can + * own behavior without the editor knowing command ids. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param shortcut Stable shortcut id, such as `Ctrl+Alt+P`. + * @param line One-based active editor line. + * @return TRUE when a plugin handled the shortcut. + */ +gboolean graptos_plugin_registry_run_shortcut(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *shortcut, + guint line); +/** + * @brief Collect native plugin completion candidates. + * @details The first provider that returns candidates owns the replacement + * prefix and visible source label for this completion popup. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param replace_prefix_out Owned prefix text to replace on accept. + * @param source_label_out Owned source label for the popup header. + * @return GPtrArray of owned char* candidates, or NULL. + */ +GPtrArray *graptos_plugin_registry_completion_candidates(GraptosPluginRegistry *registry, + EditorTab *tab, + char **replace_prefix_out, + char **source_label_out); #endif /* GRAPTOS_PLUGIN_H */ diff --git a/src/plugin_api.h b/src/plugin_api.h index e8b16f3..4f5c185 100644 --- a/src/plugin_api.h +++ b/src/plugin_api.h @@ -37,6 +37,20 @@ typedef struct _GraptosPluginCommandContext GraptosPluginCommandContext; typedef void (*GraptosPluginCommandFunc)(GraptosPluginCommandContext *context, gpointer user_data); +/** + * @brief Native plugin completion callback. + * @details Providers inspect the command context and return insertable strings + * for the current cursor. Set replace_prefix_out to the exact text + * Graptoς should replace when a candidate is accepted. + * @param context Opaque execution context for the current editor cursor. + * @param replace_prefix_out Owned replacement prefix returned by the plugin. + * @param user_data Plugin data supplied during provider registration. + * @return GPtrArray of owned char* candidates, or NULL when there are none. + */ +typedef GPtrArray *(*GraptosPluginCompletionFunc)(GraptosPluginCommandContext *context, + char **replace_prefix_out, + gpointer user_data); + /** * @brief Native plugin data destroy callback. * @param user_data Plugin-owned data supplied during command registration. @@ -109,6 +123,47 @@ gboolean graptos_plugin_host_register_editor_line_command(GraptosPluginHost *hos gpointer user_data, GraptosPluginDestroyFunc destroy); +/** + * @brief Register a native editor line command with a shortcut. + * @details The shortcut is a stable text id such as `Ctrl+Alt+P`. Graptoς owns + * key matching and invokes the registered command when it is pressed. + * @param host The host capability object supplied by Graptoς. + * @param command_id Stable command id owned by the plugin. + * @param label Human-facing command label. + * @param shortcut Shortcut id, or NULL for no shortcut. + * @param callback Native function to run for the command. + * @param user_data Plugin data passed to the callback. + * @param destroy Optional destroy callback for user_data. + * @return TRUE when the command was accepted. + */ +gboolean graptos_plugin_host_register_editor_line_command_with_shortcut(GraptosPluginHost *host, + const char *command_id, + const char *label, + const char *shortcut, + GraptosPluginCommandFunc callback, + gpointer user_data, + GraptosPluginDestroyFunc destroy); + +/** + * @brief Register a native completion provider. + * @details Providers are asked during normal editor completion. The provider + * decides when it applies by returning candidates only for matching + * cursor context. + * @param host The host capability object supplied by Graptoς. + * @param provider_id Stable provider id owned by the plugin. + * @param label Human-facing source label. + * @param callback Native completion callback. + * @param user_data Plugin data passed to the callback. + * @param destroy Optional destroy callback for user_data. + * @return TRUE when the provider was accepted. + */ +gboolean graptos_plugin_host_register_completion_provider(GraptosPluginHost *host, + const char *provider_id, + const char *label, + GraptosPluginCompletionFunc callback, + gpointer user_data, + GraptosPluginDestroyFunc destroy); + /** * @brief Return the plugin id for a command context. * @param context Command context supplied by Graptoς. @@ -168,6 +223,15 @@ char *graptos_plugin_context_selection(GraptosPluginCommandContext *context); char *graptos_plugin_context_line_text(GraptosPluginCommandContext *context, guint line); +/** + * @brief Return text before the cursor on the active line. + * @details The returned string is owned by the caller and must be freed with + * g_free(). + * @param context Command context supplied by Graptoς. + * @return Owned line prefix text, or an empty string when unavailable. + */ +char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context); + /** * @brief Insert text at the active cursor. * @param context Command context supplied by Graptoς. @@ -206,6 +270,21 @@ void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, void graptos_plugin_context_set_status(GraptosPluginCommandContext *context, const char *text); +/** + * @brief Show plugin supplied completions. + * @details The provider passes the exact prefix to replace and a list of owned + * or borrowed candidate strings. Graptoς copies candidate text before + * showing the popup. + * @param context Command context supplied by Graptoς. + * @param replace_prefix Text immediately before the cursor to replace. + * @param source_label Visible source heading. + * @param candidates GPtrArray of char* candidate text. + */ +void graptos_plugin_context_show_completions(GraptosPluginCommandContext *context, + const char *replace_prefix, + const char *source_label, + GPtrArray *candidates); + /** * @brief Return the number of open editor tabs. * @param context Command context supplied by Graptoς. diff --git a/syntax/c.yaml b/syntax/c.yaml index 03bf12a..c0b1f95 100644 --- a/syntax/c.yaml +++ b/syntax/c.yaml @@ -8,7 +8,7 @@ line_comment: // auto_indent: true indent_openers: ["{"] indent_closers: ["}"] -close_pairs: ["(=>)", "{=>}", "[=>]"] +close_pairs: ["(=>)", "{=>}", "[=>]", '"=>"'] line_close_pairs: ["#include <=>>"] statement_required_enders: [";"] statement_exempt_prefixes: ["#", "//", "/*", "*", "}", "{", "else", "case ", "default:", "if ", "if(", "for ", "for(", "while ", "while(", "switch ", "switch(", "do", "struct ", "enum ", "union ", "typedef "]