From 6f085b4002b61b30c8eeeb90dead0b5a8dbe82ec Mon Sep 17 00:00:00 2001 From: "A. Madani" Date: Tue, 4 Aug 2026 23:02:03 +0300 Subject: [PATCH] natives plugins for beancount and a test taskwarrior plugin and ui fixes --- .gitignore | 1 + Makefile | 4 +- .../plugins/beancount-tools/beancount_tools.c | 72 ++ .../beancount_tools_commands.c | 181 +++++ .../beancount-tools/beancount_tools_common.c | 525 +++++++++++++ .../beancount_tools_completion.c | 109 +++ .../beancount-tools/beancount_tools_config.h | 35 + .../beancount-tools/beancount_tools_hover.c | 108 +++ .../beancount-tools/beancount_tools_report.c | 725 ++++++++++++++++++ data/plugins/beancount-tools/plugin.yaml | 15 + data/plugins/full-demo/full_demo.c | 54 ++ .../full-demo/syntax/full-demo-log.yaml | 2 +- data/plugins/taskwarrior/plugin.yaml | 14 + data/plugins/taskwarrior/taskwarrior.c | 374 +++++++++ data/themes/apprentice.css | 21 + data/themes/dracula.css | 21 + data/themes/github.css | 21 + data/themes/graptos.css | 21 + data/themes/gruvbox.css | 21 + data/themes/moonfly.css | 21 + data/themes/srcery.css | 21 + data/themes/tokyonight.css | 21 + src/app.h | 10 + src/app/app_file_search_actions.inc.c | 32 +- src/app/app_window_events.inc.c | 2 +- src/app/app_window_lifecycle.inc.c | 38 +- src/app/app_window_state.inc.c | 1 + src/app_layout.c | 596 +++++++++++++- src/app_private.h | 1 + src/config.c | 129 ++++ src/config.h | 25 + src/editor/editor_tab_context_menu.inc.c | 107 ++- .../editor_tab_highlight_scheduling.inc.c | 34 +- src/editor/editor_tab_hover_events.inc.c | 81 ++ src/editor/editor_tab_hover_refs.inc.c | 10 +- src/editor/editor_tab_lifecycle.inc.c | 6 + .../editor_tab_signals_preferences.inc.c | 10 +- src/editor_tab.h | 12 + src/editor_tab_build.c | 20 +- src/editor_tab_gutter.c | 1 + src/editor_tab_io.c | 211 +++++ src/editor_tab_keys.c | 11 + src/editor_tab_latex.c | 68 +- src/editor_tab_preview.c | 22 +- src/editor_tab_private.h | 30 +- src/editor_tab_sourceview.c | 280 ++++++- src/plugin.c | 655 +++++++++++++++- src/plugin.h | 109 +++ src/plugin_api.h | 165 ++++ src/syntax.c | 27 + src/syntax_loader.c | 16 +- src/terminal_panel.c | 12 +- src/ui/ui_base_css.inc.c | 1 + src/ui/ui_editor_css.inc.c | 16 + syntax/beancount.yaml | 40 +- syntax/c.yaml | 2 +- tests/unit_tests.c | 45 ++ 57 files changed, 5052 insertions(+), 160 deletions(-) create mode 100644 data/plugins/beancount-tools/beancount_tools.c create mode 100644 data/plugins/beancount-tools/beancount_tools_commands.c create mode 100644 data/plugins/beancount-tools/beancount_tools_common.c create mode 100644 data/plugins/beancount-tools/beancount_tools_completion.c create mode 100644 data/plugins/beancount-tools/beancount_tools_config.h create mode 100644 data/plugins/beancount-tools/beancount_tools_hover.c create mode 100644 data/plugins/beancount-tools/beancount_tools_report.c create mode 100644 data/plugins/beancount-tools/plugin.yaml create mode 100644 data/plugins/full-demo/full_demo.c create mode 100644 data/plugins/taskwarrior/plugin.yaml create mode 100644 data/plugins/taskwarrior/taskwarrior.c diff --git a/.gitignore b/.gitignore index cab499e..2f1e7a2 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ build/ .zig-cache/ git_check.sh .graptos/ +.graptos-task.ini # Github actions .github/ diff --git a/Makefile b/Makefile index 905aaff..7988e37 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ APP_NAME := graptos APP_ID := io.github.graptos.Editor -VERSION := 0.23.74 +VERSION := 0.23.82 PREFIX ?= /usr/local CC ?= cc BUILD_DIR := build @@ -130,7 +130,7 @@ check: $(VERSION_HEADER) test: $(BUILD_DIR)/unit_tests $< -$(BUILD_DIR)/unit_tests: $(TEST_DIR)/unit_tests.c $(SRC_DIR)/codex_protocol.c $(SRC_DIR)/syntax_diagnostics.c $(SRC_DIR)/project_init.c $(SRC_DIR)/formatter.c $(SRC_DIR)/formatter_lexer.c $(SRC_DIR)/formatter_layout.c $(SRC_DIR)/formatter_spacing.c $(SRC_DIR)/formatter_scope.c $(SRC_DIR)/syntax.c $(SRC_DIR)/editor_notes.c $(SRC_DIR)/plugin.c | $(BUILD_DIR) +$(BUILD_DIR)/unit_tests: $(TEST_DIR)/unit_tests.c $(SRC_DIR)/codex_protocol.c $(SRC_DIR)/syntax_diagnostics.c $(SRC_DIR)/project_init.c $(SRC_DIR)/formatter.c $(SRC_DIR)/formatter_lexer.c $(SRC_DIR)/formatter_layout.c $(SRC_DIR)/formatter_spacing.c $(SRC_DIR)/formatter_scope.c $(SRC_DIR)/syntax.c $(SRC_DIR)/syntax_loader.c $(SRC_DIR)/editor_notes.c $(SRC_DIR)/plugin.c | $(BUILD_DIR) $(CC) $(CPPFLAGS) -DGRAPTOS_PLUGIN_NO_UI $(CFLAGS) $(WARNINGS) $(GTK_SYSTEM_CFLAGS) -std=c11 $^ $(GTK_LIBS) -o $@ smoke-test: $(BUILD_DIR)/smoke_window diff --git a/data/plugins/beancount-tools/beancount_tools.c b/data/plugins/beancount-tools/beancount_tools.c new file mode 100644 index 0000000..a25aaa0 --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools.c @@ -0,0 +1,72 @@ +/** + * @file beancount_tools.c + * @brief Native plugin entry point for Beancount ledger editing. + * @details Feature modules are included into one translation unit so plugin + * builds can select modules with compile-time flags while still + * producing one shared object. + */ + +#include "beancount_tools_config.h" + +#include "beancount_tools_common.c" + +#if BEANCOUNT_TOOLS_ENABLE_COMPLETION +#include "beancount_tools_completion.c" +#endif + +#if BEANCOUNT_TOOLS_ENABLE_HOVER +#include "beancount_tools_hover.c" +#endif + +#if BEANCOUNT_TOOLS_ENABLE_COMMANDS +#include "beancount_tools_commands.c" +#endif + +#if BEANCOUNT_TOOLS_ENABLE_REPORT +#include "beancount_tools_report.c" +#endif + +/** + * @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 t_done = TRUE; +#if BEANCOUNT_TOOLS_ENABLE_COMPLETION + t_done = graptos_plugin_host_register_completion_provider(host, + "beancount-completion", + "Beancount", + beancount_completion_candidates, + NULL, + NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command_with_shortcut(host, + "beancount-complete", + "Beancount Complete", + "Ctrl+Alt+B", + show_beancount_completion, + NULL, + NULL) && t_done; +#endif +#if BEANCOUNT_TOOLS_ENABLE_HOVER + t_done = graptos_plugin_host_register_hover_provider(host, + "beancount-account-balance", + "Beancount balance", + account_balance_hover, + NULL, + NULL) && t_done; +#endif +#if BEANCOUNT_TOOLS_ENABLE_COMMANDS + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-validate", "Validate Ledger", validate_ledger, NULL, NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-query", "Query Selection", query_selection, NULL, NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-summary", "Ledger Summary", ledger_summary, NULL, NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-format", "Format Ledger", format_ledger, NULL, NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-context", "Account Context", account_context, NULL, NULL) && t_done; +#endif +#if BEANCOUNT_TOOLS_ENABLE_REPORT + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-balance-preview", "Balance Sheet Preview", balance_sheet_preview, NULL, NULL) && t_done; + t_done = graptos_plugin_host_register_editor_line_command(host, "beancount-balance-export", "Export Balance Sheet PDF", export_balance_sheet_pdf, NULL, NULL) && t_done; +#endif + return t_done; +} diff --git a/data/plugins/beancount-tools/beancount_tools_commands.c b/data/plugins/beancount-tools/beancount_tools_commands.c new file mode 100644 index 0000000..75fdc43 --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_commands.c @@ -0,0 +1,181 @@ +/** + * @brief Publish bean-check diagnostics from tool output. + * @param context Command context supplied by Graptoς. + * @param output Combined bean-check output. + */ +static void publish_check_diagnostics(GraptosPluginCommandContext *context, + const char *output) { + graptos_plugin_context_clear_diagnostics(context); + char **lines = g_strsplit(output ? output : "", "\n", -1); + for (guint i = 0u; lines && lines[i]; i++) { + const char *p = lines[i]; + while ((p = strchr(p, ':')) != NULL) { + p++; + if (!g_ascii_isdigit(*p)) continue; + guint line = 0u; + while (g_ascii_isdigit(*p)) { + line = line * 10u + (guint)(*p - '0'); + p++; + } + if (*p == ':' && line > 0u) { + graptos_plugin_context_add_line_diagnostic(context, line, lines[i]); + break; + } + } + } + g_strfreev(lines); +} + +/** + * @brief Run bean-check and publish line diagnostics. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void validate_ledger(GraptosPluginCommandContext *context, gpointer user_data) { + (void)user_data; + char *path = saved_beancount_path(context, "Validate Ledger"); + if (!path) return; + if (!g_find_program_in_path("bean-check")) { + graptos_plugin_context_show_output(context, "Validate Ledger", "bean-check Missing", "Install Beancount to use bean-check."); + g_free(path); + return; + } + char *cwd = g_path_get_dirname(path); + char *argv[] = { "bean-check", path, NULL }; + char *stdout_text = NULL; + char *stderr_text = NULL; + gint status = 0; + (void)run_tool(cwd, argv, &stdout_text, &stderr_text, &status); + char *combined = g_strconcat(stdout_text ? stdout_text : "", stderr_text ? stderr_text : "", NULL); + publish_check_diagnostics(context, combined); + char *body = status == 0 && (!combined || !combined[0]) + ? g_strdup("bean-check passed.") + : format_tool_output("bean-check", status, stdout_text, stderr_text); + graptos_plugin_context_show_output(context, "Validate Ledger", "Bean Check", body); + g_free(body); + g_free(combined); + g_free(stdout_text); + g_free(stderr_text); + g_free(cwd); + g_free(path); +} + +/** + * @brief Run a bean-query command. + * @param context Command context supplied by Graptoς. + * @param query Beancount query text. + * @param title Dialog title. + */ +static void run_query(GraptosPluginCommandContext *context, + const char *query, + const char *title) { + char *path = saved_beancount_path(context, title); + if (!path) return; + if (!g_find_program_in_path("bean-query")) { + graptos_plugin_context_show_output(context, title, "bean-query Missing", "Install Beancount to use bean-query."); + g_free(path); + return; + } + char *cwd = g_path_get_dirname(path); + char *argv[] = { "bean-query", path, (char *)query, NULL }; + char *stdout_text = NULL; + char *stderr_text = NULL; + gint status = 0; + (void)run_tool(cwd, argv, &stdout_text, &stderr_text, &status); + char *body = format_tool_output(query, status, stdout_text, stderr_text); + graptos_plugin_context_show_output(context, title, "Bean Query", body); + g_free(body); + g_free(stdout_text); + g_free(stderr_text); + g_free(cwd); + g_free(path); +} + +/** + * @brief Query the selection or fall back to balances. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void query_selection(GraptosPluginCommandContext *context, gpointer user_data) { + (void)user_data; + char *selection = graptos_plugin_context_selection(context); + g_strstrip(selection); + run_query(context, selection && selection[0] ? selection : "BALANCES", "Query Selection"); + g_free(selection); +} + +/** + * @brief Show a compact ledger balance summary. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void ledger_summary(GraptosPluginCommandContext *context, gpointer user_data) { + (void)user_data; + run_query(context, "BALANCES", "Ledger Summary"); +} + +/** + * @brief Replace the editor text with bean-format output. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void format_ledger(GraptosPluginCommandContext *context, gpointer user_data) { + (void)user_data; + char *path = saved_beancount_path(context, "Format Ledger"); + if (!path) return; + if (!g_find_program_in_path("bean-format")) { + graptos_plugin_context_show_output(context, "Format Ledger", "bean-format Missing", "Install Beancount to use bean-format."); + g_free(path); + return; + } + char *cwd = g_path_get_dirname(path); + char *argv[] = { "bean-format", path, NULL }; + char *stdout_text = NULL; + char *stderr_text = NULL; + gint status = 0; + (void)run_tool(cwd, argv, &stdout_text, &stderr_text, &status); + if (status == 0 && stdout_text && stdout_text[0]) { + if (graptos_plugin_context_replace_text(context, stdout_text)) { + graptos_plugin_context_set_status(context, "Beancount formatted"); + } + } else { + char *body = format_tool_output("bean-format", status, stdout_text, stderr_text); + graptos_plugin_context_show_output(context, "Format Ledger", "Bean Format", body); + g_free(body); + } + g_free(stdout_text); + g_free(stderr_text); + g_free(cwd); + g_free(path); +} + +/** + * @brief Show bean-doctor context for the active line. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void account_context(GraptosPluginCommandContext *context, gpointer user_data) { + (void)user_data; + char *path = saved_beancount_path(context, "Account Context"); + if (!path) return; + if (!g_find_program_in_path("bean-doctor")) { + graptos_plugin_context_show_output(context, "Account Context", "bean-doctor Missing", "Install Beancount to use bean-doctor."); + g_free(path); + return; + } + char *line = g_strdup_printf("%u", graptos_plugin_context_line(context)); + char *cwd = g_path_get_dirname(path); + char *argv[] = { "bean-doctor", "context", path, line, NULL }; + char *stdout_text = NULL; + char *stderr_text = NULL; + gint status = 0; + (void)run_tool(cwd, argv, &stdout_text, &stderr_text, &status); + char *body = format_tool_output("bean-doctor context", status, stdout_text, stderr_text); + graptos_plugin_context_show_output(context, "Account Context", "Bean Doctor", body); + g_free(body); + g_free(stdout_text); + g_free(stderr_text); + g_free(cwd); + g_free(line); + g_free(path); +} diff --git a/data/plugins/beancount-tools/beancount_tools_common.c b/data/plugins/beancount-tools/beancount_tools_common.c new file mode 100644 index 0000000..d0b993f --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_common.c @@ -0,0 +1,525 @@ +/** + * @file beancount_tools.c + * @brief Native plugin for Beancount ledger editing. + * @details The plugin keeps Beancount-specific work outside Graptoς core: + * command execution uses installed bean-* tools, diagnostics are + * published through the public plugin ABI, and completions are built + * from the active buffer plus bounded include files. + */ + +#include "plugin_api.h" + +#include +#include +#include + +#define BEANCOUNT_MAX_OUTPUT_BYTES (128u * 1024u) +#define BEANCOUNT_MAX_COMPLETIONS 64u +#define BEANCOUNT_MAX_INCLUDE_FILES 32u +#define BEANCOUNT_MAX_INCLUDE_BYTES (2u * 1024u * 1024u) + +/** + * @brief Beancount balance query used by the report preview. + */ +#define BEANCOUNT_BALANCE_QUERY "BALANCES" + +/** + * @brief Completion dictionary built from ledger text. + */ +typedef struct { + GHashTable *accounts; /**< Account names from postings and open directives. */ + GHashTable *commodities; /**< Commodity symbols from directives and amounts. */ + GHashTable *payees; /**< Payee strings from transactions. */ + GHashTable *narrations; /**< Narration strings from transactions. */ + GHashTable *tags; /**< Tag tokens including #. */ + GHashTable *links; /**< Link tokens including ^. */ + GHashTable *metadata; /**< Metadata keys including trailing colon. */ + GHashTable *balances; /**< Account to commodity balance tables. */ + GHashTable *visited; /**< Included files already parsed. */ + guint files; /**< Number of external include files parsed. */ + gsize bytes; /**< Total include bytes parsed. */ +} BeanIndex; + +/** + * @brief One parsed balance report row. + */ +typedef struct { + char *account; /**< Beancount account name. */ + char *balance; /**< Balance text returned by bean-query. */ +} BeanReportRow; + +/** + * @brief One balance report section. + */ +typedef struct { + char *root; /**< Account root shown as the section heading. */ + GPtrArray *rows; /**< BeanReportRow*. */ + GHashTable *totals; /**< Commodity to double subtotal. */ +} BeanReportSection; + +/** + * @brief Parsed balance sheet report. + */ +typedef struct { + char *ledger_path; /**< Ledger file used for bean-query. */ + char *ledger_name; /**< Ledger basename. */ + char *generated_at; /**< Human-readable generation timestamp. */ + char *raw_output; /**< Full bean-query output. */ + GPtrArray *sections; /**< BeanReportSection*. */ +} BeanReport; + +static const char *DIRECTIVES[] = { + "open", "close", "commodity", "pad", "balance", "event", "price", + "note", "document", "query", "custom", "txn", "option", "plugin", + "include", "pushtag", "poptag" +}; + +gboolean graptos_plugin_register(GraptosPluginHost *host); + +/** + * @brief Return whether a path looks like a Beancount ledger. + * @param path Active file path. + * @return TRUE when the extension is supported. + */ +static gboolean is_beancount_path(const char *path) { + return path && + (g_str_has_suffix(path, ".beancount") || + g_str_has_suffix(path, ".bean")); +} + +/** + * @brief Return whether the active editor is a Beancount editor. + * @param context Command context supplied by Graptoς. + * @return TRUE when Beancount commands should apply. + */ +static gboolean is_beancount_context(GraptosPluginCommandContext *context) { + const char *syntax = graptos_plugin_context_syntax_name(context); + if (g_strcmp0(syntax, "Beancount") == 0) return TRUE; + char *path = graptos_plugin_context_file_path(context); + gboolean match = is_beancount_path(path); + g_free(path); + return match; +} + +/** + * @brief Add an owned copy to a string set. + * @param set Hash table used as a set. + * @param value Candidate text. + */ +static void set_add(GHashTable *set, const char *value) { + if (!set || !value || !value[0]) return; + g_hash_table_add(set, g_strdup(value)); +} + +/** + * @brief Return whether a Beancount account token starts here. + * @param text Text to inspect. + * @return TRUE when the token begins with a known account root. + */ +static gboolean starts_account_root(const char *text) { + return g_str_has_prefix(text, "Assets:") || + g_str_has_prefix(text, "Liabilities:") || + g_str_has_prefix(text, "Equity:") || + g_str_has_prefix(text, "Income:") || + g_str_has_prefix(text, "Expenses:"); +} + +/** + * @brief Free one account balance table. + * @param data Commodity balance table. + */ +static void balance_table_free(gpointer data) { + GHashTable *table = data; + if (table) g_hash_table_destroy(table); +} + +/** + * @brief Return the balance table for an account. + * @param index Ledger index. + * @param account Account name. + * @return Commodity balance table owned by the index. + */ +static GHashTable *balance_table_for_account(BeanIndex *index, + const char *account) { + if (!index || !account || !account[0]) return NULL; + GHashTable *table = g_hash_table_lookup(index->balances, account); + if (!table) { + table = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + g_hash_table_insert(index->balances, g_strdup(account), table); + } + return table; +} + +/** + * @brief Add one amount to an account commodity balance. + * @param index Ledger index. + * @param account Posting account. + * @param commodity Commodity symbol. + * @param amount Posting amount. + */ +static void add_balance(BeanIndex *index, + const char *account, + const char *commodity, + double amount) { + if (!index || !account || !commodity || !commodity[0]) return; + GHashTable *table = balance_table_for_account(index, account); + if (!table) return; + double *slot = g_hash_table_lookup(table, commodity); + if (!slot) { + slot = g_new0(double, 1); + g_hash_table_insert(table, g_strdup(commodity), slot); + } + *slot += amount; +} + +/** + * @brief Return whether a child account belongs under a parent account. + * @param child Candidate child account. + * @param parent Parent account under hover. + * @return TRUE when child is parent or below parent. + */ +static gboolean account_is_under(const char *child, const char *parent) { + if (!child || !parent || !parent[0]) return FALSE; + gsize len = strlen(parent); + return g_strcmp0(child, parent) == 0 || + (g_str_has_prefix(child, parent) && child[len] == ':'); +} + +/** + * @brief Scan a posting amount into account balances. + * @details This intentionally handles simple Beancount postings locally. It + * does not evaluate costs or conversions; amounts are summed by + * commodity exactly as written. + * @param index Ledger index receiving balances. + * @param line Ledger line without comments. + */ +static void scan_posting_balance(BeanIndex *index, const char *line) { + const char *p = line; + while (*p == ' ' || *p == '\t') p++; + if (!starts_account_root(p)) return; + + const char *account_start = p; + while (*p && (g_ascii_isalnum(*p) || *p == ':' || *p == '_' || *p == '-')) p++; + char *account = g_strndup(account_start, (gsize)(p - account_start)); + while (*p == ' ' || *p == '\t') p++; + if (!*p) { + g_free(account); + return; + } + + char *end = NULL; + double amount = g_ascii_strtod(p, &end); + if (end == p) { + g_free(account); + return; + } + p = end; + while (*p == ' ' || *p == '\t') p++; + const char *commodity_start = p; + while (*p && (g_ascii_isupper(*p) || g_ascii_isdigit(*p) || *p == '.' || + *p == '_' || *p == '-')) { + p++; + } + if (p > commodity_start) { + char *commodity = g_strndup(commodity_start, + (gsize)(p - commodity_start)); + add_balance(index, account, commodity, amount); + g_free(commodity); + } + g_free(account); +} + +/** + * @brief Extract and store account-like tokens from one line. + * @param index Completion index receiving tokens. + * @param line Ledger line. + */ +static void scan_accounts(BeanIndex *index, const char *line) { + const char *p = line; + while (p && *p) { + while (*p && !g_ascii_isalpha(*p)) p++; + if (!starts_account_root(p)) { + while (*p && (g_ascii_isalnum(*p) || *p == '_' || *p == '-')) p++; + continue; + } + const char *start = p; + while (*p && (g_ascii_isalnum(*p) || *p == ':' || *p == '_' || *p == '-')) p++; + char *account = g_strndup(start, (gsize)(p - start)); + set_add(index->accounts, account); + g_free(account); + } +} + +/** + * @brief Extract and store commodity-like uppercase symbols from one line. + * @param index Completion index receiving tokens. + * @param line Ledger line. + */ +static void scan_commodities(BeanIndex *index, const char *line) { + const char *p = line; + while (p && *p) { + while (*p && !g_ascii_isupper(*p)) p++; + const char *start = p; + while (*p && (g_ascii_isupper(*p) || g_ascii_isdigit(*p) || *p == '.')) p++; + if ((p - start) >= 2) { + char *commodity = g_strndup(start, (gsize)(p - start)); + set_add(index->commodities, commodity); + g_free(commodity); + } + } +} + +/** + * @brief Extract and store Beancount tags or links from one line. + * @param set Hash table used as a set. + * @param line Ledger line. + * @param marker Token marker, usually # or ^. + */ +static void scan_marked_tokens(GHashTable *set, const char *line, char marker) { + const char *p = line; + while (p && *p) { + if (*p++ != marker) continue; + const char *start = p - 1; + while (*p && (g_ascii_isalnum(*p) || *p == '_' || *p == '-')) p++; + if (p - start > 1) { + char *token = g_strndup(start, (gsize)(p - start)); + set_add(set, token); + g_free(token); + } + } +} + +/** + * @brief Extract metadata key and quoted strings from one line. + * @param index Completion index receiving values. + * @param line Ledger line. + */ +static void scan_metadata_and_strings(BeanIndex *index, const char *line) { + const char *trim = line; + while (*trim == ' ' || *trim == '\t') trim++; + const char *colon = strchr(trim, ':'); + if (colon && colon > trim && !strchr(trim, ' ') && !strchr(trim, '\t')) { + char *key = g_strndup(trim, (gsize)(colon - trim + 1)); + set_add(index->metadata, key); + g_free(key); + } + + const char *first = strchr(line, '"'); + if (!first) return; + const char *first_end = strchr(first + 1, '"'); + if (!first_end) return; + char *payee = g_strndup(first + 1, (gsize)(first_end - first - 1)); + set_add(index->payees, payee); + g_free(payee); + + const char *second = strchr(first_end + 1, '"'); + if (!second) return; + const char *second_end = strchr(second + 1, '"'); + if (!second_end) return; + char *narration = g_strndup(second + 1, (gsize)(second_end - second - 1)); + set_add(index->narrations, narration); + g_free(narration); +} + +/** + * @brief Parse one include directive. + * @param line Ledger line. + * @return Owned include path, or NULL. + */ +static char *include_path_from_line(const char *line) { + const char *trim = line; + while (*trim == ' ' || *trim == '\t') trim++; + if (!g_str_has_prefix(trim, "include")) return NULL; + const char *quote = strchr(trim, '"'); + if (!quote) return NULL; + const char *end = strchr(quote + 1, '"'); + if (!end || end == quote + 1) return NULL; + return g_strndup(quote + 1, (gsize)(end - quote - 1)); +} + +static void index_file(BeanIndex *index, const char *path); + +/** + * @brief Parse ledger text into the completion index. + * @param index Completion index. + * @param text Ledger text. + * @param base_dir Directory used to resolve include directives. + */ +static void index_text(BeanIndex *index, const char *text, const char *base_dir) { + char **lines = g_strsplit(text ? text : "", "\n", -1); + for (guint i = 0u; lines && lines[i]; i++) { + char *line = lines[i]; + char *comment = strchr(line, ';'); + if (comment) *comment = '\0'; + scan_accounts(index, line); + scan_posting_balance(index, line); + scan_commodities(index, line); + scan_marked_tokens(index->tags, line, '#'); + scan_marked_tokens(index->links, line, '^'); + scan_metadata_and_strings(index, line); + + char *include_path = include_path_from_line(line); + if (include_path && base_dir) { + char *resolved = g_path_is_absolute(include_path) + ? g_strdup(include_path) + : g_build_filename(base_dir, include_path, NULL); + index_file(index, resolved); + g_free(resolved); + } + g_free(include_path); + } + g_strfreev(lines); +} + +/** + * @brief Parse one included ledger file within fixed safety limits. + * @param index Completion index. + * @param path Include path to parse. + */ +static void index_file(BeanIndex *index, const char *path) { + if (!index || !path || index->files >= BEANCOUNT_MAX_INCLUDE_FILES || + index->bytes >= BEANCOUNT_MAX_INCLUDE_BYTES) { + return; + } + char *canonical = g_canonicalize_filename(path, NULL); + if (g_hash_table_contains(index->visited, canonical)) { + g_free(canonical); + return; + } + g_hash_table_add(index->visited, g_strdup(canonical)); + + char *contents = NULL; + gsize len = 0u; + if (g_file_get_contents(canonical, &contents, &len, NULL)) { + index->files++; + index->bytes += len; + if (index->bytes <= BEANCOUNT_MAX_INCLUDE_BYTES) { + char *base_dir = g_path_get_dirname(canonical); + index_text(index, contents, base_dir); + g_free(base_dir); + } + } + g_free(contents); + g_free(canonical); +} + +/** + * @brief Create a new completion index. + * @return Newly allocated index. + */ +static BeanIndex *bean_index_new(void) { + BeanIndex *index = g_new0(BeanIndex, 1); + index->accounts = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->commodities = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->payees = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->narrations = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->links = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->metadata = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + index->balances = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, balance_table_free); + index->visited = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + return index; +} + +/** + * @brief Free a completion index. + * @param index Completion index. + */ +static void bean_index_free(BeanIndex *index) { + if (!index) return; + g_hash_table_destroy(index->accounts); + g_hash_table_destroy(index->commodities); + g_hash_table_destroy(index->payees); + g_hash_table_destroy(index->narrations); + g_hash_table_destroy(index->tags); + g_hash_table_destroy(index->links); + g_hash_table_destroy(index->metadata); + g_hash_table_destroy(index->balances); + g_hash_table_destroy(index->visited); + g_free(index); +} + +/** + * @brief Return a saved Beancount path for file-backed commands. + * @param context Command context supplied by Graptoς. + * @param command_name Name used in user-facing errors. + * @return Owned active path, or NULL when the command cannot run. + */ +static char *saved_beancount_path(GraptosPluginCommandContext *context, + const char *command_name) { + char *path = graptos_plugin_context_file_path(context); + if (!is_beancount_context(context) || !path || !path[0]) { + graptos_plugin_context_show_output(context, + command_name, + "No Beancount File", + "Open a saved .bean or .beancount file first."); + g_free(path); + return NULL; + } + if (graptos_plugin_context_is_modified(context)) { + graptos_plugin_context_show_output(context, + command_name, + "Save Required", + "Save the ledger before running this file-backed Beancount command."); + g_free(path); + return NULL; + } + return path; +} + +/** + * @brief Run an external Beancount command. + * @param cwd Working directory. + * @param argv Null-terminated command vector. + * @param stdout_text Captured stdout. + * @param stderr_text Captured stderr. + * @param status Process status. + * @return TRUE when the process launched. + */ +static gboolean run_tool(const char *cwd, + char **argv, + char **stdout_text, + char **stderr_text, + gint *status) { + GError *error = NULL; + gboolean ok = g_spawn_sync(cwd, + argv, + NULL, + G_SPAWN_SEARCH_PATH, + NULL, + NULL, + stdout_text, + stderr_text, + status, + &error); + if (!ok && error) { + *stderr_text = g_strdup(error->message); + g_clear_error(&error); + } + return ok; +} + +/** + * @brief Join process output into a bounded dialog body. + * @param command Command text to show. + * @param status Process wait status. + * @param stdout_text Captured stdout. + * @param stderr_text Captured stderr. + * @return Newly allocated dialog body. + */ +static char *format_tool_output(const char *command, + gint status, + const char *stdout_text, + const char *stderr_text) { + GString *out = g_string_new(NULL); + g_string_append_printf(out, "Command: %s\nExit status: %d\n\nstdout:\n%s\n\nstderr:\n%s", + command, + status, + stdout_text && stdout_text[0] ? stdout_text : "(empty)", + stderr_text && stderr_text[0] ? stderr_text : "(empty)"); + if (out->len > BEANCOUNT_MAX_OUTPUT_BYTES) { + g_string_truncate(out, BEANCOUNT_MAX_OUTPUT_BYTES); + g_string_append(out, "\n\n[output truncated]"); + } + return g_string_free(out, FALSE); +} diff --git a/data/plugins/beancount-tools/beancount_tools_completion.c b/data/plugins/beancount-tools/beancount_tools_completion.c new file mode 100644 index 0000000..b0ecb4a --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_completion.c @@ -0,0 +1,109 @@ +static char *token_before_cursor(const char *line_prefix) { + const char *start = line_prefix ? line_prefix : ""; + const char *p = start; + while (*p) { + if (g_ascii_isspace(*p) || *p == '"' || *p == '\'') start = p + 1; + p++; + } + return g_strdup(start); +} + +/** + * @brief Add matching values from a set into completion candidates. + * @param items Candidate array. + * @param set Hash table used as a set. + * @param prefix Current token prefix. + */ +static void add_matches(GPtrArray *items, GHashTable *set, const char *prefix) { + if (!items || !set) return; + GHashTableIter iter; + gpointer key = NULL; + g_hash_table_iter_init(&iter, set); + while (items->len < BEANCOUNT_MAX_COMPLETIONS && g_hash_table_iter_next(&iter, &key, NULL)) { + const char *value = key; + if (!prefix || !prefix[0] || g_str_has_prefix(value, prefix)) { + g_ptr_array_add(items, g_strdup(value)); + } + } +} + +/** + * @brief Build completion candidates for Beancount context. + * @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 *beancount_completion_candidates(GraptosPluginCommandContext *context, + char **replace_prefix_out, + gpointer user_data) { + (void)user_data; + if (replace_prefix_out) *replace_prefix_out = NULL; + if (!is_beancount_context(context)) return NULL; + + char *prefix_line = graptos_plugin_context_line_prefix(context); + char *token = token_before_cursor(prefix_line); + char *text = graptos_plugin_context_text(context); + char *path = graptos_plugin_context_file_path(context); + char *base_dir = path ? g_path_get_dirname(path) : NULL; + + BeanIndex *index = bean_index_new(); + for (guint i = 0u; i < G_N_ELEMENTS(DIRECTIVES); i++) set_add(index->metadata, DIRECTIVES[i]); + index_text(index, text, base_dir); + + GPtrArray *items = g_ptr_array_new_with_free_func(g_free); + if (g_str_has_prefix(token, "#")) { + add_matches(items, index->tags, token); + } else if (g_str_has_prefix(token, "^")) { + add_matches(items, index->links, token); + } else if (starts_account_root(token) || strchr(token, ':')) { + add_matches(items, index->accounts, token); + } else { + add_matches(items, index->accounts, token); + add_matches(items, index->commodities, token); + add_matches(items, index->payees, token); + add_matches(items, index->narrations, token); + add_matches(items, index->metadata, token); + } + + if (items->len == 0u) { + g_ptr_array_free(items, TRUE); + items = NULL; + } else if (replace_prefix_out) { + *replace_prefix_out = g_strdup(token); + } + + bean_index_free(index); + g_free(base_dir); + g_free(path); + g_free(text); + g_free(token); + g_free(prefix_line); + return items; +} + +/** + * @brief Show Beancount completions for the shortcut command. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void show_beancount_completion(GraptosPluginCommandContext *context, + gpointer user_data) { + char *replace_prefix = NULL; + GPtrArray *items = beancount_completion_candidates(context, &replace_prefix, user_data); + if (!items || items->len == 0u || !replace_prefix) { + graptos_plugin_context_set_status(context, "No Beancount completions"); + g_free(replace_prefix); + if (items) g_ptr_array_free(items, TRUE); + return; + } + graptos_plugin_context_show_completions(context, replace_prefix, "Beancount", items); + g_free(replace_prefix); + g_ptr_array_free(items, TRUE); +} + +/** + * @brief Return whether a token is a Beancount account. + * @param word Hover token. + * @return TRUE when the token starts with an account root. + */ diff --git a/data/plugins/beancount-tools/beancount_tools_config.h b/data/plugins/beancount-tools/beancount_tools_config.h new file mode 100644 index 0000000..4ce701b --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_config.h @@ -0,0 +1,35 @@ +/** + * @file beancount_tools_config.h + * @brief Compile-time feature selection for the Beancount Tools plugin. + * + * Build examples: + * + * cc -fPIC -shared -I../../../src beancount_tools.c \ + * $(pkg-config --cflags --libs glib-2.0) \ + * -o lib/libgraptos_beancount_tools.so + * + * cc -fPIC -shared -I../../../src \ + * -DBEANCOUNT_TOOLS_ENABLE_REPORT=0 beancount_tools.c \ + * $(pkg-config --cflags --libs glib-2.0) \ + * -o lib/libgraptos_beancount_tools.so + */ +#ifndef BEANCOUNT_TOOLS_CONFIG_H +#define BEANCOUNT_TOOLS_CONFIG_H + +#ifndef BEANCOUNT_TOOLS_ENABLE_COMPLETION +#define BEANCOUNT_TOOLS_ENABLE_COMPLETION 1 +#endif + +#ifndef BEANCOUNT_TOOLS_ENABLE_HOVER +#define BEANCOUNT_TOOLS_ENABLE_HOVER 1 +#endif + +#ifndef BEANCOUNT_TOOLS_ENABLE_COMMANDS +#define BEANCOUNT_TOOLS_ENABLE_COMMANDS 1 +#endif + +#ifndef BEANCOUNT_TOOLS_ENABLE_REPORT +#define BEANCOUNT_TOOLS_ENABLE_REPORT 1 +#endif + +#endif diff --git a/data/plugins/beancount-tools/beancount_tools_hover.c b/data/plugins/beancount-tools/beancount_tools_hover.c new file mode 100644 index 0000000..87a44d2 --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_hover.c @@ -0,0 +1,108 @@ +static gboolean is_account_word(const char *word) { + return word && starts_account_root(word); +} + +/** + * @brief Build an include-aware index for the active ledger. + * @param context Command context supplied by Graptoς. + * @return Newly allocated ledger index. + */ +static BeanIndex *index_for_context(GraptosPluginCommandContext *context) { + char *text = graptos_plugin_context_text(context); + char *path = graptos_plugin_context_file_path(context); + char *base_dir = path ? g_path_get_dirname(path) : NULL; + BeanIndex *index = bean_index_new(); + index_text(index, text, base_dir); + g_free(base_dir); + g_free(path); + g_free(text); + return index; +} + +/** + * @brief Accumulate child account balances into a summary table. + * @param summary Commodity balance table receiving totals. + * @param table Commodity balance table from one account. + */ +static void merge_balance_table(GHashTable *summary, GHashTable *table) { + if (!summary || !table) return; + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + g_hash_table_iter_init(&iter, table); + while (g_hash_table_iter_next(&iter, &key, &value)) { + const char *commodity = key; + double amount = value ? *((double *)value) : 0.0; + double *slot = g_hash_table_lookup(summary, commodity); + if (!slot) { + slot = g_new0(double, 1); + g_hash_table_insert(summary, g_strdup(commodity), slot); + } + *slot += amount; + } +} + +/** + * @brief Add formatted balance lines to a string. + * @param out Output string. + * @param summary Commodity balance table. + */ +static void append_balance_lines(GString *out, GHashTable *summary) { + if (!out || !summary) return; + GList *commodities = g_hash_table_get_keys(summary); + commodities = g_list_sort(commodities, (GCompareFunc)g_strcmp0); + for (GList *l = commodities; l; l = l->next) { + const char *commodity = l->data; + double *amount = g_hash_table_lookup(summary, commodity); + if (!amount || *amount == 0.0) continue; + g_string_append_printf(out, "%.2f %s\n", *amount, commodity); + } + g_list_free(commodities); +} + +/** + * @brief Return a Beancount account balance hover. + * @details Balances are summed from postings in the active buffer and included + * ledgers. Amounts remain grouped by commodity and are not converted. + * @param context Command context supplied by Graptoς. + * @param word Account token under the pointer. + * @param user_data Plugin data supplied during registration. + * @return Owned hover body, or NULL. + */ +static char *account_balance_hover(GraptosPluginCommandContext *context, + const char *word, + gpointer user_data) { + (void)user_data; + if (!is_beancount_context(context) || !is_account_word(word)) return NULL; + + BeanIndex *index = index_for_context(context); + GHashTable *summary = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + g_free); + GHashTableIter iter; + gpointer key = NULL; + gpointer value = NULL; + g_hash_table_iter_init(&iter, index->balances); + while (g_hash_table_iter_next(&iter, &key, &value)) { + const char *account = key; + if (account_is_under(account, word)) { + merge_balance_table(summary, value); + } + } + + GString *out = g_string_new(NULL); + g_string_append_printf(out, "%s\n", word); + append_balance_lines(out, summary); + if (out->len == strlen(word) + 1u) { + g_string_append(out, "No local postings found."); + } else if (index->files > 0u) { + g_string_append_printf(out, "Includes: %u file%s", + index->files, + index->files == 1u ? "" : "s"); + } + + g_hash_table_destroy(summary); + bean_index_free(index); + return g_string_free(out, FALSE); +} diff --git a/data/plugins/beancount-tools/beancount_tools_report.c b/data/plugins/beancount-tools/beancount_tools_report.c new file mode 100644 index 0000000..d9d663b --- /dev/null +++ b/data/plugins/beancount-tools/beancount_tools_report.c @@ -0,0 +1,725 @@ +static void bean_report_row_free(gpointer data) { + BeanReportRow *row = data; + if (!row) return; + g_free(row->account); + g_free(row->balance); + g_free(row); +} + +/** + * @brief Free one balance report section. + * @param data Section to free. + */ +static void bean_report_section_free(gpointer data) { + BeanReportSection *section = data; + if (!section) return; + g_free(section->root); + if (section->rows) g_ptr_array_free(section->rows, TRUE); + if (section->totals) g_hash_table_destroy(section->totals); + g_free(section); +} + +/** + * @brief Free a parsed balance report. + * @param report Report to free. + */ +static void bean_report_free(BeanReport *report) { + if (!report) return; + g_free(report->ledger_path); + g_free(report->ledger_name); + g_free(report->generated_at); + g_free(report->raw_output); + if (report->sections) g_ptr_array_free(report->sections, TRUE); + g_free(report); +} + +/** + * @brief Return the root of an account. + * @param account Account name. + * @return Owned account root. + */ +static char *account_root(const char *account) { + const char *colon = account ? strchr(account, ':') : NULL; + if (!account || !account[0]) return g_strdup("Other"); + if (!colon || colon == account) return g_strdup("Other"); + return g_strndup(account, (gsize)(colon - account)); +} + +/** + * @brief Return whether text is an account balance row. + * @param text Candidate line. + * @return TRUE when the line starts with a Beancount account root. + */ +static gboolean balance_row_starts_account(const char *text) { + const char *p = text ? text : ""; + while (*p == ' ' || *p == '\t') p++; + return starts_account_root(p); +} + +/** + * @brief Parse one bean-query balance row. + * @param line Output line from bean-query. + * @param account_out Owned account output. + * @param balance_out Owned balance output. + * @return TRUE when a row was parsed. + */ +static gboolean parse_balance_row(const char *line, + char **account_out, + char **balance_out) { + if (account_out) *account_out = NULL; + if (balance_out) *balance_out = NULL; + if (!balance_row_starts_account(line)) return FALSE; + const char *p = line; + while (*p == ' ' || *p == '\t') p++; + const char *start = p; + while (*p && (g_ascii_isalnum(*p) || *p == ':' || *p == '_' || *p == '-')) p++; + if (p == start) return FALSE; + char *account = g_strndup(start, (gsize)(p - start)); + while (*p == ' ' || *p == '\t') p++; + if (!*p) { + g_free(account); + return FALSE; + } + char *balance = g_strdup(p); + g_strstrip(balance); + if (!balance[0]) { + g_free(account); + g_free(balance); + return FALSE; + } + if (account_out) *account_out = account; + else g_free(account); + if (balance_out) *balance_out = balance; + else g_free(balance); + return TRUE; +} + +/** + * @brief Parse a simple amount and commodity from balance text. + * @details Complex inventories are preserved in report rows but skipped for + * subtotal math when the first value is not a plain amount. + * @param balance Balance text from bean-query. + * @param amount_out Parsed amount. + * @param commodity_out Owned commodity output. + * @return TRUE when a simple amount and commodity were parsed. + */ +static gboolean parse_balance_amount(const char *balance, + double *amount_out, + char **commodity_out) { + if (amount_out) *amount_out = 0.0; + if (commodity_out) *commodity_out = NULL; + const char *p = balance ? balance : ""; + while (*p == ' ' || *p == '\t' || *p == '(') p++; + char *end = NULL; + double amount = g_ascii_strtod(p, &end); + if (end == p) return FALSE; + p = end; + while (*p == ' ' || *p == '\t') p++; + const char *start = p; + while (*p && (g_ascii_isupper(*p) || g_ascii_isdigit(*p) || *p == '.' || + *p == '_' || *p == '-')) { + p++; + } + if (p == start) return FALSE; + if (amount_out) *amount_out = amount; + if (commodity_out) *commodity_out = g_strndup(start, (gsize)(p - start)); + return TRUE; +} + +/** + * @brief Add one amount to a section subtotal. + * @param section Report section. + * @param commodity Commodity symbol. + * @param amount Amount to add. + */ +static void report_section_add_total(BeanReportSection *section, + const char *commodity, + double amount) { + if (!section || !section->totals || !commodity || !commodity[0]) return; + double *slot = g_hash_table_lookup(section->totals, commodity); + if (!slot) { + slot = g_new0(double, 1); + g_hash_table_insert(section->totals, g_strdup(commodity), slot); + } + *slot += amount; +} + +/** + * @brief Return a report section by root, creating it if needed. + * @param report Report receiving the section. + * @param root Account root. + * @return Section owned by the report. + */ +static BeanReportSection *report_section_for_root(BeanReport *report, + const char *root) { + if (!report || !report->sections) return NULL; + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (section && g_strcmp0(section->root, root) == 0) return section; + } + BeanReportSection *section = g_new0(BeanReportSection, 1); + section->root = g_strdup(root && root[0] ? root : "Other"); + section->rows = g_ptr_array_new_with_free_func(bean_report_row_free); + section->totals = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + g_ptr_array_add(report->sections, section); + return section; +} + +/** + * @brief Add one parsed row to a report. + * @param report Report receiving the row. + * @param account Account name. + * @param balance Balance text. + */ +static void report_add_row(BeanReport *report, + const char *account, + const char *balance) { + g_autofree char *root = account_root(account); + BeanReportSection *section = report_section_for_root(report, root); + if (!section) return; + BeanReportRow *row = g_new0(BeanReportRow, 1); + row->account = g_strdup(account); + row->balance = g_strdup(balance); + g_ptr_array_add(section->rows, row); + + double amount = 0.0; + g_autofree char *commodity = NULL; + if (parse_balance_amount(balance, &amount, &commodity)) { + report_section_add_total(section, commodity, amount); + } +} + +/** + * @brief Build a report model from bean-query output. + * @param path Ledger path used for the query. + * @param output Raw bean-query output. + * @return Parsed report. + */ +static BeanReport *bean_report_parse(const char *path, const char *output) { + BeanReport *report = g_new0(BeanReport, 1); + report->ledger_path = g_strdup(path); + report->ledger_name = g_path_get_basename(path ? path : "ledger.bean"); + report->raw_output = g_strdup(output ? output : ""); + report->sections = g_ptr_array_new_with_free_func(bean_report_section_free); + GDateTime *now = g_date_time_new_now_local(); + report->generated_at = now + ? g_date_time_format(now, "%Y-%m-%d %H:%M:%S") + : g_strdup("unknown time"); + if (now) g_date_time_unref(now); + + char **lines = g_strsplit(output ? output : "", "\n", -1); + for (guint i = 0u; lines && lines[i]; i++) { + g_autofree char *account = NULL; + g_autofree char *balance = NULL; + if (parse_balance_row(lines[i], &account, &balance)) { + report_add_row(report, account, balance); + } + } + g_strfreev(lines); + return report; +} + +/** + * @brief Append section subtotal lines to a string. + * @param out Output string. + * @param totals Commodity totals. + * @param prefix Prefix before each total line. + */ +static void append_report_totals(GString *out, + GHashTable *totals, + const char *prefix) { + if (!out || !totals || g_hash_table_size(totals) == 0u) return; + GList *commodities = g_hash_table_get_keys(totals); + commodities = g_list_sort(commodities, (GCompareFunc)g_strcmp0); + for (GList *l = commodities; l; l = l->next) { + const char *commodity = l->data; + double *amount = g_hash_table_lookup(totals, commodity); + if (!amount) continue; + g_string_append_printf(out, + "%s%.2f %s\n", + prefix ? prefix : "", + *amount, + commodity); + } + g_list_free(commodities); +} + +/** + * @brief Build plain-text preview body for a balance report. + * @param report Parsed report. + * @return Owned preview text. + */ +static char *bean_report_to_text(BeanReport *report) { + if (!report) return g_strdup("No report."); + GString *out = g_string_new(NULL); + g_string_append_printf(out, + "Ledger: %s\nGenerated: %s\nSource: bean-query %s\n\n", + report->ledger_name ? report->ledger_name : "ledger", + report->generated_at ? report->generated_at : "unknown", + BEANCOUNT_BALANCE_QUERY); + if (!report->sections || report->sections->len == 0u) { + g_string_append(out, report->raw_output && report->raw_output[0] + ? report->raw_output + : "No balances returned."); + return g_string_free(out, FALSE); + } + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (!section) continue; + g_string_append_printf(out, "%s\n", section->root ? section->root : "Other"); + g_string_append(out, "----------------------------------------\n"); + for (guint j = 0u; section->rows && j < section->rows->len; j++) { + BeanReportRow *row = g_ptr_array_index(section->rows, j); + if (!row) continue; + g_string_append_printf(out, "%-44s %s\n", + row->account ? row->account : "", + row->balance ? row->balance : ""); + } + if (g_hash_table_size(section->totals) > 0u) { + g_string_append(out, "Subtotal:\n"); + append_report_totals(out, section->totals, " "); + } + g_string_append_c(out, '\n'); + } + return g_string_free(out, FALSE); +} + +/** + * @brief Escape text for LaTeX output. + * @param text Text to escape. + * @return Owned escaped text. + */ +static char *latex_escape(const char *text) { + GString *out = g_string_new(NULL); + for (const char *p = text ? text : ""; *p; p++) { + switch (*p) { + case '\\': g_string_append(out, "\\textbackslash{}"); break; + case '&': g_string_append(out, "\\&"); break; + case '%': g_string_append(out, "\\%"); break; + case '$': g_string_append(out, "\\$"); break; + case '#': g_string_append(out, "\\#"); break; + case '_': g_string_append(out, "\\_"); break; + case '{': g_string_append(out, "\\{"); break; + case '}': g_string_append(out, "\\}"); break; + case '~': g_string_append(out, "\\textasciitilde{}"); break; + case '^': g_string_append(out, "\\textasciicircum{}"); break; + default: g_string_append_c(out, *p); break; + } + } + return g_string_free(out, FALSE); +} + +/** + * @brief Append one escaped LaTeX table row. + * @param out Output LaTeX string. + * @param left Left column. + * @param right Right column. + */ +static void latex_append_row(GString *out, const char *left, const char *right) { + g_autofree char *a = latex_escape(left); + g_autofree char *b = latex_escape(right); + g_string_append_printf(out, + "\\texttt{%s}\\\\\n" + "\\hspace*{1em}%s\\\\[0.35em]\n", + a ? a : "", + b ? b : ""); +} + +/** + * @brief Append one escaped two-column LaTeX row. + * @param out Output LaTeX string. + * @param left Left column. + * @param right Right column. + */ +static void latex_append_table_row(GString *out, + const char *left, + const char *right) { + g_autofree char *a = latex_escape(left); + g_autofree char *b = latex_escape(right); + g_string_append_printf(out, "%s & %s \\\\\n", a ? a : "", b ? b : ""); +} + +/** + * @brief Append totals to a LaTeX table. + * @param out Output LaTeX string. + * @param totals Commodity totals. + */ +static void latex_append_totals(GString *out, GHashTable *totals) { + if (!out || !totals || g_hash_table_size(totals) == 0u) return; + GList *commodities = g_hash_table_get_keys(totals); + commodities = g_list_sort(commodities, (GCompareFunc)g_strcmp0); + for (GList *l = commodities; l; l = l->next) { + const char *commodity = l->data; + double *amount = g_hash_table_lookup(totals, commodity); + if (!amount) continue; + g_autofree char *value = g_strdup_printf("%.2f %s", *amount, commodity); + latex_append_row(out, "Subtotal", value); + } + g_list_free(commodities); +} + +/** + * @brief Count parsed report rows. + * @param report Parsed report. + * @return Number of account rows. + */ +static guint bean_report_row_count(BeanReport *report) { + guint rows = 0u; + for (guint i = 0u; report && report->sections && i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + rows += section && section->rows ? section->rows->len : 0u; + } + return rows; +} + +/** + * @brief Append all account rows as escaped two-column LaTeX rows. + * @param out Output LaTeX string. + * @param report Parsed report. + */ +static void latex_append_all_rows(GString *out, BeanReport *report) { + if (!out || !report || !report->sections) return; + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (!section || !section->rows) continue; + for (guint j = 0u; j < section->rows->len; j++) { + BeanReportRow *row = g_ptr_array_index(section->rows, j); + if (!row) continue; + latex_append_table_row(out, row->account, row->balance); + } + } +} + +/** + * @brief Append all section subtotals as escaped three-column LaTeX rows. + * @param out Output LaTeX string. + * @param report Parsed report. + */ +static void latex_append_all_totals(GString *out, BeanReport *report) { + if (!out || !report || !report->sections) return; + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (!section || !section->totals || g_hash_table_size(section->totals) == 0u) { + continue; + } + GList *commodities = g_hash_table_get_keys(section->totals); + commodities = g_list_sort(commodities, (GCompareFunc)g_strcmp0); + for (GList *l = commodities; l; l = l->next) { + const char *commodity = l->data; + double *amount = g_hash_table_lookup(section->totals, commodity); + if (!amount) continue; + g_autofree char *root = latex_escape(section->root ? section->root : "Other"); + g_autofree char *escaped_commodity = latex_escape(commodity); + g_string_append_printf(out, + "%s & %.2f & %s \\\\\n", + root ? root : "", + *amount, + escaped_commodity ? escaped_commodity : ""); + } + g_list_free(commodities); + } +} + +/** + * @brief Append generated report sections as LaTeX. + * @param out Output LaTeX string. + * @param report Parsed report. + */ +static void latex_append_sections(GString *out, BeanReport *report) { + if (!out) return; + if (!report || !report->sections || report->sections->len == 0u) { + g_string_append(out, "\\begin{verbatim}\n"); + g_string_append(out, report && report->raw_output + ? report->raw_output + : "No balances returned."); + g_string_append(out, "\n\\end{verbatim}\n"); + return; + } + + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (!section) continue; + g_autofree char *root = latex_escape(section->root ? section->root : "Other"); + g_string_append_printf(out, + "\\subsection*{%s}\n" + "\\begin{longtable}{p{0.96\\linewidth}}\n", + root); + for (guint j = 0u; section->rows && j < section->rows->len; j++) { + BeanReportRow *row = g_ptr_array_index(section->rows, j); + if (!row) continue; + latex_append_row(out, row->account, row->balance); + } + if (g_hash_table_size(section->totals) > 0u) { + g_string_append(out, "\\hline\n"); + latex_append_totals(out, section->totals); + } + g_string_append(out, "\\end{longtable}\n"); + } +} + +/** + * @brief Append generated report sections as two-column LaTeX tables. + * @param out Output LaTeX string. + * @param report Parsed report. + */ +static void latex_append_table_sections(GString *out, BeanReport *report) { + if (!out) return; + if (!report || !report->sections || report->sections->len == 0u) { + g_string_append(out, "\\begin{verbatim}\n"); + g_string_append(out, report && report->raw_output + ? report->raw_output + : "No balances returned."); + g_string_append(out, "\n\\end{verbatim}\n"); + return; + } + + for (guint i = 0u; i < report->sections->len; i++) { + BeanReportSection *section = g_ptr_array_index(report->sections, i); + if (!section) continue; + g_autofree char *root = latex_escape(section->root ? section->root : "Other"); + g_string_append_printf(out, + "\\subsection*{%s}\n" + "\\begin{longtable}{p{0.44\\linewidth}@{\\hspace{1em}}p{0.46\\linewidth}}\n" + "\\textbf{Account} & \\textbf{Balance}\\\\\\hline\n", + root); + for (guint j = 0u; section->rows && j < section->rows->len; j++) { + BeanReportRow *row = g_ptr_array_index(section->rows, j); + if (!row) continue; + latex_append_table_row(out, row->account, row->balance); + } + if (g_hash_table_size(section->totals) > 0u) { + g_string_append(out, "\\hline\n"); + GList *commodities = g_hash_table_get_keys(section->totals); + commodities = g_list_sort(commodities, (GCompareFunc)g_strcmp0); + for (GList *l = commodities; l; l = l->next) { + const char *commodity = l->data; + double *amount = g_hash_table_lookup(section->totals, commodity); + if (!amount) continue; + g_autofree char *value = g_strdup_printf("%.2f %s", *amount, commodity); + latex_append_table_row(out, "Subtotal", value); + } + g_list_free(commodities); + } + g_string_append(out, "\\end{longtable}\n"); + } +} + +/** + * @brief Replace all occurrences of a placeholder in text. + * @param text Source text. + * @param placeholder Placeholder token. + * @param replacement Replacement text. + * @return Owned rendered text. + */ +static char *latex_template_replace(const char *text, + const char *placeholder, + const char *replacement) { + GString *out = g_string_new(NULL); + const char *cursor = text ? text : ""; + gsize placeholder_len = strlen(placeholder); + while (TRUE) { + const char *match = g_strstr_len(cursor, -1, placeholder); + if (!match) { + g_string_append(out, cursor); + break; + } + g_string_append_len(out, cursor, (gssize)(match - cursor)); + g_string_append(out, replacement ? replacement : ""); + cursor = match + placeholder_len; + } + return g_string_free(out, FALSE); +} + +/** + * @brief Render report placeholders into a LaTeX template. + * @param template_source Full LaTeX template. + * @param report Parsed report. + * @param sections Generated LaTeX sections. + * @return Owned rendered LaTeX source. + */ +static char *latex_template_render(const char *template_source, + BeanReport *report, + const char *sections) { + g_autofree char *ledger = latex_escape(report && report->ledger_name + ? report->ledger_name + : "ledger"); + g_autofree char *generated = latex_escape(report && report->generated_at + ? report->generated_at + : "unknown"); + g_autofree char *query = latex_escape(BEANCOUNT_BALANCE_QUERY); + g_autofree char *raw = latex_escape(report && report->raw_output + ? report->raw_output + : ""); + g_autofree char *ledger_path = latex_escape(report && report->ledger_path + ? report->ledger_path + : ""); + g_autofree char *section_count = g_strdup_printf("%u", + report && report->sections + ? report->sections->len + : 0u); + g_autofree char *row_count = g_strdup_printf("%u", bean_report_row_count(report)); + g_autoptr(GString) table_sections = g_string_new(NULL); + g_autoptr(GString) rows = g_string_new(NULL); + g_autoptr(GString) totals = g_string_new(NULL); + latex_append_table_sections(table_sections, report); + latex_append_all_rows(rows, report); + latex_append_all_totals(totals, report); + g_autofree char *rendered = latex_template_replace(template_source, + "{{ledger_name}}", + ledger); + g_autofree char *with_generated = latex_template_replace(rendered, + "{{generated_at}}", + generated); + g_autofree char *with_query = latex_template_replace(with_generated, + "{{query}}", + query); + g_autofree char *with_sections = latex_template_replace(with_query, + "{{sections}}", + sections); + g_autofree char *with_stacked = latex_template_replace(with_sections, + "{{sections_stacked}}", + sections); + g_autofree char *with_table_sections = latex_template_replace(with_stacked, + "{{sections_table}}", + table_sections->str); + g_autofree char *with_rows = latex_template_replace(with_table_sections, + "{{rows}}", + rows->str); + g_autofree char *with_totals = latex_template_replace(with_rows, + "{{totals}}", + totals->str); + g_autofree char *with_raw = latex_template_replace(with_totals, + "{{raw_output}}", + raw); + g_autofree char *with_path = latex_template_replace(with_raw, + "{{ledger_path}}", + ledger_path); + g_autofree char *with_section_count = latex_template_replace(with_path, + "{{section_count}}", + section_count); + return latex_template_replace(with_section_count, + "{{row_count}}", + row_count); +} + +/** + * @brief Return ledger-local LaTeX template path. + * @param report Parsed report. + * @return Owned path, or NULL. + */ +static char *latex_template_path(BeanReport *report) { + if (!report || !report->ledger_path || !report->ledger_path[0]) return NULL; + g_autofree char *dir = g_path_get_dirname(report->ledger_path); + return g_build_filename(dir, ".graptos-beancount-template.tex", NULL); +} + +/** + * @brief Load the ledger-local LaTeX template if it exists. + * @param report Parsed report. + * @return Owned template source, or NULL. + */ +static char *latex_template_load(BeanReport *report) { + g_autofree char *path = latex_template_path(report); + if (!path || !g_file_test(path, G_FILE_TEST_IS_REGULAR)) return NULL; + char *contents = NULL; + if (!g_file_get_contents(path, &contents, NULL, NULL)) return NULL; + return contents; +} + +/** + * @brief Build LaTeX source for a balance report. + * @param report Parsed report. + * @return Owned LaTeX source. + */ +static char *bean_report_to_latex(BeanReport *report) { + static const char *default_template = + "\\documentclass[11pt]{article}\n" + "\\usepackage[margin=0.8in]{geometry}\n" + "\\usepackage{longtable}\n" + "\\usepackage{array}\n" + "\\begin{document}\n" + "\\section*{Beancount Balance Sheet}\n" + "\\textbf{Ledger:} {{ledger_name}}\\\\\n" + "\\textbf{Generated:} {{generated_at}}\\\\\n" + "\\textbf{Source:} bean-query {{query}}\n\n" + "{{sections}}\n" + "\\end{document}\n"; + g_autofree char *custom_template = latex_template_load(report); + const char *template_source = custom_template ? custom_template : default_template; + g_autoptr(GString) sections = g_string_new(NULL); + latex_append_sections(sections, report); + return latex_template_render(template_source, report, sections->str); +} + +/** + * @brief Run bean-query and parse a balance report. + * @param context Command context supplied by Graptoς. + * @return Parsed report, or NULL on failure. + */ +static BeanReport *build_balance_report(GraptosPluginCommandContext *context) { + char *path = saved_beancount_path(context, "Balance Sheet"); + if (!path) return NULL; + if (!g_find_program_in_path("bean-query")) { + graptos_plugin_context_show_output(context, + "Balance Sheet", + "bean-query Missing", + "Install Beancount to use bean-query."); + g_free(path); + return NULL; + } + g_autofree char *cwd = g_path_get_dirname(path); + char *argv[] = { "bean-query", path, BEANCOUNT_BALANCE_QUERY, NULL }; + g_autofree char *stdout_text = NULL; + g_autofree char *stderr_text = NULL; + gint status = 0; + (void)run_tool(cwd, argv, &stdout_text, &stderr_text, &status); + if (status != 0) { + g_autofree char *body = format_tool_output("bean-query " BEANCOUNT_BALANCE_QUERY, + status, + stdout_text, + stderr_text); + graptos_plugin_context_show_output(context, + "Balance Sheet", + "Bean Query Failed", + body); + g_free(path); + return NULL; + } + BeanReport *report = bean_report_parse(path, stdout_text ? stdout_text : ""); + g_free(path); + return report; +} + +/** + * @brief Show the Beancount balance sheet in the preview pane. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void balance_sheet_preview(GraptosPluginCommandContext *context, + gpointer user_data) { + (void)user_data; + BeanReport *report = build_balance_report(context); + if (!report) return; + g_autofree char *body = bean_report_to_text(report); + graptos_plugin_context_show_preview(context, + "Beancount Balance Sheet", + body); + bean_report_free(report); +} + +/** + * @brief Export the Beancount balance sheet to PDF. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void export_balance_sheet_pdf(GraptosPluginCommandContext *context, + gpointer user_data) { + (void)user_data; + BeanReport *report = build_balance_report(context); + if (!report) return; + g_autofree char *latex = bean_report_to_latex(report); + g_autofree char *stem = g_strdup_printf("%s-balance-sheet", + report->ledger_name ? report->ledger_name : "ledger"); + (void)graptos_plugin_context_export_latex_pdf(context, stem, latex); + bean_report_free(report); +} diff --git a/data/plugins/beancount-tools/plugin.yaml b/data/plugins/beancount-tools/plugin.yaml new file mode 100644 index 0000000..3011b16 --- /dev/null +++ b/data/plugins/beancount-tools/plugin.yaml @@ -0,0 +1,15 @@ +id: beancount-tools +name: Beancount Tools +version: 1.0 +description: Validates, formats, queries, and completes Beancount ledgers. +graptos_api_version: 1 +enabled: true +native: lib/libgraptos_beancount_tools.so + +permissions: + - native + - editor.read + - editor.write + - project.read + - command + - ui diff --git a/data/plugins/full-demo/full_demo.c b/data/plugins/full-demo/full_demo.c new file mode 100644 index 0000000..50ea1f5 --- /dev/null +++ b/data/plugins/full-demo/full_demo.c @@ -0,0 +1,54 @@ +/** + * @file full_demo.c + * @brief Native command for the full demo plugin. + * @details The full demo manifest declares every current permission. This + * shared library keeps the native part real so startup does not emit a + * missing-library warning and plugin authors can see the full shape. + */ + +#include "plugin_api.h" + +gboolean graptos_plugin_register(GraptosPluginHost *host); + +/** + * @brief Show a compact native plugin status report. + * @param context Command context supplied by Graptoς. + * @param user_data Plugin data supplied during registration. + */ +static void full_demo_native_command(GraptosPluginCommandContext *context, + gpointer user_data) { + (void)user_data; + char *path = graptos_plugin_context_file_path(context); + const char *syntax = graptos_plugin_context_syntax_name(context); + char *body = g_strdup_printf("Plugin: %s\nCommand: %s\nLine: %u\nSyntax: %s\nModified: %s\nTabs: %u\nFile: %s", + graptos_plugin_context_plugin_id(context), + graptos_plugin_context_command_id(context), + graptos_plugin_context_line(context), + syntax ? syntax : "(none)", + graptos_plugin_context_is_modified(context) ? "yes" : "no", + graptos_plugin_context_tab_count(context), + path ? path : "(unsaved)"); + graptos_plugin_context_show_output(context, + "Full Demo Native", + "Native Plugin Info", + body); + g_free(body); + g_free(path); +} + +/** + * @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; + } + return graptos_plugin_host_register_editor_line_command(host, + "native-demo-command", + "Full Demo Native", + full_demo_native_command, + NULL, + NULL); +} diff --git a/data/plugins/full-demo/syntax/full-demo-log.yaml b/data/plugins/full-demo/syntax/full-demo-log.yaml index 82047ff..49e4f79 100644 --- a/data/plugins/full-demo/syntax/full-demo-log.yaml +++ b/data/plugins/full-demo/syntax/full-demo-log.yaml @@ -30,7 +30,7 @@ statement_exempt_suffixes: [] formatting: enabled: false scope: selection_or_block - profile: none + profile: plain block_style: none statement_style: newline brace_style: attach diff --git a/data/plugins/taskwarrior/plugin.yaml b/data/plugins/taskwarrior/plugin.yaml new file mode 100644 index 0000000..8663fb9 --- /dev/null +++ b/data/plugins/taskwarrior/plugin.yaml @@ -0,0 +1,14 @@ +id: taskwarrior +name: Taskwarrior +version: 1.0 +description: Project-scoped Taskwarrior hub for listing, adding, and updating tasks. +graptos_api_version: 1 +enabled: true +native: lib/libgraptos_taskwarrior.so + +permissions: + - native + - editor.read + - project.read + - command + - ui diff --git a/data/plugins/taskwarrior/taskwarrior.c b/data/plugins/taskwarrior/taskwarrior.c new file mode 100644 index 0000000..d746791 --- /dev/null +++ b/data/plugins/taskwarrior/taskwarrior.c @@ -0,0 +1,374 @@ +/** + * @file taskwarrior.c + * @brief Native Taskwarrior hub plugin for Graptoς. + * @details The plugin assumes users may or may not have taskwarrior installed. + * Missing tools and missing project config render as hub states rather + * than plugin load failures. + */ + +#include "plugin_api.h" + +#include +#include + +#define TASK_CONFIG_FILE ".graptos-task.ini" + +typedef struct { + char *root; /**< Project root used for config and cwd. */ + char *filter; /**< Taskwarrior filter from config. */ + char *project; /**< Taskwarrior project for new tasks. */ +} TaskProject; + +gboolean graptos_plugin_register(GraptosPluginHost *host); + +static void task_project_free(TaskProject *project) { + if (!project) return; + g_free(project->root); + g_free(project->filter); + g_free(project->project); + g_free(project); +} + +static char *context_root(GraptosPluginCommandContext *context) { + char *root = graptos_plugin_context_project_root(context); + if (root && root[0]) return root; + g_free(root); + char *path = graptos_plugin_context_file_path(context); + if (!path || !path[0]) { + g_free(path); + return NULL; + } + char *dir = g_path_get_dirname(path); + g_free(path); + return dir; +} + +static char *default_project_for_root(const char *root) { + if (!root || !root[0]) return g_strdup("default"); + char *base = g_path_get_basename(root); + if (!base || !base[0] || g_strcmp0(base, ".") == 0 || + g_strcmp0(base, G_DIR_SEPARATOR_S) == 0) { + g_free(base); + return g_strdup("default"); + } + return base; +} + +static TaskProject *task_project_load(GraptosPluginCommandContext *context, + char **message_out) { + if (message_out) *message_out = NULL; + char *root = context_root(context); + if (!root || !root[0]) { + if (message_out) { + *message_out = g_strdup("Open a project folder or save the active file first."); + } + g_free(root); + return NULL; + } + + TaskProject *project = g_new0(TaskProject, 1); + project->root = root; + project->project = default_project_for_root(root); + + g_autofree char *path = g_build_filename(root, TASK_CONFIG_FILE, NULL); + if (g_file_test(path, G_FILE_TEST_IS_REGULAR)) { + g_autoptr(GKeyFile) key_file = g_key_file_new(); + if (!g_key_file_load_from_file(key_file, path, G_KEY_FILE_NONE, NULL)) { + if (message_out) *message_out = g_strdup("Could not read " TASK_CONFIG_FILE "."); + task_project_free(project); + return NULL; + } + + g_autofree char *configured_project = + g_key_file_get_string(key_file, "Taskwarrior", "project", NULL); + if (configured_project && configured_project[0]) { + g_free(project->project); + project->project = g_steal_pointer(&configured_project); + } + project->filter = g_key_file_get_string(key_file, "Taskwarrior", "filter", NULL); + } + + if (!project->filter || !project->filter[0]) { + g_free(project->filter); + project->filter = g_strdup_printf("project:%s", project->project); + } + return project; +} + +static gboolean task_available(void) { + g_autofree char *path = g_find_program_in_path("task"); + return path != NULL; +} + +static GPtrArray *argv_new(void) { + return g_ptr_array_new_with_free_func(g_free); +} + +static void argv_add(GPtrArray *argv, const char *text) { + if (argv && text && text[0]) g_ptr_array_add(argv, g_strdup(text)); +} + +static void argv_add_shell_words(GPtrArray *argv, const char *text) { + if (!argv || !text || !text[0]) return; + gint argc = 0; + g_auto(GStrv) words = NULL; + if (!g_shell_parse_argv(text, &argc, &words, NULL)) { + argv_add(argv, text); + return; + } + for (gint i = 0; i < argc; i++) argv_add(argv, words[i]); +} + +static char **argv_finish(GPtrArray *argv) { + if (!argv) return NULL; + g_ptr_array_add(argv, NULL); + return (char **)g_ptr_array_free(argv, FALSE); +} + +static gboolean run_task(TaskProject *project, + GPtrArray *argv, + char **stdout_text, + char **stderr_text, + gint *status) { + g_autoptr(GError) error = NULL; + char **args = argv_finish(argv); + gboolean t_done = g_spawn_sync(project && project->root ? project->root : NULL, + args, + NULL, + G_SPAWN_SEARCH_PATH, + NULL, + NULL, + stdout_text, + stderr_text, + status, + &error); + g_strfreev(args); + if (!t_done && error) { + g_free(*stderr_text); + *stderr_text = g_strdup(error->message); + } + return t_done; +} + +static char *format_task_result(const char *title, + gint status, + const char *stdout_text, + const char *stderr_text) { + return g_strdup_printf("%s\nExit status: %d\n\n%s%s%s%s", + title ? title : "Taskwarrior", + status, + stdout_text && stdout_text[0] ? stdout_text : "", + stdout_text && stdout_text[0] ? "\n" : "", + stderr_text && stderr_text[0] ? stderr_text : "", + stderr_text && stderr_text[0] ? "\n" : ""); +} + +static char *sanitize_task_text(const char *text) { + if (!text) return g_strdup(""); + GString *out = g_string_new(NULL); + for (const unsigned char *p = (const unsigned char *)text; *p; p++) { + if (*p == 0x1b) { + while (*p && ((*p < 'A' || *p > 'Z') && (*p < 'a' || *p > 'z'))) { + p++; + } + if (!*p) break; + continue; + } + if (*p == '\n' || *p == '\t') { + g_string_append_c(out, (char)*p); + } else if (*p == '\r' || *p == '\b' || *p < 0x20) { + continue; + } else { + g_string_append_c(out, (char)*p); + } + } + return g_string_free(out, FALSE); +} + +static char *task_hub_render(GraptosPluginCommandContext *context, + gpointer user_data) { + (void)user_data; + if (!task_available()) { + return g_strdup("Taskwarrior is not installed.\n\nInstall the `task` command to use this hub."); + } + g_autofree char *message = NULL; + TaskProject *project = task_project_load(context, &message); + if (!project) return g_steal_pointer(&message); + + GPtrArray *argv = argv_new(); + argv_add(argv, "task"); + argv_add(argv, "rc.verbose=nothing"); + argv_add(argv, "rc.color=off"); + argv_add_shell_words(argv, project->filter); + argv_add(argv, "list"); + + g_autofree char *stdout_text = NULL; + g_autofree char *stderr_text = NULL; + gint status = 0; + (void)run_task(project, argv, &stdout_text, &stderr_text, &status); + g_autofree char *clean_stdout = sanitize_task_text(stdout_text); + g_autofree char *clean_stderr = sanitize_task_text(stderr_text); + GString *out = g_string_new(NULL); + g_string_append_printf(out, + "Project root: %s\nFilter: %s\nProject: %s\n\n", + project->root, + project->filter, + project->project && project->project[0] + ? project->project + : "(not configured)"); + if (status == 0) { + g_string_append(out, + clean_stdout && clean_stdout[0] + ? clean_stdout + : "No matching tasks."); + } else { + g_autofree char *body = format_task_result("Task list failed", + status, + clean_stdout, + clean_stderr); + g_string_append(out, body); + } + task_project_free(project); + return g_string_free(out, FALSE); +} + +static gboolean input_first_word(const char *input, + char **first_out, + char **rest_out) { + if (first_out) *first_out = NULL; + if (rest_out) *rest_out = NULL; + g_auto(GStrv) words = NULL; + gint argc = 0; + if (!g_shell_parse_argv(input ? input : "", &argc, &words, NULL) || argc == 0) { + return FALSE; + } + if (first_out) *first_out = g_strdup(words[0]); + if (rest_out) { + GString *rest = g_string_new(NULL); + for (gint i = 1; i < argc; i++) { + if (rest->len > 0u) g_string_append_c(rest, ' '); + g_string_append(rest, words[i]); + } + *rest_out = g_string_free(rest, FALSE); + } + return TRUE; +} + +static void show_action_result(GraptosPluginCommandContext *context, + const char *heading, + gint status, + const char *stdout_text, + const char *stderr_text) { + if (status == 0) { + graptos_plugin_context_set_status(context, "Taskwarrior updated."); + return; + } + g_autofree char *body = format_task_result(heading, status, stdout_text, stderr_text); + graptos_plugin_context_show_output(context, "Taskwarrior", heading, body); +} + +static void task_hub_action(GraptosPluginCommandContext *context, + const char *action_id, + const char *input, + gpointer user_data) { + (void)user_data; + if (g_strcmp0(action_id, "refresh") == 0) return; + if (!task_available()) { + graptos_plugin_context_set_status(context, "Taskwarrior is not installed."); + return; + } + g_autofree char *message = NULL; + TaskProject *project = task_project_load(context, &message); + if (!project) { + graptos_plugin_context_show_output(context, + "Taskwarrior", + "Project Task Config Missing", + message ? message : "Missing config."); + return; + } + + GPtrArray *argv = argv_new(); + argv_add(argv, "task"); + argv_add(argv, "rc.confirmation=no"); + argv_add(argv, "rc.color=off"); + const char *heading = "Taskwarrior"; + if (g_strcmp0(action_id, "add") == 0) { + if (!project->project || !project->project[0]) { + graptos_plugin_context_show_output(context, + "Taskwarrior", + "Project Missing", + TASK_CONFIG_FILE " needs [Taskwarrior] project=... before adding tasks."); + g_ptr_array_free(argv, TRUE); + task_project_free(project); + return; + } + argv_add(argv, "add"); + g_autofree char *project_arg = g_strdup_printf("project:%s", project->project); + argv_add(argv, project_arg); + argv_add(argv, input); + heading = "Task Add"; + } else if (g_strcmp0(action_id, "modify") == 0) { + g_autofree char *id = NULL; + g_autofree char *rest = NULL; + if (!input_first_word(input, &id, &rest) || !rest || !rest[0]) { + graptos_plugin_context_set_status(context, "Use: "); + g_ptr_array_free(argv, TRUE); + task_project_free(project); + return; + } + argv_add(argv, id); + argv_add(argv, "modify"); + argv_add_shell_words(argv, rest); + heading = "Task Modify"; + } else if (g_strcmp0(action_id, "priority") == 0) { + g_autofree char *id = NULL; + g_autofree char *priority = NULL; + if (!input_first_word(input, &id, &priority)) { + graptos_plugin_context_set_status(context, "Use: [H|M|L]"); + g_ptr_array_free(argv, TRUE); + task_project_free(project); + return; + } + argv_add(argv, id); + argv_add(argv, "modify"); + g_autofree char *priority_arg = priority && priority[0] + ? g_strdup_printf("priority:%s", priority) + : g_strdup("priority:"); + argv_add(argv, priority_arg); + heading = "Task Priority"; + } else if (g_strcmp0(action_id, "done") == 0) { + argv_add(argv, input); + argv_add(argv, "done"); + heading = "Task Done"; + } else if (g_strcmp0(action_id, "delete") == 0) { + argv_add(argv, input); + argv_add(argv, "delete"); + heading = "Task Delete"; + } else { + g_ptr_array_free(argv, TRUE); + task_project_free(project); + return; + } + + g_autofree char *stdout_text = NULL; + g_autofree char *stderr_text = NULL; + gint status = 0; + (void)run_task(project, argv, &stdout_text, &stderr_text, &status); + g_autofree char *clean_stdout = sanitize_task_text(stdout_text); + g_autofree char *clean_stderr = sanitize_task_text(stderr_text); + show_action_result(context, heading, status, clean_stdout, clean_stderr); + task_project_free(project); +} + +gboolean graptos_plugin_register(GraptosPluginHost *host) { + if (graptos_plugin_host_api_version(host) != GRAPTOS_PLUGIN_API_VERSION) { + return FALSE; + } + return graptos_plugin_host_register_hub(host, + "taskwarrior-hub", + "Taskwarrior", + task_hub_render, + task_hub_action, + NULL, + NULL); +} diff --git a/data/themes/apprentice.css b/data/themes/apprentice.css index 88f1537..ce26f15 100644 --- a/data/themes/apprentice.css +++ b/data/themes/apprentice.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/dracula.css b/data/themes/dracula.css index 368df39..45dc1fd 100644 --- a/data/themes/dracula.css +++ b/data/themes/dracula.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/github.css b/data/themes/github.css index a764f78..df4be4c 100644 --- a/data/themes/github.css +++ b/data/themes/github.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/graptos.css b/data/themes/graptos.css index 6be0eac..0c84e9f 100644 --- a/data/themes/graptos.css +++ b/data/themes/graptos.css @@ -149,6 +149,27 @@ window.graptos-window .graptos-dialog-output text { color: @graptos_editor_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_accent; } diff --git a/data/themes/gruvbox.css b/data/themes/gruvbox.css index 44f7092..2b4a801 100644 --- a/data/themes/gruvbox.css +++ b/data/themes/gruvbox.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/moonfly.css b/data/themes/moonfly.css index 3975650..bdc01b2 100644 --- a/data/themes/moonfly.css +++ b/data/themes/moonfly.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/srcery.css b/data/themes/srcery.css index 86410e7..7aef29d 100644 --- a/data/themes/srcery.css +++ b/data/themes/srcery.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/data/themes/tokyonight.css b/data/themes/tokyonight.css index 66b2f82..6ee2db7 100644 --- a/data/themes/tokyonight.css +++ b/data/themes/tokyonight.css @@ -106,6 +106,27 @@ sourceview.graptos-editor text selection { background: @graptos_editor_selection .graptos-root entry, .graptos-root entry text, .graptos-root spinbutton, .graptos-root spinbutton text { background: @graptos_input_bg; color: @graptos_input_fg; border-color: @graptos_input_border; } popover.graptos-context-popover, popover.graptos-tools-popover, popover.graptos-completion-popover, popover.graptos-hover-popover { background: @graptos_popover_bg; border-color: @graptos_popover_border; } .graptos-completion-list row:selected, .graptos-completion-list row:hover { background: @graptos_completion_selection_bg; color: @graptos_completion_selection_fg; } +window.graptos-window.graptos-plugin-manager-window, +window.graptos-window.graptos-plugin-manager-window > contents, +window.graptos-window .graptos-plugin-manager-root, +window.graptos-window .graptos-plugin-manager-scroll, +window.graptos-window .graptos-plugin-manager-scroll viewport, +window.graptos-window .graptos-plugin-manager-list, +window.graptos-window .graptos-plugin-manager-row { + background: ; + background-color: ; + color: ; + border-radius: 0; + border: none; + box-shadow: none; + outline: none; +} +window.graptos-window .graptos-plugin-manager-root label, +window.graptos-window .graptos-plugin-manager-list label, +window.graptos-window .graptos-plugin-manager-row label { + color: ; +} + .graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; } .graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; } .graptos-codex-approval { background: @graptos_diagnostic_warning_bg; border-color: @graptos_diagnostic_warning_fg; } diff --git a/src/app.h b/src/app.h index f402650..f3b1e08 100644 --- a/src/app.h +++ b/src/app.h @@ -148,8 +148,13 @@ typedef struct _EditorWindow { GtkWidget *preview_button; /**< Preview button. */ GtkWidget *syntax_override_button; /**< Syntax override button. */ GtkWidget *search_revealer; /**< Search revealer. */ + GtkWidget *work_pane; /**< Resizable editor-versus-bottom pane. */ + GtkWidget *bottom_pane; /**< Resizable stacked bottom tool/terminal area. */ + gboolean bottom_pane_attached; /**< Bottom pane is currently attached to the work pane. */ GtkWidget *tool_revealer; /**< Tool revealer. */ GtkWidget *tool_panel; /**< Tool panel. */ + GtkWidget *plugin_hub_revealer; /**< Resizable plugin hub revealer. */ + GtkWidget *plugin_hub_panel; /**< Resizable plugin hub panel. */ GtkWidget *find_entry; /**< Find entry. */ GtkWidget *replace_label; /**< Replace label. */ GtkWidget *replace_entry; /**< Replace entry. */ @@ -184,6 +189,8 @@ typedef struct _EditorWindow { char *terminal_font; /**< Terminal font description, or empty for VTE default. */ char *code_font; /**< Code snippet and auxiliary code font description. */ char *theme_css_path; /**< Active CSS theme path. */ + char *latex_command; /**< Preferred LaTeX executable, or empty for auto-detect. */ + char *latex_arguments; /**< LaTeX argv tail with {output_dir} and {source_path}. */ char *editor_bg_color; /**< Editor bg color. */ char *editor_fg_color; /**< Editor fg color. */ char *editor_gutter_bg_color; /**< Editor gutter bg color. */ @@ -276,6 +283,7 @@ typedef struct _EditorWindow { guint lsp_sync_max_chars; /**< Maximum buffer chars for LSP didChange sync. */ guint max_undo_states; /**< Maximum undo snapshots retained per tab. */ guint full_highlight_max_chars; /**< Maximum chars for full highlighting work. */ + char *custom_highlight_mode; /**< Custom YAML highlight mode: auto, viewport, background, or full. */ guint max_undo_capture_bytes; /**< Maximum buffer chars copied into undo snapshots. */ guint highlight_delay_ms; /**< Delay before applying editor highlight refresh. */ guint completion_delay_ms; /**< Delay before automatic completion. */ @@ -314,6 +322,8 @@ typedef struct _EditorWindow { * @return The resolved value for the caller, or NULL when no suitable value is available. */ EditorWindow *app_window_new(GtkApplication *application); +void app_window_show_bottom_pane(EditorWindow *win); +void app_window_hide_bottom_pane_if_empty(EditorWindow *win); /** * @brief App window free. * @details Application glue touches actions, tabs, panels, and persistent state. Keeping the contract explicit here makes UI callbacks easier to audit when a later change moves work between the window and child widgets. diff --git a/src/app/app_file_search_actions.inc.c b/src/app/app_file_search_actions.inc.c index 84e31c7..b52a740 100644 --- a/src/app/app_file_search_actions.inc.c +++ b/src/app/app_file_search_actions.inc.c @@ -26,6 +26,36 @@ void set_search_panel(EditorWindow *win, gboolean visible, gboolean replace_mode } } +/** + * @brief Seed find from selection and run it. + * @details Ctrl+F should respect the selected editor text the same way common + * editors do: the selection becomes the query, the find field shows + * that query, and the first search happens immediately. + * @param win The window whose active tab supplies the selection. + */ +static void show_find_from_selection(EditorWindow *win) { + if (!win) return; + EditorTab *tab = app_window_current_tab(win); + g_autofree char *selected = NULL; + if (tab && tab->buffer) { + GtkTextIter start; + GtkTextIter end; + if (gtk_text_buffer_get_selection_bounds(tab->buffer, &start, &end)) { + selected = gtk_text_buffer_get_text(tab->buffer, &start, &end, FALSE); + } + } + set_search_panel(win, TRUE, FALSE); + if (selected && selected[0] != '\0') { + gtk_entry_set_text(GTK_ENTRY(win->find_entry), selected); + } + const char *query = win->find_entry + ? gtk_entry_get_text(GTK_ENTRY(win->find_entry)) + : NULL; + if (tab && query && query[0] != '\0') { + editor_tab_find(tab, query, FALSE); + } +} + /** * @brief Action new. @@ -178,7 +208,7 @@ void action_save_as(GtkWidget *widget, gpointer user_data) { */ void action_show_find(GtkWidget *widget, gpointer user_data) { (void)widget; - set_search_panel(user_data, TRUE, FALSE); + show_find_from_selection(user_data); } diff --git a/src/app/app_window_events.inc.c b/src/app/app_window_events.inc.c index ea20da1..f0f597f 100644 --- a/src/app/app_window_events.inc.c +++ b/src/app/app_window_events.inc.c @@ -321,7 +321,7 @@ gboolean on_window_key_pressed(GtkEventControllerKey *controller, } // Search uses one panel. Ctrl+F opens find mode and Ctrl+H opens replace mode. - if (ctrl && key == GDK_KEY_f) { set_search_panel(win, TRUE, FALSE); return TRUE; } + if (ctrl && key == GDK_KEY_f) { action_show_find(NULL, win); return TRUE; } if (ctrl && key == GDK_KEY_h) { set_search_panel(win, TRUE, TRUE); return TRUE; } // Editing helpers are tab-scoped because they operate on the active buffer. diff --git a/src/app/app_window_lifecycle.inc.c b/src/app/app_window_lifecycle.inc.c index 8869c65..777b233 100644 --- a/src/app/app_window_lifecycle.inc.c +++ b/src/app/app_window_lifecycle.inc.c @@ -103,6 +103,8 @@ EditorWindow *app_window_new(GtkApplication *application) { win->preview_font = g_strdup(""); win->terminal_font = g_strdup(""); win->code_font = g_strdup("monospace"); + win->latex_command = g_strdup(""); + win->latex_arguments = g_strdup("-interaction=nonstopmode -halt-on-error -file-line-error -no-shell-escape -output-directory {output_dir} {source_path}"); { const char *config_dir = g_get_user_config_dir(); win->theme_css_path = config_dir && config_dir[0] != '\0' @@ -202,6 +204,7 @@ EditorWindow *app_window_new(GtkApplication *application) { win->lsp_sync_max_chars = GRAPTOS_DEFAULT_LSP_SYNC_MAX_CHARS; win->max_undo_states = GRAPTOS_DEFAULT_MAX_UNDO_STATES; win->full_highlight_max_chars = GRAPTOS_DEFAULT_FULL_HIGHLIGHT_MAX_CHARS; + win->custom_highlight_mode = g_strdup("auto"); win->max_undo_capture_bytes = GRAPTOS_DEFAULT_MAX_UNDO_CAPTURE_BYTES; win->highlight_delay_ms = GRAPTOS_DEFAULT_HIGHLIGHT_DELAY_MS; win->completion_delay_ms = GRAPTOS_DEFAULT_COMPLETION_DELAY_MS; @@ -227,6 +230,7 @@ EditorWindow *app_window_new(GtkApplication *application) { plugin_error) { g_warning("Plugin discovery failed: %s", plugin_error->message); } + graptos_plugin_registry_apply_user_state(win->plugins); g_clear_error(&plugin_error); if (!graptos_plugin_registry_load_native(win->plugins, &plugin_error) && plugin_error) { @@ -266,9 +270,16 @@ EditorWindow *app_window_new(GtkApplication *application) { GtkWidget *top = build_top_bar(win); gtk_box_append(GTK_BOX(root), top); + win->work_pane = gtk_paned_new(GTK_ORIENTATION_VERTICAL); + gtk_widget_add_css_class(win->work_pane, "graptos-work-pane"); + gtk_widget_set_vexpand(win->work_pane, TRUE); + gtk_box_append(GTK_BOX(root), win->work_pane); + GtkWidget *main_pane = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL); gtk_widget_set_vexpand(main_pane, TRUE); - gtk_box_append(GTK_BOX(root), main_pane); + gtk_paned_set_start_child(GTK_PANED(win->work_pane), main_pane); + gtk_paned_set_resize_start_child(GTK_PANED(win->work_pane), TRUE); + gtk_paned_set_shrink_start_child(GTK_PANED(win->work_pane), FALSE); win->project_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(win->project_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_RIGHT); @@ -301,8 +312,17 @@ EditorWindow *app_window_new(GtkApplication *application) { gtk_paned_set_position(GTK_PANED(main_pane), 240); g_signal_connect(win->notebook, "switch-page", G_CALLBACK(on_switch_page), win); + win->bottom_pane = gtk_paned_new(GTK_ORIENTATION_VERTICAL); + g_object_ref_sink(win->bottom_pane); + gtk_widget_add_css_class(win->bottom_pane, "graptos-bottom-pane"); + gtk_widget_set_vexpand(win->bottom_pane, TRUE); + gtk_widget_set_visible(win->bottom_pane, FALSE); + win->terminal_panel = terminal_panel_new(win); - gtk_box_append(GTK_BOX(root), terminal_panel_widget(win->terminal_panel)); + gtk_paned_set_start_child(GTK_PANED(win->bottom_pane), + terminal_panel_widget(win->terminal_panel)); + gtk_paned_set_resize_start_child(GTK_PANED(win->bottom_pane), TRUE); + gtk_paned_set_shrink_start_child(GTK_PANED(win->bottom_pane), FALSE); GtkWidget *search_panel = build_search_panel(win); gtk_box_append(GTK_BOX(root), search_panel); @@ -310,6 +330,12 @@ EditorWindow *app_window_new(GtkApplication *application) { GtkWidget *tool_panel = build_tool_panel(win); gtk_box_append(GTK_BOX(root), tool_panel); + GtkWidget *plugin_hub_panel = build_plugin_hub_panel(win); + gtk_paned_set_end_child(GTK_PANED(win->bottom_pane), plugin_hub_panel); + gtk_paned_set_resize_end_child(GTK_PANED(win->bottom_pane), TRUE); + gtk_paned_set_shrink_end_child(GTK_PANED(win->bottom_pane), FALSE); + gtk_paned_set_position(GTK_PANED(win->bottom_pane), 150); + GtkWidget *bottom = build_bottom_bar(win); gtk_box_append(GTK_BOX(root), bottom); @@ -368,6 +394,11 @@ void app_window_free(EditorWindow *win) { lsp_client_free(win->lsp_client); codex_panel_free(win->codex_panel); terminal_panel_free(win->terminal_panel); + if (win->bottom_pane_attached && win->work_pane) { + gtk_paned_set_end_child(GTK_PANED(win->work_pane), NULL); + win->bottom_pane_attached = FALSE; + } + g_clear_object(&win->bottom_pane); graptos_config_save(win); if (win->syntaxes) g_ptr_array_free(win->syntaxes, TRUE); if (win->project_expanded) g_hash_table_destroy(win->project_expanded); @@ -467,7 +498,10 @@ void app_window_free(EditorWindow *win) { g_free(win->preview_font); g_free(win->terminal_font); g_free(win->code_font); + g_free(win->custom_highlight_mode); g_free(win->theme_css_path); + g_free(win->latex_command); + g_free(win->latex_arguments); g_free(win->project_root); g_free(win); } diff --git a/src/app/app_window_state.inc.c b/src/app/app_window_state.inc.c index 0b33c0a..80ab7bd 100644 --- a/src/app/app_window_state.inc.c +++ b/src/app/app_window_state.inc.c @@ -268,6 +268,7 @@ void app_window_note_path_renamed(EditorWindow *win, */ tab->manual_syntax_override = FALSE; editor_tab_auto_select_syntax(tab); + editor_tab_update_highlight_engine(tab); // Refresh tab state after the path change. editor_tab_set_locked(tab, diff --git a/src/app_layout.c b/src/app_layout.c index f9e8474..a1199d7 100644 --- a/src/app_layout.c +++ b/src/app_layout.c @@ -444,6 +444,64 @@ static void hide_tool_panel(GtkWidget *widget, gpointer user_data) { } } +void app_window_show_bottom_pane(EditorWindow *win) { + if (!win || !win->bottom_pane) return; + gboolean attached_now = FALSE; + if (win->work_pane && !win->bottom_pane_attached) { + gtk_paned_set_end_child(GTK_PANED(win->work_pane), win->bottom_pane); + gtk_paned_set_resize_end_child(GTK_PANED(win->work_pane), TRUE); + gtk_paned_set_shrink_end_child(GTK_PANED(win->work_pane), FALSE); + win->bottom_pane_attached = TRUE; + attached_now = TRUE; + } + gtk_widget_set_visible(win->bottom_pane, TRUE); + if (attached_now && win->work_pane) { + int height = gtk_widget_get_height(win->work_pane); + gtk_paned_set_position(GTK_PANED(win->work_pane), + height > 320 ? height - 220 : 520); + } + if (attached_now && win->bottom_pane) { + gtk_paned_set_position(GTK_PANED(win->bottom_pane), 150); + } +} + +void app_window_hide_bottom_pane_if_empty(EditorWindow *win) { + if (!win || !win->bottom_pane) return; + gboolean terminal_open = terminal_panel_is_open(win->terminal_panel); + gboolean hub_open = win->plugin_hub_revealer + ? gtk_revealer_get_reveal_child(GTK_REVEALER(win->plugin_hub_revealer)) + : FALSE; + if (terminal_open || hub_open) return; + gtk_widget_set_visible(win->bottom_pane, FALSE); + if (win->work_pane && win->bottom_pane_attached) { + gtk_paned_set_end_child(GTK_PANED(win->work_pane), NULL); + win->bottom_pane_attached = FALSE; + } + if (win->work_pane) { + int height = gtk_widget_get_height(win->work_pane); + gtk_paned_set_position(GTK_PANED(win->work_pane), + height > 0 ? height : 100000); + } +} + +/** + * @brief Hide the tool revealer after its close transition completes. + * @param revealer The revealer supplied by GTK. + * @param pspec The changed property, unused. + * @param user_data The editor window, unused. + */ +static void tool_panel_revealer_child_revealed(GtkRevealer *revealer, + GParamSpec *pspec, + gpointer user_data) { + (void)pspec; + (void)user_data; + if (!revealer) return; + if (!gtk_revealer_get_reveal_child(revealer) && + !gtk_revealer_get_child_revealed(revealer)) { + gtk_widget_set_visible(GTK_WIDGET(revealer), FALSE); + } +} + /** * @brief Tool panel show ready. * @details Application glue touches actions, tabs, panels, and persistent state. Keeping the contract explicit here makes UI callbacks easier to audit when a later change moves work between the window and child widgets. @@ -451,6 +509,7 @@ static void hide_tool_panel(GtkWidget *widget, gpointer user_data) { */ static void tool_panel_show_ready(EditorWindow *win) { if (!win || !win->tool_panel || !win->tool_revealer) return; + gtk_widget_set_visible(win->tool_revealer, TRUE); GtkWidget *spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_set_hexpand(spacer, TRUE); tool_panel_append(win, spacer); @@ -458,6 +517,60 @@ static void tool_panel_show_ready(EditorWindow *win) { gtk_revealer_set_reveal_child(GTK_REVEALER(win->tool_revealer), TRUE); } +static void plugin_hub_panel_clear(EditorWindow *win) { + if (!win || !win->plugin_hub_panel) return; + GtkWidget *child = gtk_widget_get_first_child(win->plugin_hub_panel); + while (child) { + GtkWidget *next = gtk_widget_get_next_sibling(child); + gtk_box_remove(GTK_BOX(win->plugin_hub_panel), child); + child = next; + } +} + +static void plugin_hub_panel_begin(EditorWindow *win, const char *title) { + if (!win || !win->plugin_hub_panel) return; + plugin_hub_panel_clear(win); + gtk_box_append(GTK_BOX(win->plugin_hub_panel), + tool_title_new(title ? title : "Plugin Hub")); + gtk_box_append(GTK_BOX(win->plugin_hub_panel), graptos_separator_new()); +} + +static void hide_plugin_hub_panel(GtkWidget *widget, gpointer user_data) { + (void)widget; + EditorWindow *win = user_data; + if (win && win->plugin_hub_revealer) { + gtk_revealer_set_reveal_child(GTK_REVEALER(win->plugin_hub_revealer), FALSE); + } +} + +static void plugin_hub_revealer_child_revealed(GtkRevealer *revealer, + GParamSpec *pspec, + gpointer user_data) { + (void)pspec; + EditorWindow *win = user_data; + if (!revealer) return; + if (!gtk_revealer_get_reveal_child(revealer) && + !gtk_revealer_get_child_revealed(revealer)) { + gtk_widget_set_visible(GTK_WIDGET(revealer), FALSE); + app_window_hide_bottom_pane_if_empty(win); + } +} + +static void plugin_hub_panel_show_ready(EditorWindow *win) { + if (!win || !win->plugin_hub_panel || !win->plugin_hub_revealer) return; + app_window_show_bottom_pane(win); + gtk_widget_set_visible(win->plugin_hub_revealer, TRUE); + GtkWidget *spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + gtk_box_append(GTK_BOX(win->plugin_hub_panel), spacer); + gtk_box_append(GTK_BOX(win->plugin_hub_panel), + tool_button_new("×", + "Close plugin hub", + G_CALLBACK(hide_plugin_hub_panel), + win)); + gtk_revealer_set_reveal_child(GTK_REVEALER(win->plugin_hub_revealer), TRUE); +} + /** * @brief Tool syntax choice state. */ @@ -468,6 +581,32 @@ typedef struct { GtkWidget *popover; /**< Popover containing syntax choices. */ } ToolSyntaxChoice; +/** + * @brief Plugin hub callback state. + */ +typedef struct { + EditorWindow *win; /**< Owning editor window. */ + char *plugin_id; /**< Plugin id. */ + char *hub_id; /**< Hub id. */ + char *label; /**< Visible hub label. */ + GtkWidget *popover; /**< Optional popover to close. */ + char *action_id; /**< Optional hub action id. */ +} ToolPluginHubAction; + +/** + * @brief Free plugin hub callback state. + * @param data ToolPluginHubAction. + */ +static void tool_plugin_hub_action_free(gpointer data) { + ToolPluginHubAction *action = data; + if (!action) return; + g_free(action->plugin_id); + g_free(action->hub_id); + g_free(action->label); + g_free(action->action_id); + g_free(action); +} + /** * @brief Free a tool syntax choice. * @details Application glue touches actions, tabs, panels, and persistent state. Keeping the contract explicit here makes UI callbacks easier to audit when a later change moves work between the window and child widgets. @@ -951,6 +1090,379 @@ static void show_git_tools(GtkWidget *widget, gpointer user_data) { tool_panel_show_ready(win); } +/** + * @brief Plugin manager row state. + */ +typedef struct { + EditorWindow *win; /**< Window owning the plugin registry. */ + char *plugin_id; /**< Stable plugin id controlled by this row. */ +} PluginManagerRow; + +/** + * @brief Free plugin manager row state. + * @param data PluginManagerRow allocated for a switch. + */ +static void plugin_manager_row_free(gpointer data) { + PluginManagerRow *row = data; + if (!row) return; + g_free(row->plugin_id); + g_free(row); +} + +/** + * @brief Close the plugin manager dialog. + * @param widget Widget that emitted the close action. + * @param user_data Plugin manager window. + */ +static void plugin_manager_close(GtkWidget *widget, gpointer user_data) { + (void)widget; + if (GTK_IS_WINDOW(user_data)) gtk_window_destroy(GTK_WINDOW(user_data)); +} + +/** + * @brief Toggle one plugin from the manager. + * @details The manifest is left untouched. Graptoς persists user disabled + * plugin ids under the config directory and refreshes contributions + * that can affect active tabs. + * @param switch_widget Switch that changed state. + * @param pspec Property that changed. + * @param user_data PluginManagerRow for the switch. + */ +static void plugin_manager_switch_changed(GObject *switch_widget, + GParamSpec *pspec, + gpointer user_data) { + (void)pspec; + PluginManagerRow *row = user_data; + if (!row || !row->win || !row->plugin_id) return; + gboolean enabled = gtk_switch_get_active(GTK_SWITCH(switch_widget)); + if (!graptos_plugin_registry_set_enabled(row->win->plugins, + row->plugin_id, + enabled)) { + return; + } + (void)graptos_plugin_registry_save_user_state(row->win->plugins); + if (enabled) { + g_autoptr(GError) error = NULL; + if (!graptos_plugin_registry_load_native(row->win->plugins, &error) && + error) { + g_warning("Native plugin loading failed: %s", error->message); + } + } + app_window_reload_syntaxes(row->win); + app_window_set_status(row->win, + enabled ? "Plugin enabled" : "Plugin disabled"); +} + +/** + * @brief Append one plugin manager row. + * @param list List box receiving the row. + * @param win Window owning the plugin registry. + * @param plugin Plugin shown by the row. + */ +static void plugin_manager_append_row(GtkWidget *list, + EditorWindow *win, + GraptosPlugin *plugin) { + if (!list || !win || !plugin) return; + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_add_css_class(row, "graptos-plugin-manager-row"); + graptos_set_all_margins(row, 8); + + GtkWidget *text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + gtk_widget_set_hexpand(text, TRUE); + GtkWidget *title = gtk_label_new(plugin->name ? plugin->name : plugin->id); + gtk_label_set_xalign(GTK_LABEL(title), 0.0f); + gtk_widget_add_css_class(title, "graptos-menu-title"); + gtk_box_append(GTK_BOX(text), title); + + char *detail_text = g_strdup_printf("%s%s%s", + plugin->id ? plugin->id : "(unknown)", + plugin->description ? " - " : "", + plugin->description ? plugin->description : ""); + GtkWidget *detail = menu_small_label(detail_text); + g_free(detail_text); + gtk_label_set_wrap(GTK_LABEL(detail), TRUE); + gtk_box_append(GTK_BOX(text), detail); + gtk_box_append(GTK_BOX(row), text); + + GtkWidget *enabled = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(enabled), plugin->enabled); + gtk_widget_set_valign(enabled, GTK_ALIGN_CENTER); + PluginManagerRow *state = g_new0(PluginManagerRow, 1); + state->win = win; + state->plugin_id = g_strdup(plugin->id); + g_object_set_data_full(G_OBJECT(enabled), + "graptos-plugin-manager-row", + state, + plugin_manager_row_free); + g_signal_connect(enabled, + "notify::active", + G_CALLBACK(plugin_manager_switch_changed), + state); + gtk_box_append(GTK_BOX(row), enabled); + gtk_list_box_append(GTK_LIST_BOX(list), row); +} + +/** + * @brief Show the plugin manager dialog. + * @details The manager exposes runtime plugin enable state without editing + * plugin YAML files. A restart is not required for command hiding; + * enabling native code loads any missing shared libraries on demand. + * @param widget Widget that emitted the action. + * @param user_data EditorWindow owning the plugin registry. + */ +static void show_plugin_manager(GtkWidget *widget, gpointer user_data) { + EditorWindow *win = user_data; + if (!win) return; + GtkWidget *popover = widget ? gtk_widget_get_ancestor(widget, GTK_TYPE_POPOVER) : NULL; + if (popover) graptos_popover_hide(popover); + + GtkWidget *window = gtk_window_new(); + gtk_widget_add_css_class(window, "graptos-window"); + gtk_widget_add_css_class(window, "graptos-dialog"); + gtk_widget_add_css_class(window, "graptos-plugin-manager-window"); + gtk_window_set_title(GTK_WINDOW(window), "Plugin Manager"); + gtk_window_set_default_size(GTK_WINDOW(window), 520, 420); + gtk_window_set_transient_for(GTK_WINDOW(window), app_window_gtk(win)); + gtk_window_set_modal(GTK_WINDOW(window), TRUE); + + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); + gtk_widget_add_css_class(box, "graptos-root"); + gtk_widget_add_css_class(box, "graptos-dialog-root"); + gtk_widget_add_css_class(box, "graptos-flat-dialog"); + gtk_widget_add_css_class(box, "graptos-plugin-manager-root"); + graptos_set_all_margins(box, 12); + + GtkWidget *heading = gtk_label_new("Plugin Manager"); + gtk_label_set_xalign(GTK_LABEL(heading), 0.0f); + gtk_widget_add_css_class(heading, "graptos-dialog-title"); + gtk_box_append(GTK_BOX(box), heading); + + GtkWidget *scrolled = gtk_scrolled_window_new(); + gtk_widget_add_css_class(scrolled, "graptos-plugin-manager-scroll"); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_vexpand(scrolled, TRUE); + GtkWidget *list = gtk_list_box_new(); + gtk_widget_add_css_class(list, "graptos-plugin-manager-list"); + gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_NONE); + for (guint i = 0u; win->plugins && win->plugins->plugins && + i < win->plugins->plugins->len; i++) { + plugin_manager_append_row(list, + win, + g_ptr_array_index(win->plugins->plugins, i)); + } + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), list); + gtk_box_append(GTK_BOX(box), scrolled); + + GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_halign(actions, GTK_ALIGN_END); + GtkWidget *close = tool_button_new("Close", + "Close plugin manager", + G_CALLBACK(plugin_manager_close), + window); + gtk_box_append(GTK_BOX(actions), close); + gtk_box_append(GTK_BOX(box), actions); + + gtk_window_set_child(GTK_WINDOW(window), box); + gtk_window_present(GTK_WINDOW(window)); +} + +/** + * @brief Show one plugin hub in the tool panel. + * @param win Owning window. + * @param plugin_id Plugin id. + * @param hub_id Hub id. + * @param label Visible label. + */ +static void show_plugin_hub_panel(EditorWindow *win, + const char *plugin_id, + const char *hub_id, + const char *label); + +/** + * @brief Return prompt label for one hub action. + * @param action_id Hub action id. + * @return Prompt label, or NULL for no prompt. + */ +static const char *plugin_hub_action_prompt(const char *action_id) { + if (g_strcmp0(action_id, "add") == 0) return "Task description:"; + if (g_strcmp0(action_id, "modify") == 0) { + return "Task id and modifications, e.g. 12 due:tomorrow +tag:"; + } + if (g_strcmp0(action_id, "priority") == 0) { + return "Task id and priority H/M/L, or id only to clear:"; + } + if (g_strcmp0(action_id, "done") == 0) return "Task id:"; + if (g_strcmp0(action_id, "delete") == 0) return "Task id:"; + return NULL; +} + +/** + * @brief Run one plugin hub action. + * @param widget Button that emitted the callback. + * @param user_data ToolPluginHubAction. + */ +static void plugin_hub_action_clicked(GtkWidget *widget, gpointer user_data) { + (void)widget; + ToolPluginHubAction *action = user_data; + if (!action || !action->win || !action->action_id) return; + EditorWindow *win = action->win; + g_autofree char *plugin_id = g_strdup(action->plugin_id); + g_autofree char *hub_id = g_strdup(action->hub_id); + g_autofree char *label = g_strdup(action->label); + g_autofree char *action_id = g_strdup(action->action_id); + EditorTab *tab = app_window_current_tab(win); + const char *prompt = plugin_hub_action_prompt(action_id); + g_autofree char *input = NULL; + if (prompt) { + input = dialog_prompt_text(app_window_gtk(win), + label ? label : "Plugin Hub", + prompt, + ""); + if (!input) return; + } + (void)graptos_plugin_registry_run_hub_action(win->plugins, + tab, + plugin_id, + hub_id, + action_id, + input); + show_plugin_hub_panel(win, plugin_id, hub_id, label); +} + +/** + * @brief Open one plugin hub from the plugin popover. + * @param widget Button that emitted the callback. + * @param user_data ToolPluginHubAction. + */ +static void plugin_hub_open_clicked(GtkWidget *widget, gpointer user_data) { + (void)widget; + ToolPluginHubAction *action = user_data; + if (!action || !action->win) return; + if (action->popover && GTK_IS_POPOVER(action->popover)) { + gtk_popover_popdown(GTK_POPOVER(action->popover)); + } + show_plugin_hub_panel(action->win, + action->plugin_id, + action->hub_id, + action->label); +} + +/** + * @brief Add one hub button with owned callback state. + * @param box Destination box. + * @param label Button label. + * @param tooltip Tooltip text. + * @param callback Button callback. + * @param state Owned callback state. + */ +static void plugin_hub_append_button(GtkWidget *box, + const char *label, + const char *tooltip, + GCallback callback, + ToolPluginHubAction *state) { + GtkWidget *button = tool_button_new(label, tooltip, callback, state); + g_object_set_data_full(G_OBJECT(button), + "graptos-plugin-hub-action", + state, + tool_plugin_hub_action_free); + gtk_box_append(GTK_BOX(box), button); +} + +/** + * @brief Create callback state for a plugin hub. + * @param win Owning window. + * @param view Hub view. + * @param popover Optional popover. + * @param action_id Optional action id. + * @return Owned state. + */ +static ToolPluginHubAction *plugin_hub_action_new(EditorWindow *win, + const GraptosPluginHubView *view, + GtkWidget *popover, + const char *action_id) { + ToolPluginHubAction *state = g_new0(ToolPluginHubAction, 1); + state->win = win; + state->plugin_id = g_strdup(view ? view->plugin_id : NULL); + state->hub_id = g_strdup(view ? view->hub_id : NULL); + state->label = g_strdup(view ? view->label : "Plugin Hub"); + state->popover = popover; + state->action_id = g_strdup(action_id); + return state; +} + +static void show_plugin_hub_panel(EditorWindow *win, + const char *plugin_id, + const char *hub_id, + const char *label) { + if (!win || !plugin_id || !hub_id) return; + EditorTab *tab = app_window_current_tab(win); + g_autofree char *body = graptos_plugin_registry_render_hub(win->plugins, + tab, + plugin_id, + hub_id); + plugin_hub_panel_begin(win, label && label[0] ? label : "Plugin Hub"); + + GtkWidget *scrolled = gtk_scrolled_window_new(); + gtk_widget_add_css_class(scrolled, "graptos-plugin-hub-scroll"); + gtk_widget_set_size_request(scrolled, 500, 72); + gtk_widget_set_hexpand(scrolled, TRUE); + gtk_widget_set_vexpand(scrolled, TRUE); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_AUTOMATIC, + GTK_POLICY_AUTOMATIC); + GtkWidget *output = gtk_label_new(body && body[0] ? body : "No hub content."); + gtk_widget_add_css_class(output, "graptos-editor"); + gtk_widget_add_css_class(output, "graptos-plugin-hub-view"); + gtk_label_set_selectable(GTK_LABEL(output), TRUE); + gtk_label_set_wrap(GTK_LABEL(output), TRUE); + gtk_label_set_xalign(GTK_LABEL(output), 0.0f); + gtk_label_set_yalign(GTK_LABEL(output), 0.0f); + gtk_widget_set_halign(output, GTK_ALIGN_FILL); + gtk_widget_set_valign(output, GTK_ALIGN_START); + gtk_widget_set_hexpand(output, TRUE); + graptos_set_all_margins(output, 6); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), output); + gtk_box_append(GTK_BOX(win->plugin_hub_panel), scrolled); + + GraptosPluginHubView view = {0}; + view.plugin_id = (char *)plugin_id; + view.hub_id = (char *)hub_id; + view.label = (char *)(label && label[0] ? label : "Plugin Hub"); + plugin_hub_append_button(win->plugin_hub_panel, + "Refresh", + "Refresh task list", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "refresh")); + plugin_hub_append_button(win->plugin_hub_panel, + "Add", + "Add task", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "add")); + plugin_hub_append_button(win->plugin_hub_panel, + "Modify", + "Modify task", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "modify")); + plugin_hub_append_button(win->plugin_hub_panel, + "Priority", + "Set task priority", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "priority")); + plugin_hub_append_button(win->plugin_hub_panel, + "Done", + "Mark task done", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "done")); + plugin_hub_append_button(win->plugin_hub_panel, + "Delete", + "Delete task", + G_CALLBACK(plugin_hub_action_clicked), + plugin_hub_action_new(win, &view, NULL, "delete")); + plugin_hub_panel_show_ready(win); +} + /** * @brief Show plugin tools. * @details Declarative plugin menu commands are also available from the bottom @@ -959,20 +1471,56 @@ static void show_git_tools(GtkWidget *widget, gpointer user_data) { * @param user_data The callback context passed through GTK signal data. */ static void show_plugin_tools(GtkWidget *widget, gpointer user_data) { - (void)widget; EditorWindow *win = user_data; - if (!win || !win->tool_panel) return; - tool_panel_begin(win, "Plugins"); + if (!win || !widget) return; EditorTab *tab = app_window_current_tab(win); - guint added = graptos_plugin_append_editor_tool_items(win->plugins, - tab, - win->tool_panel, - tool_active_editor_line(tab)); + GtkWidget *popover = gtk_popover_new(); + graptos_popover_attach(popover, widget); + gtk_popover_set_has_arrow(GTK_POPOVER(popover), FALSE); + gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_TOP); + gtk_widget_add_css_class(popover, "graptos-tools-popover"); + + GtkWidget *scrolled = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), + GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(scrolled, 260, 320); + + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + graptos_set_all_margins(box, 6); + gtk_box_append(GTK_BOX(box), + tool_button_new("Manage Plugins", + "Enable or disable plugins", + G_CALLBACK(show_plugin_manager), + win)); + gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + g_autoptr(GPtrArray) hubs = graptos_plugin_registry_hub_views(win->plugins); + if (hubs && hubs->len > 0u) { + for (guint i = 0u; i < hubs->len; i++) { + GraptosPluginHubView *view = g_ptr_array_index(hubs, i); + if (!view) continue; + plugin_hub_append_button(box, + view->label ? view->label : "Plugin Hub", + "Open plugin hub", + G_CALLBACK(plugin_hub_open_clicked), + plugin_hub_action_new(win, view, popover, NULL)); + } + gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + } + guint added = graptos_plugin_append_editor_tool_menu_items(win->plugins, + tab, + box, + popover, + tool_active_editor_line(tab)); if (added == 0u) { - tool_panel_append(win, menu_small_label("No plugin commands for the active editor")); + gtk_box_append(GTK_BOX(box), + menu_small_label("No plugin commands for the active editor")); } - tool_panel_show_ready(win); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), box); + gtk_popover_set_child(GTK_POPOVER(popover), scrolled); + g_signal_connect(popover, "closed", G_CALLBACK(tool_popover_closed), NULL); + graptos_popover_show(popover); } /** @@ -1021,15 +1569,45 @@ GtkWidget *build_tool_panel(EditorWindow *win) { win->tool_revealer = gtk_revealer_new(); gtk_revealer_set_transition_type(GTK_REVEALER(win->tool_revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_UP); gtk_revealer_set_transition_duration(GTK_REVEALER(win->tool_revealer), 120u); + gtk_widget_set_visible(win->tool_revealer, FALSE); + gtk_widget_set_vexpand(win->tool_revealer, FALSE); + g_signal_connect(win->tool_revealer, "notify::child-revealed", + G_CALLBACK(tool_panel_revealer_child_revealed), + win); win->tool_panel = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); gtk_widget_add_css_class(win->tool_panel, "graptos-tool-panel"); gtk_widget_set_hexpand(win->tool_panel, TRUE); + gtk_widget_set_vexpand(win->tool_panel, FALSE); gtk_revealer_set_child(GTK_REVEALER(win->tool_revealer), win->tool_panel); gtk_revealer_set_reveal_child(GTK_REVEALER(win->tool_revealer), FALSE); return win->tool_revealer; } +GtkWidget *build_plugin_hub_panel(EditorWindow *win) { + win->plugin_hub_revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(win->plugin_hub_revealer), + GTK_REVEALER_TRANSITION_TYPE_SLIDE_UP); + gtk_revealer_set_transition_duration(GTK_REVEALER(win->plugin_hub_revealer), + 120u); + gtk_widget_set_visible(win->plugin_hub_revealer, FALSE); + gtk_widget_set_vexpand(win->plugin_hub_revealer, TRUE); + g_signal_connect(win->plugin_hub_revealer, + "notify::child-revealed", + G_CALLBACK(plugin_hub_revealer_child_revealed), + win); + + win->plugin_hub_panel = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_add_css_class(win->plugin_hub_panel, "graptos-tool-panel"); + gtk_widget_set_hexpand(win->plugin_hub_panel, TRUE); + gtk_widget_set_vexpand(win->plugin_hub_panel, TRUE); + gtk_revealer_set_child(GTK_REVEALER(win->plugin_hub_revealer), + win->plugin_hub_panel); + gtk_revealer_set_reveal_child(GTK_REVEALER(win->plugin_hub_revealer), + FALSE); + return win->plugin_hub_revealer; +} + /** * @brief On status label clicked. * @details Application glue touches actions, tabs, panels, and persistent state. Keeping the contract explicit here makes UI callbacks easier to audit when a later change moves work between the window and child widgets. diff --git a/src/app_private.h b/src/app_private.h index 6de7d17..9d50bdb 100644 --- a/src/app_private.h +++ b/src/app_private.h @@ -121,6 +121,7 @@ GtkWidget *build_search_panel(EditorWindow *win); * @return The resolved value for the caller, or NULL when no suitable value is available. */ GtkWidget *build_tool_panel(EditorWindow *win); +GtkWidget *build_plugin_hub_panel(EditorWindow *win); /** * @brief Build bottom bar. * @details Application glue touches actions, tabs, panels, and persistent state. Keeping the contract explicit here makes UI callbacks easier to audit when a later change moves work between the window and child widgets. diff --git a/src/config.c b/src/config.c index 20ff7e5..2c62e68 100644 --- a/src/config.c +++ b/src/config.c @@ -11,6 +11,8 @@ #include #include +#define GRAPTOS_DEFAULT_LATEX_ARGUMENTS "-interaction=nonstopmode -halt-on-error -file-line-error -no-shell-escape -output-directory {output_dir} {source_path}" + /** * @brief Parse bool. * @details Configuration values are user data, not internal constants. The comment makes the fallback path explicit so missing keys do not overwrite intentional manual edits. @@ -197,6 +199,96 @@ static void replace_config_string(char **slot, const char *value) { *slot = g_strdup(value ? value : ""); } +/** + * @brief Return whether a command token is safe for legacy env config. + * @param text Command token. + * @return TRUE when it is a single executable token. + */ +static gboolean command_token_has_no_space(const char *text) { + if (!text || !text[0]) return FALSE; + for (const char *p = text; *p; p++) { + if (g_ascii_isspace(*p)) return FALSE; + } + return TRUE; +} + +/** + * @brief Replace one placeholder token in a string. + * @param text Source text. + * @param placeholder Placeholder token. + * @param value Replacement value. + * @return Owned replaced string. + */ +static char *replace_placeholder(const char *text, + const char *placeholder, + const char *value) { + GString *out = g_string_new(NULL); + const char *cursor = text ? text : ""; + gsize placeholder_len = strlen(placeholder); + while (TRUE) { + const char *match = g_strstr_len(cursor, -1, placeholder); + if (!match) { + g_string_append(out, cursor); + break; + } + g_string_append_len(out, cursor, (gssize)(match - cursor)); + g_string_append(out, value ? value : ""); + cursor = match + placeholder_len; + } + return g_string_free(out, FALSE); +} + +char *graptos_latex_resolve_command(EditorWindow *win) { + if (win && win->latex_command && win->latex_command[0] != '\0') { + return g_strdup(win->latex_command); + } + + const char *env = g_getenv("GRAPTOS_LATEX_COMMAND"); + if (command_token_has_no_space(env)) return g_strdup(env); + + static const char *commands[] = { + "pdflatex", + "xelatex", + "lualatex", + NULL + }; + for (guint i = 0u; commands[i]; i++) { + g_autofree char *path = g_find_program_in_path(commands[i]); + if (path) return g_strdup(commands[i]); + } + return NULL; +} + +char **graptos_latex_build_argv(EditorWindow *win, + const char *command, + const char *output_dir, + const char *source_path, + GError **error) { + if (!command || !command[0]) return NULL; + const char *arguments = win && win->latex_arguments && win->latex_arguments[0] != '\0' + ? win->latex_arguments + : GRAPTOS_DEFAULT_LATEX_ARGUMENTS; + + gint argc = 0; + g_auto(GStrv) parsed = NULL; + if (!g_shell_parse_argv(arguments, &argc, &parsed, error)) { + return NULL; + } + + char **argv = g_new0(char *, (gsize)argc + 2u); + argv[0] = g_strdup(command); + for (gint i = 0; i < argc; i++) { + g_autofree char *with_output = replace_placeholder(parsed[i], + "{output_dir}", + output_dir); + argv[i + 1] = replace_placeholder(with_output, + "{source_path}", + source_path); + } + argv[argc + 1] = NULL; + return argv; +} + /** * @brief Append one managed color variable. * @details The managed block is intentionally explicit. Each variable maps to @@ -512,6 +604,26 @@ static char *build_managed_theme_css(EditorWindow *win) { " background: @graptos_dialog_input_bg;\n" " color: @graptos_dialog_input_fg;\n" "}\n\n" + "window.graptos-window.graptos-plugin-manager-window,\n" + "window.graptos-window.graptos-plugin-manager-window > contents,\n" + "window.graptos-window .graptos-plugin-manager-root,\n" + "window.graptos-window .graptos-plugin-manager-scroll,\n" + "window.graptos-window .graptos-plugin-manager-scroll viewport,\n" + "window.graptos-window .graptos-plugin-manager-list,\n" + "window.graptos-window .graptos-plugin-manager-row {\n" + " background: @graptos_dialog_bg;\n" + " background-color: @graptos_dialog_bg;\n" + " color: @graptos_dialog_fg;\n" + " border-radius: 0;\n" + " border: none;\n" + " box-shadow: none;\n" + " outline: none;\n" + "}\n" + "window.graptos-window .graptos-plugin-manager-root label,\n" + "window.graptos-window .graptos-plugin-manager-list label,\n" + "window.graptos-window .graptos-plugin-manager-row label {\n" + " color: @graptos_dialog_fg;\n" + "}\n\n" ".graptos-search-match { background: @graptos_search_match_bg; color: @graptos_search_match_fg; }\n" ".graptos-diagnostic-warning { background: @graptos_diagnostic_warning_bg; color: @graptos_diagnostic_warning_fg; }\n" "/* GRAPTOS THEME END */\n"); @@ -753,6 +865,8 @@ static void populate_config_key_file(EditorWindow *win, GKeyFile *key_file) { if (!win || !key_file) return; save_string(key_file, "theme_css_path", win->theme_css_path ? win->theme_css_path : ""); + save_string(key_file, "latex_command", win->latex_command ? win->latex_command : ""); + save_string(key_file, "latex_arguments", win->latex_arguments ? win->latex_arguments : ""); g_key_file_set_boolean(key_file, "Editor", "autocomplete_enabled", win->autocomplete_enabled); g_key_file_set_boolean(key_file, "Editor", "regex_tester_enabled", win->regex_tester_enabled); g_key_file_set_boolean(key_file, "Editor", "diagnostics_enabled", win->diagnostics_enabled); @@ -773,6 +887,7 @@ static void populate_config_key_file(EditorWindow *win, GKeyFile *key_file) { g_key_file_set_integer(key_file, "Editor", "lsp_sync_max_chars", (gint)win->lsp_sync_max_chars); g_key_file_set_integer(key_file, "Editor", "max_undo_states", (gint)win->max_undo_states); g_key_file_set_integer(key_file, "Editor", "full_highlight_max_chars", (gint)win->full_highlight_max_chars); + save_string(key_file, "custom_highlight_mode", win->custom_highlight_mode ? win->custom_highlight_mode : "auto"); g_key_file_set_integer(key_file, "Editor", "max_undo_capture_bytes", (gint)win->max_undo_capture_bytes); g_key_file_set_integer(key_file, "Editor", "highlight_delay_ms", (gint)win->highlight_delay_ms); g_key_file_set_integer(key_file, "Editor", "completion_delay_ms", (gint)win->completion_delay_ms); @@ -933,6 +1048,12 @@ void graptos_config_load(EditorWindow *win) { load_string(key_file, "preview_font", &win->preview_font); load_string(key_file, "terminal_font", &win->terminal_font); load_string(key_file, "code_font", &win->code_font); + load_string(key_file, "latex_command", &win->latex_command); + load_string(key_file, "latex_arguments", &win->latex_arguments); + if (!win->latex_arguments || win->latex_arguments[0] == '\0') { + replace_config_string(&win->latex_arguments, + "-interaction=nonstopmode -halt-on-error -file-line-error -no-shell-escape -output-directory {output_dir} {source_path}"); + } /** * @brief Resolve the active CSS theme path from new and legacy keys. * @details CSS is now the primary theme source. The old custom_css_path key @@ -978,6 +1099,14 @@ void graptos_config_load(EditorWindow *win) { win->lsp_sync_max_chars = parse_uint(key_file, "lsp_sync_max_chars", win->lsp_sync_max_chars, 1024u, 16u * 1024u * 1024u); win->max_undo_states = parse_uint(key_file, "max_undo_states", win->max_undo_states, 1u, 10000u); win->full_highlight_max_chars = parse_uint(key_file, "full_highlight_max_chars", win->full_highlight_max_chars, 1024u, 16u * 1024u * 1024u); + load_string(key_file, "custom_highlight_mode", &win->custom_highlight_mode); + if (g_strcmp0(win->custom_highlight_mode, "auto") != 0 && + g_strcmp0(win->custom_highlight_mode, "viewport") != 0 && + g_strcmp0(win->custom_highlight_mode, "background") != 0 && + g_strcmp0(win->custom_highlight_mode, "full") != 0) { + g_free(win->custom_highlight_mode); + win->custom_highlight_mode = g_strdup("auto"); + } win->max_undo_capture_bytes = parse_uint(key_file, "max_undo_capture_bytes", win->max_undo_capture_bytes, 1024u, 16u * 1024u * 1024u); win->highlight_delay_ms = parse_uint(key_file, "highlight_delay_ms", win->highlight_delay_ms, 0u, 60000u); win->completion_delay_ms = parse_uint(key_file, "completion_delay_ms", win->completion_delay_ms, 0u, 60000u); diff --git a/src/config.h b/src/config.h index 129214d..5bcdfa6 100644 --- a/src/config.h +++ b/src/config.h @@ -29,6 +29,31 @@ void graptos_config_save(EditorWindow *win); * @return The resolved value for the caller, or NULL when no suitable value is available. */ char *graptos_config_path(void); +/** + * @brief Resolve the configured LaTeX executable. + * @details The window config wins, then the legacy GRAPTOS_LATEX_COMMAND + * environment variable, then auto-detection. + * @param win The window whose config is read. + * @return Owned command, or NULL when no command is available. + */ +char *graptos_latex_resolve_command(EditorWindow *win); +/** + * @brief Build a LaTeX argv from configured arguments. + * @details Arguments are parsed with shell quoting rules but are executed + * directly without a shell. Supported placeholders are {output_dir} + * and {source_path}. + * @param win The window whose config is read. + * @param command Resolved LaTeX executable. + * @param output_dir Output directory placeholder value. + * @param source_path Source path placeholder value. + * @param error Error output for invalid argument syntax. + * @return NULL-terminated argv owned by the caller, or NULL. + */ +char **graptos_latex_build_argv(EditorWindow *win, + const char *command, + const char *output_dir, + const char *source_path, + GError **error); /** * @brief Load theme values from a Graptoς CSS theme file. * @details CSS is the primary theme source. This parser reads the managed diff --git a/src/editor/editor_tab_context_menu.inc.c b/src/editor/editor_tab_context_menu.inc.c index 64de795..ddbd7da 100644 --- a/src/editor/editor_tab_context_menu.inc.c +++ b/src/editor/editor_tab_context_menu.inc.c @@ -78,57 +78,48 @@ context_button(EditorTab *tab, return graptos_flat_button_new(label, NULL, callback, tab); } +/** + * @brief Return whether one widget is under another widget. + * @details Right-click capture is installed on the editor overlay so Graptoς + * sees the event before GtkSourceView. This helper keeps the handler + * limited to clicks that actually landed inside the text view. + * @param widget Widget picked at the click location. + * @param ancestor Expected ancestor widget. + * @return TRUE when @widget is @ancestor or one of its descendants. + */ +static gboolean context_widget_is_descendant(GtkWidget *widget, + GtkWidget *ancestor) { + while (widget) { + if (widget == ancestor) return TRUE; + widget = gtk_widget_get_parent(widget); + } + return FALSE; +} + /** - * on_text_view_right_click: - * @gesture: the click gesture that received the right-button event - * @n_press: the number of presses in the current click sequence - * @x: the horizontal click position relative to the text view - * @y: the vertical click position relative to the text view - * @user_data: the #EditorTab associated with the text view - * - * Opens the editor context menu at the pointer position. - * - * Any previously stored context popover is destroyed before the new one is - * attached. This keeps one active context menu per text view and prevents - * stale popovers from retaining references to editor state. - * @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. - * @param gesture The gesture supplied by the caller. - * @param n_press The n press supplied by the caller. - * @param x The x supplied by the caller. - * @param y The y supplied by the caller. - * @param user_data The callback context passed through GTK signal data. + * @brief Show the editor context menu. + * @details The caller already decided this is Graptoς's secondary-click menu. + * Keeping menu construction separate from the event controller lets us + * stop GtkTextView's built-in context menu before opening ours. + * @param tab Editor tab receiving the menu. + * @param widget Text view owning the menu. + * @param x Horizontal click position. + * @param y Vertical click position. */ -void -on_text_view_right_click(GtkGestureClick *gesture, - int n_press, - double x, - double y, - gpointer user_data) -{ - EditorTab *tab = user_data; - GtkWidget *widget; +static void editor_tab_show_context_menu_at(EditorTab *tab, + GtkWidget *widget, + double x, + double y) { GtkWidget *old_popover; GtkWidget *popover; GtkWidget *box; GdkRectangle rect; guint clicked_line = 0u; - (void)n_press; - - widget = gtk_event_controller_get_widget( - GTK_EVENT_CONTROLLER(gesture)); - if (!tab || !widget) return; - /* - * Claim the sequence so the right-click is handled by Graptoς's context menu - * instead of falling through to other gesture handlers. - */ - gtk_gesture_set_state(GTK_GESTURE(gesture), - GTK_EVENT_SEQUENCE_CLAIMED); - /* * Only one context popover should exist per text view. Destroy the previous * one before opening a new menu at the current pointer position. @@ -266,3 +257,41 @@ on_text_view_right_click(GtkGestureClick *gesture, graptos_popover_show(popover); } + +/** + * @brief Handle a captured text-view right click. + * @details GtkSourceView owns a default context menu. Graptoς claims the click + * sequence at capture phase before opening its own menu, which keeps + * the built-in menu from racing the editor context menu. + * @param gesture Secondary-button gesture installed on the text view. + * @param n_press Number of presses reported by GTK. + * @param x Horizontal click position in text-view coordinates. + * @param y Vertical click position in text-view coordinates. + * @param user_data EditorTab owning the text view. + */ +void on_text_view_right_click(GtkGestureClick *gesture, + int n_press, + double x, + double y, + gpointer user_data) { + (void)n_press; + EditorTab *tab = user_data; + if (!gesture) return; + GtkWidget *widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + if (!widget || !tab || !tab->text_view) return; + GtkWidget *picked = gtk_widget_pick(widget, x, y, GTK_PICK_DEFAULT); + if (!context_widget_is_descendant(picked, tab->text_view)) return; + + double text_x = x; + double text_y = y; + if (widget != tab->text_view) { + graphene_point_t from = GRAPHENE_POINT_INIT((float)x, (float)y); + graphene_point_t to = GRAPHENE_POINT_INIT(0.0f, 0.0f); + if (gtk_widget_compute_point(widget, tab->text_view, &from, &to)) { + text_x = to.x; + text_y = to.y; + } + } + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); + editor_tab_show_context_menu_at(tab, tab->text_view, text_x, text_y); +} diff --git a/src/editor/editor_tab_highlight_scheduling.inc.c b/src/editor/editor_tab_highlight_scheduling.inc.c index 9b4bad8..70c3bc5 100644 --- a/src/editor/editor_tab_highlight_scheduling.inc.c +++ b/src/editor/editor_tab_highlight_scheduling.inc.c @@ -7,16 +7,23 @@ * @param tab The editor tab whose buffer or widgets are being inspected. */ +/** + * @brief Return whether YAML regex highlighting should run. + * @details GtkSourceView owns highlighting when it has a real language for the + * active syntax. Graptoς YAML rules are still needed for custom + * syntaxes such as Beancount that do not have a GtkSourceView language. + * @param tab The editor tab whose highlight engine is being inspected. + * @return TRUE when Graptoς should apply YAML regex tags. + */ void editor_tab_apply_highlight(EditorTab *tab) { if (!tab || !tab->buffer) return; - /* Graptoς no longer applies regex YAML highlighting to the editor buffer. - * Highlighting is owned by GtkSourceView; YAML rules only generate optional - * GtkSourceView style-scheme overrides when the user enables them. - */ + if (editor_tab_custom_highlight_needed(tab)) { + editor_tab_apply_custom_highlight(tab); + return; + } editor_tab_update_highlight_engine(tab); } - /** * @brief Minimap timeout cb. * @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. @@ -123,10 +130,21 @@ gboolean highlight_timeout_cb(gpointer user_data) { */ void editor_tab_schedule_highlight(EditorTab *tab) { if (!tab) return; - /* GtkSourceView performs syntax highlighting internally. There is no - * Graptoς regex highlighter to schedule; keep this as a redraw compatibility - * hook for undo/load paths that still call it. + /* GtkSourceView updates internally for supported languages. Custom YAML + * syntaxes without a GtkSourceView language need the delayed Graptoς regex + * fallback so edits recolor after the user pauses. */ + if (editor_tab_custom_highlight_needed(tab)) { + graptos_source_cancel(&tab->highlight_timeout); + tab->highlight_timeout = g_timeout_add_full(G_PRIORITY_LOW, + tab->win && tab->win->highlight_delay_ms > 0u + ? tab->win->highlight_delay_ms + : GRAPTOS_HIGHLIGHT_DELAY_MS, + highlight_timeout_cb, + tab, + NULL); + return; + } if (tab->text_view) gtk_widget_queue_draw(tab->text_view); if (tab->minimap_view) gtk_widget_queue_draw(tab->minimap_view); } diff --git a/src/editor/editor_tab_hover_events.inc.c b/src/editor/editor_tab_hover_events.inc.c index fd3ffbc..2c23c9d 100644 --- a/src/editor/editor_tab_hover_events.inc.c +++ b/src/editor/editor_tab_hover_events.inc.c @@ -253,6 +253,10 @@ static gboolean hover_render_index_references(EditorTab *tab, * @param locations The locations supplied by the caller. * @param user_data The callback context passed through GTK signal data. */ + +static gboolean editor_tab_show_plugin_hover(EditorTab *tab, + const char *word, + GtkTextIter *iter); static void hover_lsp_references_cb(EditorTab *tab, GPtrArray *locations, gpointer user_data) { @@ -360,6 +364,10 @@ gboolean hover_timeout_cb(gpointer user_data) { return G_SOURCE_REMOVE; } + if (editor_tab_show_plugin_hover(tab, tab->hover_word, &cursor)) { + return G_SOURCE_REMOVE; + } + if (tab->win && tab->win->lsp_client && tab->file_path && tab->hover_lsp_position_valid) { tab->hover_lsp_serial++; @@ -689,6 +697,79 @@ static GtkWidget *diagnostic_hover_row_new(const char *message) { return row; } +/** + * @brief Create a plugin hover row. + * @details Plugin hover rows are informational text, so they are not + * activatable reference rows. + * @param label Provider label shown as the heading. + * @param body Hover body returned by the plugin. + * @return Newly created list row. + */ +static GtkWidget *plugin_hover_row_new(const char *label, const char *body) { + GtkWidget *row = gtk_list_box_row_new(); + gtk_widget_set_sensitive(row, FALSE); + + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_margin_start(box, 10); + gtk_widget_set_margin_end(box, 10); + gtk_widget_set_margin_top(box, 8); + gtk_widget_set_margin_bottom(box, 10); + + GtkWidget *title = gtk_label_new(label && label[0] ? label : "Plugin"); + gtk_label_set_xalign(GTK_LABEL(title), 0.0f); + gtk_widget_add_css_class(title, "graptos-ref-heading"); + + GtkWidget *text = gtk_label_new(body ? body : ""); + gtk_label_set_xalign(GTK_LABEL(text), 0.0f); + gtk_label_set_wrap(GTK_LABEL(text), TRUE); + gtk_widget_add_css_class(text, "graptos-ref-snippet"); + + gtk_box_append(GTK_BOX(box), title); + gtk_box_append(GTK_BOX(box), text); + gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); + return row; +} + +/** + * @brief Show plugin hover text for a word. + * @details Native plugins get the first chance to provide language-specific + * hover information before the generic reference lookup runs. + * @param tab Editor tab owning the hover popover. + * @param word Token under the pointer. + * @param iter Iterator used to place the hover. + * @return TRUE when a plugin hover was shown. + */ +static gboolean editor_tab_show_plugin_hover(EditorTab *tab, + const char *word, + GtkTextIter *iter) { + if (!tab || !tab->win || !tab->win->plugins || !word || !iter || + !tab->hover_popover || !tab->hover_list) { + return FALSE; + } + g_autofree char *label = NULL; + char *body = graptos_plugin_registry_hover_text(tab->win->plugins, + tab, + word, + &label); + if (!body) return FALSE; + + hover_clear_rows(tab); + cancel_hover_hide(tab); + tab->color_preview_valid = FALSE; + tab->regex_tester_active = FALSE; + tab->hover_pinned = FALSE; + tab->hover_popover_locked = TRUE; + + gtk_list_box_insert(GTK_LIST_BOX(tab->hover_list), + plugin_hover_row_new(label, body), + -1); + g_free(body); + + if (!place_hover_popover_at_iter(tab, iter, tab->hover_popover)) return FALSE; + graptos_popover_show(tab->hover_popover); + return TRUE; +} + /** * @brief Show a diagnostic hover at a text iterator. * @details Diagnostic hovers share the same hover list as references, so this diff --git a/src/editor/editor_tab_hover_refs.inc.c b/src/editor/editor_tab_hover_refs.inc.c index 7742c27..a09699e 100644 --- a/src/editor/editor_tab_hover_refs.inc.c +++ b/src/editor/editor_tab_hover_refs.inc.c @@ -191,14 +191,20 @@ char *word_at_iter(GtkTextBuffer *buffer, GtkTextIter *iter) { if (!gtk_text_iter_backward_char(&prev)) break; gunichar ch = gtk_text_iter_get_char(&prev); - if (!completion_is_word_char(ch) && ch != '#') break; + if (!completion_is_word_char(ch) && ch != '#' && ch != ':' && + ch != '-') { + break; + } start = prev; } while (!gtk_text_iter_ends_line(&end) && !gtk_text_iter_is_end(&end)) { gunichar ch = gtk_text_iter_get_char(&end); - if (!completion_is_word_char(ch) && ch != '#') break; + if (!completion_is_word_char(ch) && ch != '#' && ch != ':' && + ch != '-') { + break; + } if (!gtk_text_iter_forward_char(&end)) break; } diff --git a/src/editor/editor_tab_lifecycle.inc.c b/src/editor/editor_tab_lifecycle.inc.c index f584769..108f8a5 100644 --- a/src/editor/editor_tab_lifecycle.inc.c +++ b/src/editor/editor_tab_lifecycle.inc.c @@ -168,6 +168,7 @@ void editor_tab_cancel_live_work(EditorTab *tab) { cancel_timeout_id(&tab->selection_match_timeout); cancel_timeout_id(&tab->color_literal_timeout); cancel_timeout_id(&tab->regex_tester_timeout); + cancel_timeout_id(&tab->custom_highlight_background_timeout); cancel_timeout_id(&tab->lsp_change_timeout); cancel_timeout_id(&tab->lsp_completion_retry_timeout); cancel_timeout_id(&tab->preview_reparent_idle); @@ -465,11 +466,14 @@ void editor_tab_free(EditorTab *tab) { graptos_source_cancel(&tab->hover_timeout); graptos_source_cancel(&tab->hover_hide_timeout); graptos_source_cancel(&tab->hover_lsp_fallback_timeout); + graptos_source_cancel(&tab->custom_highlight_background_timeout); + graptos_source_cancel(&tab->disk_change_timeout); graptos_source_cancel(&tab->ui_refresh_timeout); if (tab->buffer) { g_signal_handlers_disconnect_by_data(tab->buffer, tab); } + editor_tab_clear_file_monitor(tab); editor_tab_preview_close_detached(tab); editor_tab_destroy_popovers(tab); if (tab->win && tab->win->lsp_client) lsp_client_document_closed(tab->win->lsp_client, tab); @@ -485,6 +489,8 @@ void editor_tab_free(EditorTab *tab) { g_free(tab->lsp_completion_retry_key); g_free(tab->hover_word); g_free(tab->last_typing_debug_key); + g_free(tab->plugin_preview_title); + g_free(tab->plugin_preview_body); if (tab->undo_stack) g_ptr_array_free(tab->undo_stack, TRUE); if (tab->redo_stack) g_ptr_array_free(tab->redo_stack, TRUE); diff --git a/src/editor/editor_tab_signals_preferences.inc.c b/src/editor/editor_tab_signals_preferences.inc.c index 230ebcc..cbde9d7 100644 --- a/src/editor/editor_tab_signals_preferences.inc.c +++ b/src/editor/editor_tab_signals_preferences.inc.c @@ -259,7 +259,10 @@ void on_buffer_changed(GtkTextBuffer *buffer, gpointer user_data) { editor_tab_schedule_lightweight_ui_refresh(tab); if (debug_typing) ui_schedule_ms = typing_elapsed_ms(part_start_us, g_get_monotonic_time()); part_start_us = debug_typing ? g_get_monotonic_time() : 0; - if (!tab->manual_syntax_override && !tab->file_path) editor_tab_auto_select_syntax(tab); + if (!tab->manual_syntax_override && !tab->file_path) { + editor_tab_auto_select_syntax(tab); + editor_tab_schedule_highlight(tab); + } if (debug_typing) syntax_schedule_ms = typing_elapsed_ms(part_start_us, g_get_monotonic_time()); if (debug_typing) { schedule_ms = typing_elapsed_ms(schedule_start_us, g_get_monotonic_time()); @@ -327,7 +330,10 @@ void on_buffer_changed(GtkTextBuffer *buffer, gpointer user_data) { editor_tab_schedule_syntax_diagnostics(tab); if (debug_typing) diagnostics_schedule_ms = typing_elapsed_ms(part_start_us, g_get_monotonic_time()); part_start_us = debug_typing ? g_get_monotonic_time() : 0; - if (!tab->manual_syntax_override && !tab->file_path) editor_tab_auto_select_syntax(tab); + if (!tab->manual_syntax_override && !tab->file_path) { + editor_tab_auto_select_syntax(tab); + } + editor_tab_schedule_highlight(tab); if (debug_typing) syntax_schedule_ms = typing_elapsed_ms(part_start_us, g_get_monotonic_time()); part_start_us = debug_typing ? g_get_monotonic_time() : 0; editor_tab_schedule_completion(tab); diff --git a/src/editor_tab.h b/src/editor_tab.h index 93b7cb2..2ff455c 100644 --- a/src/editor_tab.h +++ b/src/editor_tab.h @@ -66,6 +66,7 @@ struct _EditorTab { GtkWidget *preview_scrolled; /**< Preview scrolled. */ GtkWidget *preview_view; /**< Preview view. */ GtkTextBuffer *preview_buffer; /**< Preview buffer. */ + GFileMonitor *file_monitor; /**< Monitor for external file changes. */ GtkWidget *tab_widget; /**< Tab widget. */ GtkWidget *tab_lock_icon; /**< Tab lock icon. */ GtkWidget *tab_title; /**< Tab title. */ @@ -80,6 +81,8 @@ struct _EditorTab { char *lsp_completion_retry_key; /**< Active LSP completion retry key. */ char *hover_word; /**< Hover word. */ char *last_typing_debug_key; /**< Last key name shown in typing debug logs. */ + char *plugin_preview_title; /**< Plugin-owned preview title. */ + char *plugin_preview_body; /**< Plugin-owned preview body. */ SyntaxDef *active_syntax; /**< Active syntax. */ GPtrArray *undo_stack; /**< char*. */ @@ -103,6 +106,11 @@ struct _EditorTab { guint hover_timeout; /**< Hover timeout. */ guint hover_hide_timeout; /**< Hover hide timeout. */ guint hover_lsp_fallback_timeout; /**< Hover LSP fallback timeout. */ + guint custom_highlight_background_timeout; /**< Deferred background custom highlight source. */ + guint custom_highlight_start_line; /**< First custom-highlighted line. */ + guint custom_highlight_end_line; /**< Last custom-highlighted line. */ + guint custom_highlight_background_line; /**< Next line for background custom highlighting. */ + guint disk_change_timeout; /**< Debounced external disk change prompt source. */ guint ui_refresh_timeout; /**< Ui refresh timeout. */ guint note_autocomplete_timeout; /**< Deferred note autocomplete timeout. */ guint tab_width; /**< Tab width. */ @@ -134,6 +142,9 @@ struct _EditorTab { gboolean minimap_updating; /**< Minimap updating. */ gboolean preview_updating; /**< Preview updating. */ gboolean preview_detached; /**< TRUE while the preview container lives in its own window. */ + gboolean plugin_preview_active; /**< TRUE while a plugin owns preview text. */ + gboolean disk_change_prompt_visible; /**< TRUE while external-change dialog is open. */ + gboolean disk_change_pending; /**< TRUE when a changed-on-disk event is pending. */ gboolean locked; /**< Locked. */ gboolean folded_tile_member; /**< TRUE when this tab is owned by a visible tile group host. */ gboolean notebook_refs_held; /**< TRUE when folded widgets are reffed outside the notebook. */ @@ -144,6 +155,7 @@ struct _EditorTab { gboolean color_literals_active; /**< Color literals active. */ gboolean diagnostics_active; /**< Diagnostics active. */ gboolean custom_highlight_active; /**< Custom highlight active. */ + gboolean custom_highlight_range_valid; /**< TRUE when custom highlight range is tracked. */ gboolean low_latency_mode_active; /**< Low latency mode active. */ }; diff --git a/src/editor_tab_build.c b/src/editor_tab_build.c index 0d1f611..4fbd728 100644 --- a/src/editor_tab_build.c +++ b/src/editor_tab_build.c @@ -8,6 +8,8 @@ #include "editor_tab_private.h" +static GtkWidget *tab_symbolic_icon(const char *primary, const char **fallbacks); + /** * @brief Tab init state. * @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. @@ -125,7 +127,9 @@ static void tab_create_preview(EditorTab *tab, EditorWindow *win) { gtk_widget_set_hexpand(header, TRUE); GtkWidget *spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_set_hexpand(spacer, TRUE); - tab->preview_detach_button = gtk_button_new_from_icon_name("window-new-symbolic"); + tab->preview_detach_button = gtk_button_new(); + gtk_button_set_child(GTK_BUTTON(tab->preview_detach_button), + tab_symbolic_icon("window-new-symbolic", NULL)); gtk_widget_add_css_class(tab->preview_detach_button, "graptos-flat-button"); gtk_widget_add_css_class(tab->preview_detach_button, "graptos-preview-detach-button"); gtk_widget_set_tooltip_text(tab->preview_detach_button, @@ -544,14 +548,16 @@ static void tab_connect_signals(EditorTab *tab) { g_signal_connect(motion, "leave", G_CALLBACK(on_text_view_leave), tab); gtk_widget_add_controller(tab->text_view, motion); - GtkGesture *right_click = gtk_gesture_click_new(); - gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(right_click), + GtkGesture *context = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(context), GDK_BUTTON_SECONDARY); - gtk_event_controller_set_propagation_phase( - GTK_EVENT_CONTROLLER(right_click), GTK_PHASE_CAPTURE); - g_signal_connect(right_click, "pressed", + gtk_gesture_single_set_exclusive(GTK_GESTURE_SINGLE(context), TRUE); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(context), + GTK_PHASE_CAPTURE); + g_signal_connect(context, "pressed", G_CALLBACK(on_text_view_right_click), tab); - gtk_widget_add_controller(tab->text_view, GTK_EVENT_CONTROLLER(right_click)); + gtk_widget_add_controller(tab->popover_parent, + GTK_EVENT_CONTROLLER(context)); GtkGesture *minimap_click = gtk_gesture_click_new(); gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(minimap_click), diff --git a/src/editor_tab_gutter.c b/src/editor_tab_gutter.c index e1dce6c..182d527 100644 --- a/src/editor_tab_gutter.c +++ b/src/editor_tab_gutter.c @@ -279,6 +279,7 @@ void on_vadjustment_value_changed(GtkAdjustment *adjustment, gpointer user_data) if (tab) { sync_minimap_scroll(tab, adjustment); if (tab->minimap_view) gtk_widget_queue_draw(tab->minimap_view); + editor_tab_schedule_highlight(tab); editor_tab_reposition_visible_cursor_popovers(tab); } } diff --git a/src/editor_tab_io.c b/src/editor_tab_io.c index d8a34e7..5175158 100644 --- a/src/editor_tab_io.c +++ b/src/editor_tab_io.c @@ -12,6 +12,14 @@ #include #include +/** + * @brief Disk change debounce delay. + */ +#define DISK_CHANGE_DEBOUNCE_MS 250u + +static void editor_tab_update_file_monitor(EditorTab *tab); +static gboolean editor_tab_disk_change_timeout_cb(gpointer user_data); + /** * @brief Write all fd. * @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. @@ -160,6 +168,200 @@ static gboolean editor_tab_file_matches_buffer(EditorTab *tab, return disk_len == text_len && memcmp(disk, text, text_len) == 0; } +/** + * @brief Save the current buffer to a separate path. + * @details Save Copy must not move the tab to the copy path or mark the + * original external change as resolved. It only preserves the current + * editor buffer before the user chooses what to do with the changed + * source file. + * @param tab The editor tab whose buffer should be written. + * @return TRUE when a copy was written. + */ +static gboolean editor_tab_save_copy(EditorTab *tab) { + if (!tab) return FALSE; + g_autofree char *path = graptos_save_file_dialog(app_window_gtk(tab->win), + "Save Copy"); + if (!path) return FALSE; + g_autofree char *text = buffer_text(tab); + g_autoptr(GError) error = NULL; + if (!write_text_atomic(path, text, &error)) { + app_window_report_error(tab->win, + "Could not save copy", + error ? error->message : path, + TRUE); + return FALSE; + } + if (tab->win) { + g_autofree char *msg = g_strdup_printf("Saved copy to %s", path); + app_window_set_status(tab->win, msg); + } + return TRUE; +} + +/** + * @brief Reload the current tab from disk. + * @details Reloading is a user-selected conflict resolution path. The normal + * loader owns buffer replacement, syntax refresh, title state, LSP, + * Git, and monitor reset. + * @param tab The editor tab to reload. + * @return TRUE when the tab was reloaded. + */ +static gboolean editor_tab_reload_from_disk(EditorTab *tab) { + if (!tab || !tab->file_path) return FALSE; + g_autofree char *path = g_strdup(tab->file_path); + return editor_tab_load_file(tab, path); +} + +/** + * @brief Show the changed-on-disk conflict dialog. + * @param tab The editor tab whose file changed externally. + */ +static void editor_tab_prompt_disk_change(EditorTab *tab) { + if (!tab || !tab->file_path || tab->disk_change_prompt_visible) return; + tab->disk_change_prompt_visible = TRUE; + g_autofree char *base = editor_tab_basename(tab); + g_autofree char *heading = g_strdup_printf("%s changed on disk", base); + g_autofree char *body = g_strdup_printf( + "The file was changed by another process.\n\n" + "Path: %s\n\n" + "Reload replaces the editor buffer with the disk version. " + "Save Copy writes the current editor buffer to a separate file. " + "Keep leaves the editor buffer unchanged.", + tab->file_path); + GraptosDialogAction actions[] = { + { "Keep", "Keep the editor buffer as-is", GTK_RESPONSE_REJECT, FALSE, TRUE }, + { "Save Copy", "Save the current editor buffer to another file", GTK_RESPONSE_APPLY, FALSE, FALSE }, + { "Reload", "Reload the file from disk", GTK_RESPONSE_ACCEPT, TRUE, FALSE }, + }; + int response = dialog_run_message(app_window_gtk(tab->win), + "File Changed On Disk", + heading, + body, + 560, + 230, + actions, + G_N_ELEMENTS(actions), + GTK_RESPONSE_REJECT); + tab->disk_change_prompt_visible = FALSE; + tab->disk_change_pending = FALSE; + if (response == GTK_RESPONSE_ACCEPT) { + (void)editor_tab_reload_from_disk(tab); + } else if (response == GTK_RESPONSE_APPLY) { + (void)editor_tab_save_copy(tab); + } else if (tab->win) { + app_window_set_status(tab->win, "Kept editor buffer after disk change."); + } +} + +/** + * @brief Handle a settled external disk change event. + * @param user_data Editor tab. + * @return G_SOURCE_REMOVE after handling. + */ +static gboolean editor_tab_disk_change_timeout_cb(gpointer user_data) { + EditorTab *tab = user_data; + if (!tab) return G_SOURCE_REMOVE; + tab->disk_change_timeout = 0u; + if (!tab->file_path || !tab->disk_change_pending || tab->applying_change) { + return G_SOURCE_REMOVE; + } + if (!g_file_test(tab->file_path, G_FILE_TEST_EXISTS)) { + editor_tab_prompt_disk_change(tab); + return G_SOURCE_REMOVE; + } + if (editor_tab_file_matches_buffer(tab, tab->file_path)) { + tab->disk_change_pending = FALSE; + return G_SOURCE_REMOVE; + } + editor_tab_prompt_disk_change(tab); + return G_SOURCE_REMOVE; +} + +/** + * @brief Schedule changed-on-disk handling. + * @param tab The editor tab whose monitor emitted. + */ +static void editor_tab_schedule_disk_change_prompt(EditorTab *tab) { + if (!tab || tab->disposing || tab->applying_change) return; + tab->disk_change_pending = TRUE; + graptos_source_cancel(&tab->disk_change_timeout); + tab->disk_change_timeout = g_timeout_add_full(G_PRIORITY_DEFAULT, + DISK_CHANGE_DEBOUNCE_MS, + editor_tab_disk_change_timeout_cb, + tab, + NULL); +} + +/** + * @brief File monitor callback for editor tabs. + * @param monitor Monitor that emitted the event. + * @param file Changed file. + * @param other_file Secondary file for move events. + * @param event_type File monitor event type. + * @param user_data Editor tab. + */ +static void editor_tab_file_monitor_changed(GFileMonitor *monitor, + GFile *file, + GFile *other_file, + GFileMonitorEvent event_type, + gpointer user_data) { + (void)monitor; + (void)file; + (void)other_file; + EditorTab *tab = user_data; + if (!tab || !tab->file_path) return; + switch (event_type) { + case G_FILE_MONITOR_EVENT_CHANGES_DONE_HINT: + case G_FILE_MONITOR_EVENT_CREATED: + case G_FILE_MONITOR_EVENT_DELETED: + case G_FILE_MONITOR_EVENT_MOVED_IN: + case G_FILE_MONITOR_EVENT_MOVED_OUT: + case G_FILE_MONITOR_EVENT_RENAMED: + editor_tab_schedule_disk_change_prompt(tab); + break; + default: + break; + } +} + +/** + * @brief Stop monitoring the current editor file. + * @param tab The editor tab whose monitor should be cleared. + */ +void editor_tab_clear_file_monitor(EditorTab *tab) { + if (!tab) return; + graptos_source_cancel(&tab->disk_change_timeout); + tab->disk_change_pending = FALSE; + if (!tab->file_monitor) return; + g_signal_handlers_disconnect_by_data(tab->file_monitor, tab); + g_file_monitor_cancel(tab->file_monitor); + g_object_unref(tab->file_monitor); + tab->file_monitor = NULL; +} + +/** + * @brief Refresh the editor file monitor for the current path. + * @details The monitor follows the tab path after load, save, or Save As. + * Unsaved tabs are not monitored. + * @param tab The editor tab whose path should be watched. + */ +static void editor_tab_update_file_monitor(EditorTab *tab) { + if (!tab) return; + editor_tab_clear_file_monitor(tab); + if (!tab->file_path || !tab->file_path[0]) return; + g_autoptr(GFile) file = g_file_new_for_path(tab->file_path); + g_autoptr(GError) error = NULL; + tab->file_monitor = g_file_monitor_file(file, + G_FILE_MONITOR_NONE, + NULL, + &error); + if (!tab->file_monitor) return; + g_signal_connect(tab->file_monitor, + "changed", + G_CALLBACK(editor_tab_file_monitor_changed), + tab); +} + /** * @brief Editor tab load file. * @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. @@ -197,10 +399,15 @@ gboolean editor_tab_load_file(EditorTab *tab, const char *path) { tab->applying_change = TRUE; gtk_text_buffer_set_text(tab->buffer, contents, (gint)length); tab->applying_change = FALSE; + editor_tab_clear_file_monitor(tab); g_free(tab->file_path); g_clear_pointer(&tab->display_title, g_free); g_clear_pointer(&tab->display_title_markup, g_free); + g_clear_pointer(&tab->plugin_preview_title, g_free); + g_clear_pointer(&tab->plugin_preview_body, g_free); + tab->plugin_preview_active = FALSE; tab->file_path = g_canonicalize_filename(path, NULL); + editor_tab_update_file_monitor(tab); editor_tab_load_notes(tab); cleanup_legacy_tilde_backup(tab->file_path); tab->modified = FALSE; @@ -211,6 +418,7 @@ gboolean editor_tab_load_file(EditorTab *tab, const char *path) { update_gutter_width(tab); update_minimap_text(tab); editor_tab_auto_select_syntax(tab); + editor_tab_update_highlight_engine(tab); if (tab->win && tab->win->lsp_client) lsp_client_document_opened(tab->win->lsp_client, tab); editor_tab_schedule_color_literals(tab); editor_tab_update_title(tab); @@ -298,10 +506,12 @@ gboolean save_to_path(EditorTab *tab, const char *path) { error ? error->message : stable_path, TRUE); return FALSE; } + editor_tab_clear_file_monitor(tab); g_free(tab->file_path); g_clear_pointer(&tab->display_title, g_free); g_clear_pointer(&tab->display_title_markup, g_free); tab->file_path = g_canonicalize_filename(stable_path, NULL); + editor_tab_update_file_monitor(tab); editor_tab_load_notes(tab); cleanup_legacy_tilde_backup(tab->file_path); tab->modified = FALSE; @@ -309,6 +519,7 @@ gboolean save_to_path(EditorTab *tab, const char *path) { tab->low_latency_mode_active = FALSE; reset_undo_state(tab); editor_tab_auto_select_syntax(tab); + editor_tab_update_highlight_engine(tab); if (tab->win && tab->win->lsp_client) { lsp_client_document_opened(tab->win->lsp_client, tab); lsp_client_document_saved(tab->win->lsp_client, tab); diff --git a/src/editor_tab_keys.c b/src/editor_tab_keys.c index b99149c..6b2e8e1 100644 --- a/src/editor_tab_keys.c +++ b/src/editor_tab_keys.c @@ -744,6 +744,17 @@ gboolean on_text_view_key_pressed(GtkEventControllerKey *controller, if (debug_typing) g_message("Typing: key-handled action=path-completion"); return TRUE; } + if (ctrl && alt && key == GDK_KEY_b) { + gboolean handled = tab->win && tab->win->plugins + ? graptos_plugin_registry_run_shortcut(tab->win->plugins, + tab, + "Ctrl+Alt+B", + 0u) + : FALSE; + if (!handled) editor_tab_show_completion(tab, TRUE); + if (debug_typing) g_message("Typing: key-handled action=beancount-completion"); + return TRUE; + } if (alt || keyval == GDK_KEY_Alt_L || keyval == GDK_KEY_Alt_R) { tab->inspect_reference_active = TRUE; diff --git a/src/editor_tab_latex.c b/src/editor_tab_latex.c index 3763aa5..a1b9e05 100644 --- a/src/editor_tab_latex.c +++ b/src/editor_tab_latex.c @@ -7,6 +7,7 @@ */ #include "editor_tab_private.h" +#include "config.h" #include #include @@ -16,47 +17,6 @@ */ #define LATEX_BUILD_DIR ".graptos-latex-build" -/** - * @brief Has space. - * @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. - * @param text The text fragment supplied by the caller. - * @return TRUE when the condition is satisfied; otherwise FALSE. - */ -static gboolean has_space(const char *text) { - const unsigned char *p = (const unsigned char *)text; - while (p && *p != '\0') { - if (g_ascii_isspace(*p)) return TRUE; - p++; - } - return FALSE; -} - -/** - * @brief Find latex command. - * @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. - * @return The resolved value for the caller, or NULL when no suitable value is available. - */ -static char *find_latex_command(void) { - const char *env = g_getenv("GRAPTOS_LATEX_COMMAND"); - if (env && env[0] != '\0' && !has_space(env)) return g_strdup(env); - - static const char *commands[] = { - "pdflatex", - "xelatex", - "lualatex", - NULL - }; - - for (guint i = 0u; commands[i] != NULL; i++) { - char *path = g_find_program_in_path(commands[i]); - if (path) { - g_free(path); - return g_strdup(commands[i]); - } - } - return NULL; -} - /** * @brief Basename without suffix. * @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. @@ -177,17 +137,17 @@ static gboolean run_latex(EditorTab *tab, g_autoptr(GError) error = NULL; int status = 0; - char *argv[] = { - (char *)command, - "-interaction=nonstopmode", - "-halt-on-error", - "-file-line-error", - "-no-shell-escape", - "-output-directory", - (char *)output_dir, - (char *)source_path, - NULL - }; + g_auto(GStrv) argv = graptos_latex_build_argv(tab ? tab->win : NULL, + command, + output_dir, + source_path, + &error); + if (!argv) { + app_window_set_error_status(tab->win, + "LaTeX arguments invalid", + error ? error->message : "Could not parse latex_arguments."); + return FALSE; + } gboolean ok = g_spawn_sync(working_dir, argv, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, &stdout_text, &stderr_text, @@ -239,11 +199,11 @@ void editor_tab_render_latex(EditorTab *tab) { return; } - g_autofree char *command = find_latex_command(); + g_autofree char *command = graptos_latex_resolve_command(tab->win); if (!command) { app_window_set_error_status(tab->win, "LaTeX command not found", "Install pdflatex, xelatex, or lualatex, or set " - "GRAPTOS_LATEX_COMMAND to the command path."); + "latex_command in config.ini."); return; } diff --git a/src/editor_tab_preview.c b/src/editor_tab_preview.c index 5f0b27f..d5ca414 100644 --- a/src/editor_tab_preview.c +++ b/src/editor_tab_preview.c @@ -318,7 +318,9 @@ gboolean editor_tab_is_latex(EditorTab *tab) { * @return TRUE when the condition is satisfied; otherwise FALSE. */ gboolean preview_is_supported(EditorTab *tab) { - return preview_is_markdown(tab) || editor_tab_is_latex(tab); + return (tab && tab->plugin_preview_active) || + preview_is_markdown(tab) || + editor_tab_is_latex(tab); } /** @@ -1057,8 +1059,22 @@ void editor_tab_update_preview(EditorTab *tab) { tab->preview_updating = TRUE; gtk_text_buffer_set_text(tab->preview_buffer, "", 0); preview_ensure_tags(tab); - if (preview_is_markdown(tab)) preview_render_markdown(tab, text); - else if (editor_tab_is_latex(tab)) preview_render_latex(tab, text); + if (tab->plugin_preview_active) { + GString *report = g_string_new(NULL); + if (tab->plugin_preview_title && tab->plugin_preview_title[0]) { + g_string_append_printf(report, "%s\n\n", tab->plugin_preview_title); + } + g_string_append(report, + tab->plugin_preview_body && tab->plugin_preview_body[0] + ? tab->plugin_preview_body + : "No plugin preview."); + gtk_text_buffer_set_text(tab->preview_buffer, report->str, -1); + g_string_free(report, TRUE); + } else if (preview_is_markdown(tab)) { + preview_render_markdown(tab, text); + } else if (editor_tab_is_latex(tab)) { + preview_render_latex(tab, text); + } tab->preview_updating = FALSE; preview_set_container_visible(tab, TRUE); diff --git a/src/editor_tab_private.h b/src/editor_tab_private.h index 177cbec..15e29af 100644 --- a/src/editor_tab_private.h +++ b/src/editor_tab_private.h @@ -126,6 +126,13 @@ gboolean write_all_fd(int fd, const char *data, gsize len); * @return TRUE when the condition is satisfied; otherwise FALSE. */ gboolean write_text_atomic(const char *path, const char *text, GError **error); +/** + * @brief Clear the editor file monitor. + * @details Tab teardown and path replacement must disconnect monitor signals + * before releasing tab state. + * @param tab The editor tab whose monitor should be removed. + */ +void editor_tab_clear_file_monitor(EditorTab *tab); /** * @brief Autosave path for tab. * @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. @@ -204,6 +211,17 @@ gboolean editor_tab_lsp_sync_allowed(EditorTab *tab); * @return TRUE when the condition is satisfied; otherwise FALSE. */ gboolean editor_tab_highlighting_allowed(EditorTab *tab); +/** + * @brief Return whether custom YAML highlighting owns this tab. + * @param tab The editor tab whose highlighting engine is being inspected. + * @return TRUE when Graptoς regex highlighting should run. + */ +gboolean editor_tab_custom_highlight_needed(EditorTab *tab); +/** + * @brief Apply custom YAML highlighting without resetting the source engine. + * @param tab The editor tab whose visible range should be highlighted. + */ +void editor_tab_apply_custom_highlight(EditorTab *tab); /** * @brief Editor tab reference features allowed. * @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. @@ -503,13 +521,13 @@ void editor_tab_schedule_regex_tester(EditorTab *tab); */ void editor_tab_show_reference_at_pointer_or_cursor(EditorTab *tab); /** - * @brief On text view right click. - * @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. - * @param gesture The gesture supplied by the caller. - * @param n_press The n press supplied by the caller. - * @param x The x supplied by the caller. - * @param y The y supplied by the caller. + * @brief On text view context event. + * @details This capture-phase handler opens Graptoς's context menu and stops + * GtkTextView's default secondary-click menu from also appearing. + * @param controller Event controller installed on the editor text view. + * @param event GDK event being inspected. * @param user_data The callback context passed through GTK signal data. + * @return GDK_EVENT_STOP when Graptoς handled the event. */ void on_text_view_right_click(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data); /** diff --git a/src/editor_tab_sourceview.c b/src/editor_tab_sourceview.c index b249b70..10d4218 100644 --- a/src/editor_tab_sourceview.c +++ b/src/editor_tab_sourceview.c @@ -8,6 +8,16 @@ #include "editor_tab_private.h" +/** + * @brief Background custom highlight chunk size. + */ +#define CUSTOM_HIGHLIGHT_BACKGROUND_LINES 160u + +/** + * @brief Background custom highlight delay. + */ +#define CUSTOM_HIGHLIGHT_BACKGROUND_DELAY_MS 20u + /** * @brief Source language id for syntax. * @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. @@ -50,18 +60,209 @@ static GtkSourceLanguage *source_language_for_tab(EditorTab *tab) { if (!manager || !tab) return NULL; GtkSourceLanguage *language = NULL; + const char *id = source_language_id_for_syntax(tab->active_syntax); + if (tab->active_syntax) { + return id ? gtk_source_language_manager_get_language(manager, id) : NULL; + } if (tab->file_path && tab->file_path[0] != '\0') { language = gtk_source_language_manager_guess_language(manager, tab->file_path, NULL); } if (!language) { - const char *id = source_language_id_for_syntax(tab->active_syntax); if (id) language = gtk_source_language_manager_get_language(manager, id); } return language; } +/** + * @brief Return the effective custom highlight mode. + * @param tab The editor tab whose window config is being inspected. + * @return Stable mode text. + */ +static const char *custom_highlight_mode(EditorTab *tab) { + const char *mode = tab && tab->win ? tab->win->custom_highlight_mode : NULL; + if (g_strcmp0(mode, "viewport") == 0 || + g_strcmp0(mode, "background") == 0 || + g_strcmp0(mode, "full") == 0) { + return mode; + } + return "auto"; +} + +/** + * @brief Return whether the current custom highlighter should use a viewport. + * @param tab The editor tab whose buffer is being inspected. + * @return TRUE when full-buffer regex highlighting should be avoided. + */ +static gboolean custom_highlight_uses_viewport(EditorTab *tab) { + const char *mode = custom_highlight_mode(tab); + if (g_strcmp0(mode, "viewport") == 0 || + g_strcmp0(mode, "background") == 0) { + return TRUE; + } + if (g_strcmp0(mode, "full") == 0) return FALSE; + guint max_chars = tab && tab->win && tab->win->full_highlight_max_chars > 0u + ? tab->win->full_highlight_max_chars + : GRAPTOS_FULL_HIGHLIGHT_MAX_CHARS; + return tab && tab->buffer && + (guint)gtk_text_buffer_get_char_count(tab->buffer) > max_chars; +} + +/** + * @brief Return whether background custom highlighting is enabled. + * @param tab The editor tab whose window config is being inspected. + * @return TRUE when offscreen ranges may be highlighted slowly. + */ +static gboolean custom_highlight_uses_background(EditorTab *tab) { + return g_strcmp0(custom_highlight_mode(tab), "background") == 0; +} + +/** + * @brief Return whether full custom tag clearing should be avoided. + * @details Only custom YAML syntaxes need the large-file clear avoidance. When + * a tab switches to a GtkSourceView language, old Graptoς regex tags + * must still be cleared so stale custom colours do not remain. + * @param tab The editor tab whose syntax target is being inspected. + * @return TRUE when clearing should stay range-bound. + */ +static gboolean custom_highlight_avoid_full_clear(EditorTab *tab) { + return tab && + editor_tab_highlighting_allowed(tab) && + source_language_for_tab(tab) == NULL && + custom_highlight_uses_viewport(tab); +} + +/** + * @brief Compute visible custom highlight line range. + * @param tab The editor tab whose text view is visible. + * @param start_out First line to highlight. + * @param end_out Last line to highlight. + * @return TRUE when a range was computed. + */ +static gboolean custom_highlight_visible_range(EditorTab *tab, + guint *start_out, + guint *end_out) { + if (!tab || !tab->buffer || !start_out || !end_out) return FALSE; + gint line_count = gtk_text_buffer_get_line_count(tab->buffer); + if (line_count <= 0) return FALSE; + + guint top_line = 0u; + guint bottom_line = (guint)MIN(line_count - 1, 0); + if (tab->text_view && gtk_widget_get_mapped(tab->text_view)) { + GtkTextView *view = GTK_TEXT_VIEW(tab->text_view); + GdkRectangle visible; + GtkTextIter top; + GtkTextIter bottom; + gint ignored = 0; + gtk_text_view_get_visible_rect(view, &visible); + gtk_text_view_get_line_at_y(view, &top, visible.y, &ignored); + gtk_text_view_get_line_at_y(view, &bottom, visible.y + visible.height, &ignored); + top_line = (guint)gtk_text_iter_get_line(&top); + bottom_line = (guint)gtk_text_iter_get_line(&bottom); + } else { + GtkTextIter cursor; + GtkTextMark *insert = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &cursor, insert); + top_line = (guint)gtk_text_iter_get_line(&cursor); + bottom_line = top_line; + } + + guint context = GRAPTOS_HIGHLIGHT_CONTEXT_LINES; + guint start = top_line > context ? top_line - context : 0u; + guint end = bottom_line + context; + if (end >= (guint)line_count) end = (guint)line_count - 1u; + if (end < start) end = start; + *start_out = start; + *end_out = end; + return TRUE; +} + +/** + * @brief Clear old viewport-only custom highlight ranges. + * @param tab The editor tab whose previous range should be trimmed. + * @param start New highlighted start line. + * @param end New highlighted end line. + */ +static void custom_highlight_clear_old_viewport(EditorTab *tab, + guint start, + guint end) { + if (!tab || !tab->custom_highlight_range_valid || !tab->win) return; + if (tab->custom_highlight_end_line < start) { + syntax_clear_range(tab->buffer, + tab->win->syntaxes, + tab->custom_highlight_start_line, + tab->custom_highlight_end_line); + return; + } + if (tab->custom_highlight_start_line > end) { + syntax_clear_range(tab->buffer, + tab->win->syntaxes, + tab->custom_highlight_start_line, + tab->custom_highlight_end_line); + return; + } + if (tab->custom_highlight_start_line < start) { + syntax_clear_range(tab->buffer, + tab->win->syntaxes, + tab->custom_highlight_start_line, + start - 1u); + } + if (tab->custom_highlight_end_line > end) { + syntax_clear_range(tab->buffer, + tab->win->syntaxes, + end + 1u, + tab->custom_highlight_end_line); + } +} + +static gboolean custom_highlight_background_cb(gpointer user_data); + +/** + * @brief Schedule one background custom highlight chunk. + * @param tab The editor tab whose offscreen lines should be highlighted. + */ +static void custom_highlight_schedule_background(EditorTab *tab) { + if (!tab || !custom_highlight_uses_background(tab)) return; + if (tab->custom_highlight_background_timeout) return; + tab->custom_highlight_background_timeout = + g_timeout_add_full(G_PRIORITY_LOW, + CUSTOM_HIGHLIGHT_BACKGROUND_DELAY_MS, + custom_highlight_background_cb, + tab, + NULL); +} + +/** + * @brief Apply one background custom highlight chunk. + * @param user_data Editor tab. + * @return G_SOURCE_REMOVE after one chunk. + */ +static gboolean custom_highlight_background_cb(gpointer user_data) { + EditorTab *tab = user_data; + if (!tab || !tab->buffer || !tab->win || !editor_tab_custom_highlight_needed(tab)) { + return G_SOURCE_REMOVE; + } + tab->custom_highlight_background_timeout = 0u; + gint line_count = gtk_text_buffer_get_line_count(tab->buffer); + if (line_count <= 0 || tab->custom_highlight_background_line >= (guint)line_count) { + return G_SOURCE_REMOVE; + } + guint start = tab->custom_highlight_background_line; + guint end = start + CUSTOM_HIGHLIGHT_BACKGROUND_LINES - 1u; + if (end >= (guint)line_count) end = (guint)line_count - 1u; + syntax_apply_range(tab->buffer, + tab->win->syntaxes, + tab->active_syntax, + start, + end); + tab->custom_highlight_background_line = end + 1u; + if (tab->custom_highlight_background_line < (guint)line_count) { + custom_highlight_schedule_background(tab); + } + return G_SOURCE_REMOVE; +} + /** * @brief Colour is dark. * @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. @@ -485,7 +686,9 @@ static GtkSourceStyleScheme *source_yaml_override_scheme_for_tab(EditorTab *tab) */ static void clear_graptos_transient_tags(EditorTab *tab) { if (!tab || !tab->buffer) return; - syntax_clear(tab->buffer, tab->win ? tab->win->syntaxes : NULL); + if (!custom_highlight_avoid_full_clear(tab)) { + syntax_clear(tab->buffer, tab->win ? tab->win->syntaxes : NULL); + } if (tab->selection_matches_active) clear_selection_matches(tab); if (tab->diagnostics_active && (!tab->active_syntax || @@ -494,6 +697,75 @@ static void clear_graptos_transient_tags(EditorTab *tab) { clear_syntax_diagnostics(tab); } tab->custom_highlight_active = FALSE; + tab->custom_highlight_range_valid = FALSE; + graptos_source_cancel(&tab->custom_highlight_background_timeout); +} + +/** + * @brief Return whether custom YAML highlighting owns this tab. + * @details GtkSourceView-backed syntaxes are excluded because GTK already owns + * their incremental highlighter. This function only covers Graptoς + * regex syntax fallback. + * @param tab The editor tab whose highlighting engine is being inspected. + * @return TRUE when Graptoς regex highlighting should run. + */ +gboolean editor_tab_custom_highlight_needed(EditorTab *tab) { + return tab && + tab->source_buffer && + editor_tab_highlighting_allowed(tab) && + gtk_source_buffer_get_language(tab->source_buffer) == NULL; +} + +/** + * @brief Apply custom YAML highlighting without resetting the source engine. + * @details Large custom files highlight only the visible range plus context. + * Small files keep the full-buffer behavior unless config selects a + * viewport mode. + * @param tab The editor tab whose visible range should be highlighted. + */ +void editor_tab_apply_custom_highlight(EditorTab *tab) { + if (!tab || !tab->buffer || !tab->win || !editor_tab_custom_highlight_needed(tab)) { + return; + } + + guint max_chars = tab->win->full_highlight_max_chars > 0u + ? tab->win->full_highlight_max_chars + : GRAPTOS_FULL_HIGHLIGHT_MAX_CHARS; + if (!custom_highlight_uses_viewport(tab)) { + if ((guint)gtk_text_buffer_get_char_count(tab->buffer) <= max_chars) { + syntax_apply(tab->buffer, + tab->win->syntaxes, + tab->active_syntax); + tab->custom_highlight_active = TRUE; + } + return; + } + + guint start = 0u; + guint end = 0u; + if (!custom_highlight_visible_range(tab, &start, &end)) return; + if (!custom_highlight_uses_background(tab)) { + custom_highlight_clear_old_viewport(tab, start, end); + } + syntax_apply_range(tab->buffer, tab->win->syntaxes, tab->active_syntax, start, end); + tab->custom_highlight_start_line = start; + tab->custom_highlight_end_line = end; + tab->custom_highlight_range_valid = TRUE; + tab->custom_highlight_active = TRUE; + + if (custom_highlight_uses_background(tab)) { + gint line_count = gtk_text_buffer_get_line_count(tab->buffer); + if (line_count > 0) { + if (tab->custom_highlight_background_line < end + 1u) { + tab->custom_highlight_background_line = end + 1u; + } + if (tab->custom_highlight_background_line < (guint)line_count) { + custom_highlight_schedule_background(tab); + } + } + } else { + graptos_source_cancel(&tab->custom_highlight_background_timeout); + } } /** @@ -520,6 +792,10 @@ void editor_tab_update_highlight_engine(EditorTab *tab) { source_yaml_override_scheme_for_tab(tab)); gtk_source_buffer_set_language(tab->source_buffer, source_language_for_tab(tab)); gtk_source_buffer_set_highlight_syntax(tab->source_buffer, TRUE); + if (editor_tab_custom_highlight_needed(tab)) { + tab->custom_highlight_background_line = 0u; + editor_tab_apply_custom_highlight(tab); + } if (tab->text_view) gtk_widget_queue_draw(tab->text_view); if (tab->minimap_view) gtk_widget_queue_draw(tab->minimap_view); diff --git a/src/plugin.c b/src/plugin.c index d00ddee..002c7be 100644 --- a/src/plugin.c +++ b/src/plugin.c @@ -11,8 +11,10 @@ #ifndef GRAPTOS_PLUGIN_NO_UI #include "app.h" +#include "config.h" #include "dialogs.h" #include "editor_tab.h" +#include "editor_tab_private.h" #include "git.h" #include "ui.h" #endif @@ -97,6 +99,57 @@ static void native_completion_provider_free(gpointer data) { g_free(provider); } +/** + * @brief Registered native plugin hub. + */ +typedef struct { + GraptosPlugin *plugin; /**< Plugin that owns the hub. */ + char *hub_id; /**< Stable hub id. */ + char *label; /**< Visible hub label. */ + GraptosPluginHubRenderFunc render; /**< Hub render callback. */ + GraptosPluginHubActionFunc action; /**< Hub action callback. */ + gpointer user_data; /**< Plugin-owned callback data. */ + GraptosPluginDestroyFunc destroy; /**< Optional user data destroy hook. */ +} GraptosNativeHub; + +/** + * @brief Free a native plugin hub. + * @param data Native hub. + */ +static void native_hub_free(gpointer data) { + GraptosNativeHub *hub = data; + if (!hub) return; + if (hub->destroy) hub->destroy(hub->user_data); + g_free(hub->hub_id); + g_free(hub->label); + g_free(hub); +} + +/** + * @brief Registered native hover provider. + */ +typedef struct { + GraptosPlugin *plugin; /**< Plugin that owns the provider. */ + char *provider_id; /**< Provider id. */ + char *label; /**< Visible hover heading. */ + GraptosPluginHoverFunc callback; /**< Hover callback. */ + gpointer user_data; /**< Plugin-owned callback data. */ + GraptosPluginDestroyFunc destroy; /**< Optional user data destroy hook. */ +} GraptosNativeHoverProvider; + +/** + * @brief Free a native hover provider. + * @param data Native hover provider. + */ +static void native_hover_provider_free(gpointer data) { + GraptosNativeHoverProvider *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"); } @@ -233,6 +286,60 @@ gboolean graptos_plugin_host_register_completion_provider(GraptosPluginHost *hos return TRUE; } +gboolean graptos_plugin_host_register_hover_provider(GraptosPluginHost *host, + const char *provider_id, + const char *label, + GraptosPluginHoverFunc 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_hover_providers) { + plugin->native_hover_providers = + g_ptr_array_new_with_free_func(native_hover_provider_free); + } + GraptosNativeHoverProvider *provider = g_new0(GraptosNativeHoverProvider, 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_hover_providers, provider); + return TRUE; +} + +gboolean graptos_plugin_host_register_hub(GraptosPluginHost *host, + const char *hub_id, + const char *label, + GraptosPluginHubRenderFunc render, + GraptosPluginHubActionFunc action, + gpointer user_data, + GraptosPluginDestroyFunc destroy) { + if (!host || !host->plugin || !hub_id || !hub_id[0] || !render || !action) { + return FALSE; + } + GraptosPlugin *plugin = host->plugin; + if (!plugin->native_hubs) { + plugin->native_hubs = g_ptr_array_new_with_free_func(native_hub_free); + } + GraptosNativeHub *hub = g_new0(GraptosNativeHub, 1); + if (!hub) return FALSE; + hub->plugin = plugin; + hub->hub_id = g_strdup(hub_id); + hub->label = g_strdup(label && label[0] ? label : "Plugin Hub"); + hub->render = render; + hub->action = action; + hub->user_data = user_data; + hub->destroy = destroy; + g_ptr_array_add(plugin->native_hubs, hub); + return TRUE; +} + /** * @brief Strip a line comment. * @details Plugin manifests do not support quoted hashes yet. Matching the @@ -336,6 +443,7 @@ static GraptosPlugin *plugin_new(void) { GraptosPlugin *plugin = g_new0(GraptosPlugin, 1); if (!plugin) return NULL; plugin->enabled = TRUE; + plugin->default_enabled = TRUE; plugin->graptos_api_version = GRAPTOS_PLUGIN_API_VERSION; plugin->permissions = g_ptr_array_new_with_free_func(g_free); plugin->syntax_dirs = g_ptr_array_new_with_free_func(g_free); @@ -350,6 +458,10 @@ static GraptosPlugin *plugin_new(void) { native_command_free); plugin->native_completion_providers = g_ptr_array_new_with_free_func(native_completion_provider_free); + plugin->native_hover_providers = + g_ptr_array_new_with_free_func(native_hover_provider_free); + plugin->native_hubs = + g_ptr_array_new_with_free_func(native_hub_free); return plugin; } @@ -373,6 +485,12 @@ void graptos_plugin_free(GraptosPlugin *plugin) { if (plugin->native_completion_providers) { g_ptr_array_free(plugin->native_completion_providers, TRUE); } + if (plugin->native_hover_providers) { + g_ptr_array_free(plugin->native_hover_providers, TRUE); + } + if (plugin->native_hubs) { + g_ptr_array_free(plugin->native_hubs, TRUE); + } g_free(plugin); } @@ -539,6 +657,7 @@ GraptosPlugin *graptos_plugin_load_manifest(const char *plugin_dir, GError **err GRAPTOS_PLUGIN_API_VERSION); goto fail; } + plugin->default_enabled = plugin->enabled; return plugin; fail: @@ -606,6 +725,115 @@ gboolean graptos_plugin_registry_discover(GraptosPluginRegistry *registry, return TRUE; } +/** + * @brief Return the user plugin state path. + * @details Plugin enable choices are editor preferences, not plugin package + * data, so they live beside the main Graptoς config file. + * @return Owned plugin state path. + */ +static char *plugin_state_path(void) { + const char *base = g_get_user_config_dir(); + if (!base || base[0] == '\0') return NULL; + return g_build_filename(base, "graptos", "plugins.ini", NULL); +} + +/** + * @brief Return whether a string list contains a plugin id. + * @param values String list returned by GKeyFile. + * @param id Plugin id to find. + * @return TRUE when the id exists in the list. + */ +static gboolean plugin_id_list_contains(char **values, const char *id) { + if (!values || !id) return FALSE; + for (guint i = 0u; values[i]; i++) { + if (g_strcmp0(values[i], id) == 0) return TRUE; + } + return FALSE; +} + +void graptos_plugin_registry_apply_user_state(GraptosPluginRegistry *registry) { + if (!registry || !registry->plugins) return; + g_autofree char *path = plugin_state_path(); + if (!path || !g_file_test(path, G_FILE_TEST_EXISTS)) return; + g_autoptr(GKeyFile) key_file = g_key_file_new(); + if (!g_key_file_load_from_file(key_file, path, G_KEY_FILE_NONE, NULL)) return; + + gsize disabled_len = 0u; + g_auto(GStrv) disabled = + g_key_file_get_string_list(key_file, "Plugins", "disabled", &disabled_len, NULL); + gsize enabled_len = 0u; + g_auto(GStrv) enabled = + g_key_file_get_string_list(key_file, "Plugins", "enabled", &enabled_len, NULL); + (void)disabled_len; + (void)enabled_len; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->id) continue; + if (plugin_id_list_contains(enabled, plugin->id)) { + plugin->enabled = TRUE; + } else if (plugin_id_list_contains(disabled, plugin->id)) { + plugin->enabled = FALSE; + } + } +} + +gboolean graptos_plugin_registry_save_user_state(GraptosPluginRegistry *registry) { + if (!registry || !registry->plugins) return FALSE; + g_autofree char *path = plugin_state_path(); + if (!path) return FALSE; + + GPtrArray *enabled = g_ptr_array_new(); + GPtrArray *disabled = g_ptr_array_new(); + if (!enabled || !disabled) { + if (enabled) g_ptr_array_free(enabled, TRUE); + if (disabled) g_ptr_array_free(disabled, TRUE); + return FALSE; + } + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->id) continue; + if (!plugin->default_enabled && plugin->enabled) { + g_ptr_array_add(enabled, plugin->id); + } else if (plugin->default_enabled && !plugin->enabled) { + g_ptr_array_add(disabled, plugin->id); + } + } + + g_autoptr(GKeyFile) key_file = g_key_file_new(); + g_key_file_set_string_list(key_file, + "Plugins", + "enabled", + (const gchar * const *)enabled->pdata, + enabled->len); + g_key_file_set_string_list(key_file, + "Plugins", + "disabled", + (const gchar * const *)disabled->pdata, + disabled->len); + g_ptr_array_free(enabled, TRUE); + g_ptr_array_free(disabled, TRUE); + + gsize len = 0u; + g_autofree char *data = g_key_file_to_data(key_file, &len, NULL); + g_autofree char *dir = g_path_get_dirname(path); + if (!data || g_mkdir_with_parents(dir, 0700) != 0) return FALSE; + return g_file_set_contents(path, data, (gssize)len, NULL); +} + +gboolean graptos_plugin_registry_set_enabled(GraptosPluginRegistry *registry, + const char *plugin_id, + gboolean enabled) { + if (!registry || !registry->plugins || !plugin_id) return FALSE; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (plugin && g_strcmp0(plugin->id, plugin_id) == 0) { + plugin->enabled = enabled; + return TRUE; + } + } + return FALSE; +} + /** * @brief Select a contribution array from a plugin. * @param plugin Plugin manifest. @@ -769,6 +997,16 @@ char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context) { FALSE); } +const char *graptos_plugin_context_syntax_name(GraptosPluginCommandContext *context) { + return context && context->tab && context->tab->active_syntax + ? context->tab->active_syntax->name + : NULL; +} + +gboolean graptos_plugin_context_is_modified(GraptosPluginCommandContext *context) { + return context && context->tab ? context->tab->modified : FALSE; +} + gboolean graptos_plugin_context_insert_text(GraptosPluginCommandContext *context, const char *text) { if (!context || !context->tab || !context->tab->buffer || !text) return FALSE; @@ -792,6 +1030,42 @@ gboolean graptos_plugin_context_replace_selection(GraptosPluginCommandContext *c return TRUE; } +gboolean graptos_plugin_context_replace_text(GraptosPluginCommandContext *context, + const char *text) { + if (!context || !context->tab || !context->tab->buffer || !text || + context->tab->locked) { + return FALSE; + } + + gtk_text_buffer_begin_user_action(context->tab->buffer); + gtk_text_buffer_set_text(context->tab->buffer, text, -1); + gtk_text_buffer_end_user_action(context->tab->buffer); + + editor_tab_schedule_lightweight_ui_refresh(context->tab); + editor_tab_schedule_syntax_diagnostics(context->tab); + return TRUE; +} + +void graptos_plugin_context_clear_diagnostics(GraptosPluginCommandContext *context) { + if (!context || !context->tab) return; + clear_syntax_diagnostics(context->tab); + editor_tab_schedule_lightweight_ui_refresh(context->tab); +} + +gboolean graptos_plugin_context_add_line_diagnostic(GraptosPluginCommandContext *context, + guint line, + const char *message) { + if (!context || !context->tab || line == 0u) return FALSE; + gboolean applied = editor_tab_apply_external_diagnostic(context->tab, + (gint)(line - 1u), + 0, + (gint)(line - 1u), + G_MAXINT, + message); + if (applied) editor_tab_schedule_lightweight_ui_refresh(context->tab); + return applied; +} + void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, const char *title, const char *heading, @@ -805,6 +1079,159 @@ void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, body ? body : ""); } +/** + * @brief Return a safe generated file stem. + * @param basename Requested basename. + * @return Owned safe basename. + */ +static char *plugin_safe_basename(const char *basename) { + GString *out = g_string_new(NULL); + for (const char *p = basename && basename[0] ? basename : "plugin-report"; *p; p++) { + if (g_ascii_isalnum(*p) || *p == '-' || *p == '_') { + g_string_append_c(out, *p); + } else { + g_string_append_c(out, '-'); + } + } + if (out->len == 0u) g_string_append(out, "plugin-report"); + return g_string_free(out, FALSE); +} + +/** + * @brief Open a plugin-generated PDF. + * @param context Command context supplied by Graptoς. + * @param pdf_path Path to generated PDF. + * @return TRUE when the default application launched. + */ +static gboolean plugin_open_pdf(GraptosPluginCommandContext *context, + const char *pdf_path) { + g_autoptr(GError) error = NULL; + g_autofree char *uri = g_filename_to_uri(pdf_path, NULL, &error); + if (!uri) { + if (context && context->tab && context->tab->win) { + app_window_set_error_status(context->tab->win, + "Could not open exported PDF", + error ? error->message : "Invalid PDF path"); + } + return FALSE; + } + if (!g_app_info_launch_default_for_uri(uri, NULL, &error)) { + if (context && context->tab && context->tab->win) { + app_window_set_error_status(context->tab->win, + "Could not open exported PDF", + error ? error->message : "No default PDF application"); + } + return FALSE; + } + return TRUE; +} + +void graptos_plugin_context_show_preview(GraptosPluginCommandContext *context, + const char *title, + const char *body) { + if (!context || !context->tab || !context->tab->preview_buffer) return; + EditorTab *tab = context->tab; + g_free(tab->plugin_preview_title); + g_free(tab->plugin_preview_body); + tab->plugin_preview_title = g_strdup(title && title[0] ? title : "Plugin Preview"); + tab->plugin_preview_body = g_strdup(body ? body : ""); + tab->plugin_preview_active = TRUE; + if (tab->win) tab->win->preview_enabled = TRUE; + editor_tab_update_preview(tab); + editor_tab_set_preview_visible(tab, TRUE); + if (tab->win) { + app_window_update_ui(tab->win); + app_window_set_status(tab->win, "Plugin preview updated."); + } +} + +void graptos_plugin_context_set_preview_visible(GraptosPluginCommandContext *context, + gboolean visible) { + if (!context || !context->tab || !context->tab->win) return; + context->tab->win->preview_enabled = visible; + editor_tab_set_preview_visible(context->tab, visible); + app_window_update_ui(context->tab->win); +} + +gboolean graptos_plugin_context_export_latex_pdf(GraptosPluginCommandContext *context, + const char *basename, + const char *latex_source) { + if (!context || !context->tab || !context->tab->win || !latex_source) { + return FALSE; + } + EditorTab *tab = context->tab; + if (!tab->file_path || !tab->file_path[0]) { + app_window_set_error_status(tab->win, + "Export requires a saved file", + "Open or save the active file before exporting a plugin PDF."); + return FALSE; + } + g_autofree char *command = graptos_latex_resolve_command(tab->win); + if (!command) { + app_window_set_error_status(tab->win, + "LaTeX command not found", + "Install pdflatex, xelatex, or lualatex, or set latex_command in config.ini."); + return FALSE; + } + + g_autofree char *dir = g_path_get_dirname(tab->file_path); + g_autofree char *output_dir = g_build_filename(dir, ".graptos-beancount-build", NULL); + if (g_mkdir_with_parents(output_dir, 0700) != 0) { + app_window_set_error_status(tab->win, + "Could not create export dir", + "Graptoς could not create .graptos-beancount-build."); + return FALSE; + } + + g_autofree char *safe = plugin_safe_basename(basename); + g_autofree char *tex_name = g_strdup_printf("%s.tex", safe); + g_autofree char *pdf_name = g_strdup_printf("%s.pdf", safe); + g_autofree char *tex_path = g_build_filename(output_dir, tex_name, NULL); + g_autofree char *pdf_path = g_build_filename(output_dir, pdf_name, NULL); + g_autoptr(GError) error = NULL; + if (!write_text_atomic(tex_path, latex_source, &error)) { + app_window_set_error_status(tab->win, + "Could not write LaTeX report", + error ? error->message : tex_path); + return FALSE; + } + + g_autofree char *stdout_text = NULL; + g_autofree char *stderr_text = NULL; + g_auto(GStrv) argv = graptos_latex_build_argv(tab->win, + command, + output_dir, + tex_path, + &error); + if (!argv) { + app_window_set_error_status(tab->win, + "LaTeX arguments invalid", + error ? error->message : "Could not parse latex_arguments."); + return FALSE; + } + int status = 0; + if (!g_spawn_sync(output_dir, argv, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, + &stdout_text, &stderr_text, &status, &error) || + status != 0) { + g_autofree char *message = g_strdup_printf("%s\n%s", + error ? error->message : "LaTeX export failed.", + stderr_text ? stderr_text : ""); + app_window_set_error_status(tab->win, "LaTeX export failed", message); + return FALSE; + } + if (!g_file_test(pdf_path, G_FILE_TEST_IS_REGULAR)) { + app_window_set_error_status(tab->win, + "Exported PDF not found", + "LaTeX finished, but Graptoς could not find the report PDF."); + return FALSE; + } + if (plugin_open_pdf(context, pdf_path)) { + app_window_set_status(tab->win, "Plugin PDF exported."); + return TRUE; + } + return FALSE; +} + void graptos_plugin_context_set_status(GraptosPluginCommandContext *context, const char *text) { if (!context || !context->tab || !context->tab->win) return; @@ -1193,6 +1620,16 @@ char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context) { return g_strdup(""); } +const char *graptos_plugin_context_syntax_name(GraptosPluginCommandContext *context) { + (void)context; + return NULL; +} + +gboolean graptos_plugin_context_is_modified(GraptosPluginCommandContext *context) { + (void)context; + return FALSE; +} + gboolean graptos_plugin_context_insert_text(GraptosPluginCommandContext *context, const char *text) { (void)context; @@ -1207,6 +1644,26 @@ gboolean graptos_plugin_context_replace_selection(GraptosPluginCommandContext *c return FALSE; } +gboolean graptos_plugin_context_replace_text(GraptosPluginCommandContext *context, + const char *text) { + (void)context; + (void)text; + return FALSE; +} + +void graptos_plugin_context_clear_diagnostics(GraptosPluginCommandContext *context) { + (void)context; +} + +gboolean graptos_plugin_context_add_line_diagnostic(GraptosPluginCommandContext *context, + guint line, + const char *message) { + (void)context; + (void)line; + (void)message; + return FALSE; +} + void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, const char *title, const char *heading, @@ -1217,6 +1674,29 @@ void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, (void)body; } +void graptos_plugin_context_show_preview(GraptosPluginCommandContext *context, + const char *title, + const char *body) { + (void)context; + (void)title; + (void)body; +} + +void graptos_plugin_context_set_preview_visible(GraptosPluginCommandContext *context, + gboolean visible) { + (void)context; + (void)visible; +} + +gboolean graptos_plugin_context_export_latex_pdf(GraptosPluginCommandContext *context, + const char *basename, + const char *latex_source) { + (void)context; + (void)basename; + (void)latex_source; + return FALSE; +} + void graptos_plugin_context_set_status(GraptosPluginCommandContext *context, const char *text) { (void)context; @@ -1290,6 +1770,7 @@ gboolean graptos_plugin_registry_load_native(GraptosPluginRegistry *registry, plugin->native_library[0] == '\0') { continue; } + if (plugin->native_handle) continue; if (!plugin_has_permission(plugin, "native")) { g_set_error(error, GRAPTOS_PLUGIN_ERROR, GRAPTOS_PLUGIN_ERROR_NATIVE, @@ -1378,7 +1859,6 @@ void graptos_plugin_append_editor_context_items(GraptosPluginRegistry *registry, child = next; } } - g_object_unref(plugin_box); #endif } @@ -1402,6 +1882,28 @@ guint graptos_plugin_append_editor_tool_items(GraptosPluginRegistry *registry, #endif } +guint graptos_plugin_append_editor_tool_menu_items(GraptosPluginRegistry *registry, + EditorTab *tab, + GtkWidget *box, + GtkWidget *popover, + guint line) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)box; + (void)popover; + (void)line; + return 0u; +#else + return plugin_append_editor_line_specs(registry, + tab, + box, + popover, + line, + TRUE); +#endif +} + gboolean graptos_plugin_registry_run_shortcut(GraptosPluginRegistry *registry, EditorTab *tab, const char *shortcut, @@ -1485,3 +1987,154 @@ GPtrArray *graptos_plugin_registry_completion_candidates(GraptosPluginRegistry * return NULL; #endif } + +char *graptos_plugin_registry_hover_text(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *word, + char **source_label_out) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)word; + (void)source_label_out; + return NULL; +#else + if (source_label_out) *source_label_out = NULL; + if (!registry || !registry->plugins || !tab || !word || !word[0]) { + 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_hover_providers) { + continue; + } + for (guint j = 0u; j < plugin->native_hover_providers->len; j++) { + GraptosNativeHoverProvider *provider = + g_ptr_array_index(plugin->native_hover_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); + char *body = provider->callback(&context, word, provider->user_data); + if (!body || !body[0]) { + g_free(body); + continue; + } + if (source_label_out) { + *source_label_out = g_strdup(provider->label + ? provider->label + : "Plugin"); + } + return body; + } + } + return NULL; +#endif +} + +void graptos_plugin_hub_view_free(gpointer data) { + GraptosPluginHubView *view = data; + if (!view) return; + g_free(view->plugin_id); + g_free(view->hub_id); + g_free(view->label); + g_free(view); +} + +GPtrArray *graptos_plugin_registry_hub_views(GraptosPluginRegistry *registry) { + GPtrArray *views = g_ptr_array_new_with_free_func(graptos_plugin_hub_view_free); + if (!views) return NULL; + if (!registry || !registry->plugins) return views; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->enabled || !plugin->native_hubs) continue; + for (guint j = 0u; j < plugin->native_hubs->len; j++) { + GraptosNativeHub *hub = g_ptr_array_index(plugin->native_hubs, j); + if (!hub || !hub->render || !hub->action) continue; + GraptosPluginHubView *view = g_new0(GraptosPluginHubView, 1); + view->plugin_id = g_strdup(plugin->id); + view->hub_id = g_strdup(hub->hub_id); + view->label = g_strdup(hub->label ? hub->label : "Plugin Hub"); + g_ptr_array_add(views, view); + } + } + return views; +} + +#ifndef GRAPTOS_PLUGIN_NO_UI +static GraptosNativeHub *plugin_find_hub(GraptosPluginRegistry *registry, + const char *plugin_id, + const char *hub_id, + GraptosPlugin **plugin_out) { + if (plugin_out) *plugin_out = NULL; + if (!registry || !registry->plugins || !plugin_id || !hub_id) return NULL; + for (guint i = 0u; i < registry->plugins->len; i++) { + GraptosPlugin *plugin = g_ptr_array_index(registry->plugins, i); + if (!plugin || !plugin->enabled || g_strcmp0(plugin->id, plugin_id) != 0 || + !plugin->native_hubs) { + continue; + } + for (guint j = 0u; j < plugin->native_hubs->len; j++) { + GraptosNativeHub *hub = g_ptr_array_index(plugin->native_hubs, j); + if (hub && g_strcmp0(hub->hub_id, hub_id) == 0) { + if (plugin_out) *plugin_out = plugin; + return hub; + } + } + } + return NULL; +} +#endif + +char *graptos_plugin_registry_render_hub(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *plugin_id, + const char *hub_id) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)plugin_id; + (void)hub_id; + return NULL; +#else + GraptosPlugin *plugin = NULL; + GraptosNativeHub *hub = plugin_find_hub(registry, plugin_id, hub_id, &plugin); + if (!hub || !hub->render || !plugin || !tab) return NULL; + GraptosPluginCommandContext context = {0}; + context.plugin = plugin; + context.tab = tab; + context.command = hub->hub_id; + context.line = plugin_tab_cursor_line(tab); + return hub->render(&context, hub->user_data); +#endif +} + +gboolean graptos_plugin_registry_run_hub_action(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *plugin_id, + const char *hub_id, + const char *action_id, + const char *input) { +#ifdef GRAPTOS_PLUGIN_NO_UI + (void)registry; + (void)tab; + (void)plugin_id; + (void)hub_id; + (void)action_id; + (void)input; + return FALSE; +#else + GraptosPlugin *plugin = NULL; + GraptosNativeHub *hub = plugin_find_hub(registry, plugin_id, hub_id, &plugin); + if (!hub || !hub->action || !plugin || !tab || !action_id) return FALSE; + GraptosPluginCommandContext context = {0}; + context.plugin = plugin; + context.tab = tab; + context.command = hub->hub_id; + context.line = plugin_tab_cursor_line(tab); + hub->action(&context, action_id, input, hub->user_data); + return TRUE; +#endif +} diff --git a/src/plugin.h b/src/plugin.h index 7a66c4f..7187638 100644 --- a/src/plugin.h +++ b/src/plugin.h @@ -55,6 +55,7 @@ typedef struct { char *native_library; /**< Optional native library path relative to base. */ guint graptos_api_version; /**< Required plugin API version. */ gboolean enabled; /**< FALSE when manifest disables the plugin. */ + gboolean default_enabled; /**< Manifest enable value before user overrides. */ GPtrArray *permissions; /**< char*. */ GPtrArray *syntax_dirs; /**< char*. */ GPtrArray *theme_dirs; /**< char*. */ @@ -65,8 +66,19 @@ typedef struct { gpointer native_handle; /**< Private GModule handle. */ GHashTable *native_commands; /**< char* to native command handler. */ GPtrArray *native_completion_providers; /**< Native completion providers. */ + GPtrArray *native_hover_providers; /**< Native hover providers. */ + GPtrArray *native_hubs; /**< Native hub providers. */ } GraptosPlugin; +/** + * @brief A lightweight plugin hub entry for UI callers. + */ +typedef struct { + char *plugin_id; /**< Plugin id that owns the hub. */ + char *hub_id; /**< Hub id inside the plugin. */ + char *label; /**< Human-facing hub label. */ +} GraptosPluginHubView; + /** * @brief Plugin registry. */ @@ -120,6 +132,32 @@ void graptos_plugin_registry_free(GraptosPluginRegistry *registry); */ gboolean graptos_plugin_registry_discover(GraptosPluginRegistry *registry, GError **error); +/** + * @brief Apply persisted user enable and disable choices. + * @details Manifest `enabled:` remains the plugin default. User choices are + * stored outside plugin folders so package updates do not overwrite + * local decisions. + * @param registry Registry receiving persisted state. + */ +void graptos_plugin_registry_apply_user_state(GraptosPluginRegistry *registry); +/** + * @brief Save current plugin enable state. + * @details Only disabled plugin ids are persisted. Plugins default to enabled + * when their manifest enables them and no user override exists. + * @param registry Registry whose state should be saved. + * @return TRUE when the state reached disk. + */ +gboolean graptos_plugin_registry_save_user_state(GraptosPluginRegistry *registry); +/** + * @brief Set one plugin enabled state. + * @param registry Registry to update. + * @param plugin_id Stable plugin id. + * @param enabled TRUE to enable the plugin. + * @return TRUE when a plugin was found and updated. + */ +gboolean graptos_plugin_registry_set_enabled(GraptosPluginRegistry *registry, + const char *plugin_id, + gboolean enabled); /** * @brief Return contribution directories of one kind. * @details Relative contribution paths are resolved against each plugin root @@ -170,6 +208,23 @@ guint graptos_plugin_append_editor_tool_items(GraptosPluginRegistry *registry, EditorTab *tab, GtkWidget *box, guint line); +/** + * @brief Append plugin editor items to a transient tool popover. + * @details This keeps the bottom plugin surface compact while reusing the + * same command path as editor context menus and the plugin tool panel. + * The popover is closed before a selected command runs. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param box Popover box receiving button widgets. + * @param popover Popover to close before running the command. + * @param line One-based active editor line. + * @return Number of plugin command rows appended. + */ +guint graptos_plugin_append_editor_tool_menu_items(GraptosPluginRegistry *registry, + EditorTab *tab, + GtkWidget *box, + GtkWidget *popover, + guint line); /** * @brief Run a plugin shortcut command. * @details Keyboard handling passes stable shortcut ids here so plugins can @@ -199,4 +254,58 @@ GPtrArray *graptos_plugin_registry_completion_candidates(GraptosPluginRegistry * char **replace_prefix_out, char **source_label_out); +/** + * @brief Return native plugin hover text for a token. + * @details The first enabled provider returning text owns the label and body. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param word Token under the pointer. + * @param source_label_out Owned source label for the hover heading. + * @return Owned hover body, or NULL. + */ +char *graptos_plugin_registry_hover_text(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *word, + char **source_label_out); + +/** + * @brief Free one hub view. + * @param data GraptosPluginHubView. + */ +void graptos_plugin_hub_view_free(gpointer data); +/** + * @brief Return enabled native plugin hubs. + * @param registry Plugin registry to inspect. + * @return GPtrArray of GraptosPluginHubView*. + */ +GPtrArray *graptos_plugin_registry_hub_views(GraptosPluginRegistry *registry); +/** + * @brief Render a native plugin hub. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param plugin_id Plugin id. + * @param hub_id Hub id. + * @return Owned hub body, or NULL. + */ +char *graptos_plugin_registry_render_hub(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *plugin_id, + const char *hub_id); +/** + * @brief Run a native plugin hub action. + * @param registry Plugin registry to inspect. + * @param tab Active editor tab. + * @param plugin_id Plugin id. + * @param hub_id Hub id. + * @param action_id Action id. + * @param input Prompt input. + * @return TRUE when a hub handled the action. + */ +gboolean graptos_plugin_registry_run_hub_action(GraptosPluginRegistry *registry, + EditorTab *tab, + const char *plugin_id, + const char *hub_id, + const char *action_id, + const char *input); + #endif /* GRAPTOS_PLUGIN_H */ diff --git a/src/plugin_api.h b/src/plugin_api.h index 4f5c185..12aeded 100644 --- a/src/plugin_api.h +++ b/src/plugin_api.h @@ -51,6 +51,44 @@ typedef GPtrArray *(*GraptosPluginCompletionFunc)(GraptosPluginCommandContext *c char **replace_prefix_out, gpointer user_data); +/** + * @brief Native plugin hover callback. + * @details Providers inspect the current hover word and may return a short + * plain-text body for Graptoς to show in the hover popover. + * @param context Opaque execution context for the current editor hover. + * @param word Word or token under the pointer. + * @param user_data Plugin data supplied during provider registration. + * @return Owned hover body, or NULL when the provider does not apply. + */ +typedef char *(*GraptosPluginHoverFunc)(GraptosPluginCommandContext *context, + const char *word, + gpointer user_data); + +/** + * @brief Native plugin hub render callback. + * @details Hubs provide one focused tool surface without adding many command + * rows. The returned text is shown in the tool panel. + * @param context Command context for the active editor/project. + * @param user_data Plugin data supplied during hub registration. + * @return Owned plain-text hub body. + */ +typedef char *(*GraptosPluginHubRenderFunc)(GraptosPluginCommandContext *context, + gpointer user_data); + +/** + * @brief Native plugin hub action callback. + * @details Graptoς owns the compact hub controls and passes action ids plus + * prompt text to the plugin. + * @param context Command context for the active editor/project. + * @param action_id Stable action id such as add, modify, done, delete, priority. + * @param input Prompt text supplied by the user, or NULL. + * @param user_data Plugin data supplied during hub registration. + */ +typedef void (*GraptosPluginHubActionFunc)(GraptosPluginCommandContext *context, + const char *action_id, + const char *input, + gpointer user_data); + /** * @brief Native plugin data destroy callback. * @param user_data Plugin-owned data supplied during command registration. @@ -164,6 +202,46 @@ gboolean graptos_plugin_host_register_completion_provider(GraptosPluginHost *hos gpointer user_data, GraptosPluginDestroyFunc destroy); +/** + * @brief Register a native hover provider. + * @details Providers are asked before built-in reference lookup. The first + * provider that returns text owns the hover body. + * @param host The host capability object supplied by Graptoς. + * @param provider_id Stable provider id owned by the plugin. + * @param label Human-facing hover heading. + * @param callback Native hover 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_hover_provider(GraptosPluginHost *host, + const char *provider_id, + const char *label, + GraptosPluginHoverFunc callback, + gpointer user_data, + GraptosPluginDestroyFunc destroy); + +/** + * @brief Register a focused native plugin hub. + * @details A hub appears as one plugin tool entry and opens a compact tool + * panel instead of contributing many command menu rows. + * @param host The host capability object supplied by Graptoς. + * @param hub_id Stable hub id owned by the plugin. + * @param label Human-facing hub label. + * @param render Render callback. + * @param action Action callback. + * @param user_data Plugin data passed to callbacks. + * @param destroy Optional user data destroy hook. + * @return TRUE when the hub was accepted. + */ +gboolean graptos_plugin_host_register_hub(GraptosPluginHost *host, + const char *hub_id, + const char *label, + GraptosPluginHubRenderFunc render, + GraptosPluginHubActionFunc action, + gpointer user_data, + GraptosPluginDestroyFunc destroy); + /** * @brief Return the plugin id for a command context. * @param context Command context supplied by Graptoς. @@ -232,6 +310,24 @@ char *graptos_plugin_context_line_text(GraptosPluginCommandContext *context, */ char *graptos_plugin_context_line_prefix(GraptosPluginCommandContext *context); +/** + * @brief Return the active syntax name. + * @details Native plugins use this to stay language-aware without reading + * editor internals or duplicating Graptoς syntax detection. + * @param context Command context supplied by Graptoς. + * @return Syntax name owned by Graptoς, or NULL when no syntax is active. + */ +const char *graptos_plugin_context_syntax_name(GraptosPluginCommandContext *context); + +/** + * @brief Return whether the active editor has unsaved changes. + * @details Plugins that invoke file-backed command line tools should use this + * before reading the on-disk file. + * @param context Command context supplied by Graptoς. + * @return TRUE when the active buffer differs from disk. + */ +gboolean graptos_plugin_context_is_modified(GraptosPluginCommandContext *context); + /** * @brief Insert text at the active cursor. * @param context Command context supplied by Graptoς. @@ -250,6 +346,38 @@ gboolean graptos_plugin_context_insert_text(GraptosPluginCommandContext *context gboolean graptos_plugin_context_replace_selection(GraptosPluginCommandContext *context, const char *text); +/** + * @brief Replace the entire active editor text. + * @details This is intended for formatter-style plugins. Graptoς owns the GTK + * buffer edit so undo state and editor refreshes stay consistent. + * @param context Command context supplied by Graptoς. + * @param text New complete buffer text. + * @return TRUE when the buffer was updated. + */ +gboolean graptos_plugin_context_replace_text(GraptosPluginCommandContext *context, + const char *text); + +/** + * @brief Clear diagnostics currently shown in the active editor. + * @details External tool plugins can clear stale results before publishing + * fresh diagnostics from a validation run. + * @param context Command context supplied by Graptoς. + */ +void graptos_plugin_context_clear_diagnostics(GraptosPluginCommandContext *context); + +/** + * @brief Add a diagnostic to one editor line. + * @details The line is one-based to match command-line tool output and the + * editor gutter. Graptoς clamps the range to the live buffer. + * @param context Command context supplied by Graptoς. + * @param line One-based target line. + * @param message Diagnostic message shown on hover. + * @return TRUE when the diagnostic was applied. + */ +gboolean graptos_plugin_context_add_line_diagnostic(GraptosPluginCommandContext *context, + guint line, + const char *message); + /** * @brief Show command output in a Graptoς dialog. * @param context Command context supplied by Graptoς. @@ -262,6 +390,43 @@ void graptos_plugin_context_show_output(GraptosPluginCommandContext *context, const char *heading, const char *body); +/** + * @brief Show plugin text in the active editor preview pane. + * @details Plugins use this for derived reports that belong beside the editor + * instead of in a transient dialog. Graptoς owns the preview widgets + * and copies both strings before returning. + * @param context Command context supplied by Graptoς. + * @param title Preview title. + * @param body Plain-text preview body. + */ +void graptos_plugin_context_show_preview(GraptosPluginCommandContext *context, + const char *title, + const char *body); + +/** + * @brief Set active editor preview visibility. + * @details This lets report plugins bring the side preview into view without + * exposing the preview widget tree through the plugin ABI. + * @param context Command context supplied by Graptoς. + * @param visible TRUE to show preview, FALSE to hide it. + */ +void graptos_plugin_context_set_preview_visible(GraptosPluginCommandContext *context, + gboolean visible); + +/** + * @brief Export plugin supplied LaTeX to a PDF. + * @details Graptoς writes the source into a build directory beside the active + * file, runs a detected LaTeX engine without shell expansion, and + * opens the generated PDF. + * @param context Command context supplied by Graptoς. + * @param basename Safe base name for generated files. + * @param latex_source Complete LaTeX source. + * @return TRUE when a PDF was generated and opened. + */ +gboolean graptos_plugin_context_export_latex_pdf(GraptosPluginCommandContext *context, + const char *basename, + const char *latex_source); + /** * @brief Set the Graptoς status text. * @param context Command context supplied by Graptoς. diff --git a/src/syntax.c b/src/syntax.c index 67dc15c..1498918 100644 --- a/src/syntax.c +++ b/src/syntax.c @@ -281,6 +281,31 @@ static gboolean text_has_token(const char *text, const char *token) { return g_strstr_len(text, 16384, token) != NULL; } +/** + * @brief Detect Beancount content before generic YAML. + * @details Unsaved ledgers do not have a `.bean` or `.beancount` extension yet. + * A dated directive or account root is specific enough to pick + * Beancount while the user types, and it avoids the generic colon + * rule from selecting YAML first. + * @param text The text fragment supplied by the caller. + * @return TRUE when the text looks like a Beancount ledger. + */ +static gboolean text_looks_like_beancount(const char *text) { + if (!text || text[0] == '\0') return FALSE; + static const char *dated_directive = + "(?m)^[0-9]{4}-[0-9]{2}-[0-9]{2}[ \\t]+" + "(open|close|commodity|pad|balance|event|price|note|document|query|custom|txn|[*!&?#%])" + "([ \\t]|$)"; + static const char *account_root = + "(?m)(^|[ \\t])(Assets|Liabilities|Equity|Income|Expenses):" + "[A-Z][A-Za-z0-9_-]*"; + static const char *ledger_option = + "(?m)^(option|plugin|include|pushtag|poptag)[ \\t]+"; + return g_regex_match_simple(dated_directive, text, 0, 0) || + g_regex_match_simple(account_root, text, 0, 0) || + g_regex_match_simple(ledger_option, text, 0, 0); +} + /** * @brief Syntax for content. * @details Syntax data comes from YAML rules but is applied to live buffers. The comment calls out the narrow contract between static language metadata and mutable editor state. @@ -308,6 +333,8 @@ SyntaxDef *syntax_for_content(GPtrArray *syntaxes, GtkTextBuffer *buffer) { syntax = syntax_by_name(syntaxes, "Python"); } else if (text_has_token(text, "#include") || text_has_token(text, "int main(") || text_has_token(text, "int main (")) { syntax = syntax_by_name(syntaxes, "C"); + } else if (text_looks_like_beancount(text)) { + syntax = syntax_by_name(syntaxes, "Beancount"); } else if (trimmed && (g_str_has_prefix(trimmed, "---") || text_has_token(text, ": ") || text_has_token(text, ":\n"))) { syntax = syntax_by_name(syntaxes, "YAML"); } else if (trimmed && (g_str_has_prefix(trimmed, "# ") || g_str_has_prefix(trimmed, "## ") || text_has_token(text, "```"))) { diff --git a/src/syntax_loader.c b/src/syntax_loader.c index ef56f03..1aa8f03 100644 --- a/src/syntax_loader.c +++ b/src/syntax_loader.c @@ -937,12 +937,18 @@ GPtrArray *syntax_load_all_for_roots(GPtrArray *project_roots) { char *local = g_build_filename(cwd, "syntax", NULL); char *parent = g_path_get_dirname(cwd); char *parent_syntax = g_build_filename(parent, "syntax", NULL); + char *exe_path = g_file_read_link("/proc/self/exe", NULL); + char *exe_dir = exe_path ? g_path_get_dirname(exe_path) : NULL; + char *exe_parent = exe_dir ? g_path_get_dirname(exe_dir) : NULL; + char *exe_syntax = exe_dir ? g_build_filename(exe_dir, "syntax", NULL) : NULL; + char *exe_parent_syntax = exe_parent ? g_build_filename(exe_parent, "syntax", NULL) : NULL; char *system = g_build_filename(DATADIR, "syntax", NULL); /* * Earlier directories win when duplicate language names are removed below. * User config remains highest priority, then open project roots, then - * process-local development folders, then installed system syntaxes. + * process-local and executable-local development folders, then installed + * system syntaxes. */ load_syntax_dir(syntaxes, config_syntax); load_syntax_dir(syntaxes, config_root); @@ -955,10 +961,13 @@ GPtrArray *syntax_load_all_for_roots(GPtrArray *project_roots) { } load_syntax_dir(syntaxes, local); load_syntax_dir(syntaxes, parent_syntax); + load_syntax_dir(syntaxes, exe_syntax); + load_syntax_dir(syntaxes, exe_parent_syntax); { GraptosPluginRegistry *plugins = graptos_plugin_registry_new(); if (plugins) { if (graptos_plugin_registry_discover(plugins, NULL)) { + graptos_plugin_registry_apply_user_state(plugins); GPtrArray *plugin_dirs = graptos_plugin_registry_contribution_dirs(plugins, GRAPTOS_PLUGIN_CONTRIBUTION_SYNTAX); @@ -981,6 +990,11 @@ GPtrArray *syntax_load_all_for_roots(GPtrArray *project_roots) { g_free(local); g_free(parent); g_free(parent_syntax); + g_free(exe_path); + g_free(exe_dir); + g_free(exe_parent); + g_free(exe_syntax); + g_free(exe_parent_syntax); g_free(system); remove_duplicate_syntaxes(syntaxes); diff --git a/src/terminal_panel.c b/src/terminal_panel.c index a9d5aff..65050e9 100644 --- a/src/terminal_panel.c +++ b/src/terminal_panel.c @@ -939,6 +939,7 @@ static void terminal_panel_close_clicked(GtkWidget *widget, gpointer user_data) TerminalPanel *panel = user_data; if (!panel || !panel->revealer) return; gtk_revealer_set_reveal_child(GTK_REVEALER(panel->revealer), FALSE); + app_window_hide_bottom_pane_if_empty(panel->win); } /** @@ -952,11 +953,12 @@ static void terminal_panel_revealer_child_revealed(GtkRevealer *revealer, GParamSpec *pspec, gpointer user_data) { (void)pspec; - (void)user_data; + TerminalPanel *panel = user_data; if (!revealer) return; if (!gtk_revealer_get_reveal_child(revealer) && !gtk_revealer_get_child_revealed(revealer)) { gtk_widget_set_visible(GTK_WIDGET(revealer), FALSE); + app_window_hide_bottom_pane_if_empty(panel->win); } } @@ -1035,13 +1037,13 @@ TerminalPanel *terminal_panel_new(EditorWindow *win) { gtk_revealer_set_transition_duration(GTK_REVEALER(panel->revealer), 120u); gtk_revealer_set_reveal_child(GTK_REVEALER(panel->revealer), FALSE); gtk_widget_set_visible(panel->revealer, FALSE); - gtk_widget_set_vexpand(panel->revealer, FALSE); + gtk_widget_set_vexpand(panel->revealer, TRUE); g_signal_connect(panel->revealer, "notify::child-revealed", G_CALLBACK(terminal_panel_revealer_child_revealed), panel); panel->box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_widget_set_vexpand(panel->box, FALSE); + gtk_widget_set_vexpand(panel->box, TRUE); gtk_widget_add_css_class(panel->box, "graptos-terminal-panel"); GtkWidget *header = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); @@ -1080,7 +1082,7 @@ TerminalPanel *terminal_panel_new(EditorWindow *win) { panel->notebook = gtk_notebook_new(); gtk_notebook_set_scrollable(GTK_NOTEBOOK(panel->notebook), TRUE); - gtk_widget_set_vexpand(panel->notebook, FALSE); + gtk_widget_set_vexpand(panel->notebook, TRUE); gtk_widget_set_hexpand(panel->notebook, TRUE); g_signal_connect(panel->notebook, "switch-page", G_CALLBACK(terminal_panel_switch_page), panel); @@ -1173,9 +1175,11 @@ void terminal_panel_toggle(TerminalPanel *panel) { gtk_revealer_get_reveal_child(GTK_REVEALER(panel->revealer)); if (visible) { gtk_revealer_set_reveal_child(GTK_REVEALER(panel->revealer), FALSE); + app_window_hide_bottom_pane_if_empty(panel->win); return; } + app_window_show_bottom_pane(panel->win); gtk_widget_set_visible(panel->revealer, TRUE); gtk_revealer_set_reveal_child(GTK_REVEALER(panel->revealer), TRUE); terminal_panel_apply_colors(panel); diff --git a/src/ui/ui_base_css.inc.c b/src/ui/ui_base_css.inc.c index 919494d..d1c802a 100644 --- a/src/ui/ui_base_css.inc.c +++ b/src/ui/ui_base_css.inc.c @@ -204,6 +204,7 @@ void graptos_apply_css(void) { ".graptos-git-status-staged { color: currentColor; }" ".graptos-search-panel { border-top: 1px solid alpha(currentColor, 0.18); padding: 5px 8px; }" ".graptos-tool-panel { border-top: 1px solid alpha(currentColor, 0.18); padding: 5px 8px; min-height: 34px; }" + ".graptos-plugin-hub-scroll { border: 1px solid alpha(currentColor, 0.16); }" ".graptos-tool-title { opacity: 0.72; font-size: 8pt; font-weight: 700; padding: 0 4px; }" ".graptos-bottom { border-top: 1px solid alpha(currentColor, 0.18); padding: 3px 6px; }" ".graptos-bottom-controls { border: none; min-height: 34px; }" diff --git a/src/ui/ui_editor_css.inc.c b/src/ui/ui_editor_css.inc.c index 767ebb1..7fd8285 100644 --- a/src/ui/ui_editor_css.inc.c +++ b/src/ui/ui_editor_css.inc.c @@ -846,6 +846,18 @@ void graptos_apply_editor_css(const char *editor_bg_color, ".graptos-window .graptos-root.graptos-dialog-root textview, " ".graptos-window .graptos-root.graptos-dialog-root textview text { " "background: %s; background-color: %s; color: %s; }\n" + "window.graptos-window.graptos-plugin-manager-window, " + "window.graptos-window.graptos-plugin-manager-window > contents, " + "window.graptos-window .graptos-plugin-manager-root, " + "window.graptos-window .graptos-plugin-manager-scroll, " + "window.graptos-window .graptos-plugin-manager-scroll viewport, " + "window.graptos-window .graptos-plugin-manager-list, " + "window.graptos-window .graptos-plugin-manager-row { " + "background: %s; background-color: %s; color: %s; " + "border-radius: 0; border: none; box-shadow: none; outline: none; }\n" + "window.graptos-window .graptos-plugin-manager-root label, " + "window.graptos-window .graptos-plugin-manager-list label, " + "window.graptos-window .graptos-plugin-manager-row label { color: %s; }\n" ".graptos-window .graptos-dialog-output, " ".graptos-window .graptos-dialog-output text, " ".graptos-window textview.graptos-dialog-output, " @@ -873,6 +885,10 @@ void graptos_apply_editor_css(const char *editor_bg_color, effective_dialog_input_fg, effective_dialog_bg, effective_dialog_bg, + effective_dialog_fg, + effective_dialog_fg, + effective_dialog_bg, + effective_dialog_bg, effective_dialog_output); g_string_append_printf(css, "window.graptos-window .graptos-git-output-dialog-root, " diff --git a/syntax/beancount.yaml b/syntax/beancount.yaml index 3c48714..7ee7c0e 100644 --- a/syntax/beancount.yaml +++ b/syntax/beancount.yaml @@ -52,45 +52,57 @@ rules: - name: comment scope: comment pattern: ";.*$" - color: "#6A9955" + color: "#928374" italic: true + - name: string scope: string pattern: "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'" - color: "#CE9178" + color: "#B8BB26" + - name: date pattern: "(?)", "{=>}", "[=>]", '"=>"'] +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 "] diff --git a/tests/unit_tests.c b/tests/unit_tests.c index a578e9d..6beca74 100644 --- a/tests/unit_tests.c +++ b/tests/unit_tests.c @@ -185,6 +185,49 @@ static void test_syntax_diagnostics_summary(void) { g_ptr_array_unref(line_close_pairs); } +/** + * @brief Verifies bundled Beancount syntax is loadable. + * @details The Beancount syntax has real regex rules and file extensions, so + * loading it in tests catches missing bundled data and invalid syntax + * definitions before users see plain text for ledger files. + */ +static void test_syntax_beancount_loaded(void) { + GPtrArray *syntaxes = syntax_load_all(); + g_assert_nonnull(syntaxes); + + SyntaxDef *bean = syntax_for_path(syntaxes, "/tmp/example.bean"); + SyntaxDef *beancount = syntax_for_path(syntaxes, "/tmp/example.beancount"); + g_assert_nonnull(bean); + g_assert_nonnull(beancount); + g_assert_cmpstr(bean->name, ==, "Beancount"); + g_assert_cmpstr(beancount->name, ==, "Beancount"); + g_assert_cmpuint(bean->rules ? bean->rules->len : 0u, >, 0u); + + g_ptr_array_free(syntaxes, TRUE); +} + +/** + * @brief Verifies unsaved Beancount content is detected while typing. + * @details Beancount ledgers use colon-heavy account names, so the content + * detector must classify them before the generic YAML fallback runs. + */ +static void test_syntax_beancount_content_detection(void) { + GPtrArray *syntaxes = syntax_load_all(); + g_assert_nonnull(syntaxes); + + GtkTextBuffer *buffer = gtk_text_buffer_new(NULL); + gtk_text_buffer_set_text(buffer, + "2026-08-02 open Assets:Cash USD\n" + "2026-08-02 balance Assets:Cash 10 USD\n", + -1); + SyntaxDef *syntax = syntax_for_content(syntaxes, buffer); + g_assert_nonnull(syntax); + g_assert_cmpstr(syntax->name, ==, "Beancount"); + + g_object_unref(buffer); + g_ptr_array_free(syntaxes, TRUE); +} + /** * @brief Create a minimal C-like syntax for formatter tests. * @details The fixture uses the same structural fields as real syntax files so @@ -913,6 +956,8 @@ int main(int argc, char **argv) { g_test_add_func("/codex/protocol/rejects_invalid_json", test_codex_protocol_rejects_invalid_json); g_test_add_func("/syntax/diagnostics/empty", test_syntax_diagnostics_empty); g_test_add_func("/syntax/diagnostics/summary", test_syntax_diagnostics_summary); + g_test_add_func("/syntax/beancount/loaded", test_syntax_beancount_loaded); + g_test_add_func("/syntax/beancount/content-detection", test_syntax_beancount_content_detection); g_test_add_func("/formatter/disabled", test_formatter_disabled); g_test_add_func("/formatter/one-line-function", test_formatter_one_line_function); g_test_add_func("/formatter/for-semicolons", test_formatter_for_semicolons);