diff --git a/.gitignore b/.gitignore index a3a763d..cab499e 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ dkms.conf build/ .zig-cache/ git_check.sh +.graptos/ # Github actions .github/ diff --git a/Makefile b/Makefile index dd338c0..95a9913 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ APP_NAME := graptos APP_ID := io.github.graptos.Editor -VERSION := 0.23.66 +VERSION := 0.23.67 PREFIX ?= /usr/local CC ?= cc BUILD_DIR := build @@ -26,6 +26,8 @@ SOURCES := \ $(SRC_DIR)/editor_tab_latex.c \ $(SRC_DIR)/editor_tab_hover.c \ $(SRC_DIR)/editor_tab_completion.c \ + $(SRC_DIR)/editor_notes.c \ + $(SRC_DIR)/editor_tab_notes.c \ $(SRC_DIR)/editor_tab_keys.c \ $(SRC_DIR)/editor_tab_diagnostics.c \ $(SRC_DIR)/editor_tab_edit.c \ @@ -127,7 +129,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 | $(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)/editor_notes.c | $(BUILD_DIR) $(CC) $(CPPFLAGS) $(CFLAGS) $(WARNINGS) $(GTK_SYSTEM_CFLAGS) -std=c11 $^ $(GTK_LIBS) -o $@ smoke-test: $(BUILD_DIR)/smoke_window diff --git a/data/themes/apprentice.css b/data/themes/apprentice.css index 2304227..88f1537 100644 --- a/data/themes/apprentice.css +++ b/data/themes/apprentice.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #bcbcbc; @define-color graptos_editor_gutter_bg #1c1c1c; @define-color graptos_editor_gutter_fg #6c6c6c; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #303030; @define-color graptos_editor_selection_bg #444444; @define-color graptos_editor_selection_fg #ffffff; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/dracula.css b/data/themes/dracula.css index 3665049..368df39 100644 --- a/data/themes/dracula.css +++ b/data/themes/dracula.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #f8f8f2; @define-color graptos_editor_gutter_bg #21222c; @define-color graptos_editor_gutter_fg #6272a4; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #44475a; @define-color graptos_editor_selection_bg #44475a; @define-color graptos_editor_selection_fg #f8f8f2; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/github.css b/data/themes/github.css index 116bb2f..a764f78 100644 --- a/data/themes/github.css +++ b/data/themes/github.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #c9d1d9; @define-color graptos_editor_gutter_bg #010409; @define-color graptos_editor_gutter_fg #8b949e; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #161b22; @define-color graptos_editor_selection_bg #264f78; @define-color graptos_editor_selection_fg #ffffff; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/graptos.css b/data/themes/graptos.css index f3e003c..6be0eac 100644 --- a/data/themes/graptos.css +++ b/data/themes/graptos.css @@ -9,6 +9,13 @@ /* GRAPTOS THEME BEGIN */ @define-color graptos_editor_bg #181a1f; @define-color graptos_editor_fg #d4d4d4; +@define-color graptos_editor_gutter_bg #181a1f; +@define-color graptos_editor_gutter_fg #8b949e; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_sidebar_bg #111318; @define-color graptos_tabbar_bg #111318; @define-color graptos_tabbar_border #00000000; @@ -115,6 +122,17 @@ popover.graptos-hover-popover contents { background-color: @graptos_popover_bg; } +popover.graptos-note-popover, +popover.graptos-note-popover contents, +popover.graptos-note-popover textview, +popover.graptos-note-popover textview text, +popover.graptos-note-popover .graptos-note-status { + background: @graptos_note_popover_bg; + background-color: @graptos_note_popover_bg; + color: @graptos_note_popover_fg; + border-color: @graptos_note_popover_border; +} + .graptos-completion-list row:selected, .graptos-completion-list row:selected label, .graptos-completion-list row:selected .graptos-completion-title, diff --git a/data/themes/gruvbox.css b/data/themes/gruvbox.css index 8786f00..44f7092 100644 --- a/data/themes/gruvbox.css +++ b/data/themes/gruvbox.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #ebdbb2; @define-color graptos_editor_gutter_bg #1d2021; @define-color graptos_editor_gutter_fg #928374; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #32302f; @define-color graptos_editor_selection_bg #504945; @define-color graptos_editor_selection_fg #fbf1c7; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/moonfly.css b/data/themes/moonfly.css index 922e101..3975650 100644 --- a/data/themes/moonfly.css +++ b/data/themes/moonfly.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #bdbdbd; @define-color graptos_editor_gutter_bg #0f0f0f; @define-color graptos_editor_gutter_fg #626262; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #1c1c1c; @define-color graptos_editor_selection_bg #b2ceee; @define-color graptos_editor_selection_fg #080808; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/srcery.css b/data/themes/srcery.css index e0ec96a..86410e7 100644 --- a/data/themes/srcery.css +++ b/data/themes/srcery.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #fce8c3; @define-color graptos_editor_gutter_bg #121212; @define-color graptos_editor_gutter_fg #918175; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #2d2b28; @define-color graptos_editor_selection_bg #3a3935; @define-color graptos_editor_selection_fg #fce8c3; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/data/themes/tokyonight.css b/data/themes/tokyonight.css index d4a409c..66b2f82 100644 --- a/data/themes/tokyonight.css +++ b/data/themes/tokyonight.css @@ -4,6 +4,11 @@ @define-color graptos_editor_fg #c0caf5; @define-color graptos_editor_gutter_bg #15161e; @define-color graptos_editor_gutter_fg #565f89; +@define-color graptos_note_indicator #f9c74f; +@define-color graptos_note_indicator_active #ffd166; +@define-color graptos_note_popover_bg #181a1f; +@define-color graptos_note_popover_fg #d4d4d4; +@define-color graptos_note_popover_border #3a4050; @define-color graptos_editor_current_line_bg #24283b; @define-color graptos_editor_selection_bg #33467c; @define-color graptos_editor_selection_fg #c0caf5; @@ -86,6 +91,7 @@ .graptos-root { background: @graptos_editor_bg; color: @graptos_editor_fg; } .graptos-editor, .graptos-editor text, textview.graptos-editor, textview.graptos-editor text, sourceview.graptos-editor, sourceview.graptos-editor text { background: @graptos_editor_bg; color: @graptos_editor_fg; caret-color: @graptos_editor_cursor; } sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { background: @graptos_editor_gutter_bg; color: @graptos_editor_gutter_fg; } +.graptos-note-popover, .graptos-note-popover textview, .graptos-note-popover textview text, .graptos-note-popover .graptos-note-input, .graptos-note-popover .graptos-note-status { background: @graptos_note_popover_bg; color: @graptos_note_popover_fg; border-color: @graptos_note_popover_border; } sourceview.graptos-editor text selection { background: @graptos_editor_selection_bg; color: @graptos_editor_selection_fg; } .graptos-top { background: @graptos_topbar_bg; color: @graptos_topbar_fg; } .graptos-bottom, .graptos-search-panel, .graptos-tool-panel { background: @graptos_bottombar_bg; color: @graptos_bottombar_fg; } diff --git a/src/app.h b/src/app.h index 1bb931b..87d6a03 100644 --- a/src/app.h +++ b/src/app.h @@ -96,6 +96,11 @@ typedef struct _EditorWindow { char *editor_fg_color; /**< Editor fg color. */ char *editor_gutter_bg_color; /**< Editor gutter bg color. */ char *editor_gutter_fg_color; /**< Editor gutter fg color. */ + char *note_indicator_color; /**< Note indicator color. */ + char *note_indicator_active_color; /**< Active note indicator color. */ + char *note_popover_bg_color; /**< Note popover background color. */ + char *note_popover_fg_color; /**< Note popover foreground color. */ + char *note_popover_border_color; /**< Note popover border color. */ char *editor_current_line_bg_color; /**< Editor current line bg color. */ char *editor_selection_bg_color; /**< Editor selection bg color. */ char *editor_selection_fg_color; /**< Editor selection fg color. */ diff --git a/src/app/app_about_view_actions.inc.c b/src/app/app_about_view_actions.inc.c index f402402..6bfa8cb 100644 --- a/src/app/app_about_view_actions.inc.c +++ b/src/app/app_about_view_actions.inc.c @@ -229,6 +229,7 @@ void action_shortcuts(GtkWidget *widget, gpointer user_data) { {"Ctrl+K", "Cut line"}, {"Ctrl+U", "Paste cut line"}, {"Ctrl+/", "Comment or uncomment line"}, + {"Ctrl+Shift+M", "Add note at cursor or selection"}, {"Tab", "Indent selection, accept completion, or insert indentation"}, {"Shift+Tab", "Unindent selection or line"}, {"Enter", "Insert newline with editor indentation handling"}, @@ -349,6 +350,11 @@ typedef struct { char *editor_fg_color; /**< Editor foreground color. */ char *editor_gutter_bg_color; /**< Editor gutter background color. */ char *editor_gutter_fg_color; /**< Editor gutter foreground color. */ + char *note_indicator_color; /**< Note indicator color. */ + char *note_indicator_active_color; /**< Active note indicator color. */ + char *note_popover_bg_color; /**< Note popover background. */ + char *note_popover_fg_color; /**< Note popover foreground. */ + char *note_popover_border_color; /**< Note popover border. */ char *editor_current_line_bg_color; /**< Editor current line background. */ char *editor_selection_bg_color; /**< Editor selection background. */ char *editor_selection_fg_color; /**< Editor selection foreground. */ @@ -514,6 +520,11 @@ static ThemeState *theme_state_from_window(EditorWindow *win) { COPY_FIELD(editor_fg_color); COPY_FIELD(editor_gutter_bg_color); COPY_FIELD(editor_gutter_fg_color); + COPY_FIELD(note_indicator_color); + COPY_FIELD(note_indicator_active_color); + COPY_FIELD(note_popover_bg_color); + COPY_FIELD(note_popover_fg_color); + COPY_FIELD(note_popover_border_color); COPY_FIELD(editor_current_line_bg_color); COPY_FIELD(editor_selection_bg_color); COPY_FIELD(editor_selection_fg_color); @@ -612,6 +623,11 @@ static void theme_state_free(ThemeState *state) { FREE_FIELD(editor_fg_color); FREE_FIELD(editor_gutter_bg_color); FREE_FIELD(editor_gutter_fg_color); + FREE_FIELD(note_indicator_color); + FREE_FIELD(note_indicator_active_color); + FREE_FIELD(note_popover_bg_color); + FREE_FIELD(note_popover_fg_color); + FREE_FIELD(note_popover_border_color); FREE_FIELD(editor_current_line_bg_color); FREE_FIELD(editor_selection_bg_color); FREE_FIELD(editor_selection_fg_color); @@ -713,6 +729,11 @@ static void theme_state_apply_to_window(ThemeState *state, EditorWindow *win) { APPLY_FIELD(editor_fg_color); APPLY_FIELD(editor_gutter_bg_color); APPLY_FIELD(editor_gutter_fg_color); + APPLY_FIELD(note_indicator_color); + APPLY_FIELD(note_indicator_active_color); + APPLY_FIELD(note_popover_bg_color); + APPLY_FIELD(note_popover_fg_color); + APPLY_FIELD(note_popover_border_color); APPLY_FIELD(editor_current_line_bg_color); APPLY_FIELD(editor_selection_bg_color); APPLY_FIELD(editor_selection_fg_color); @@ -830,6 +851,11 @@ static void theme_state_load_key_file(ThemeState *state, GKeyFile *key_file) { LOAD_THEME_COLOR("foreground_color", editor_fg_color); LOAD_THEME_COLOR("editor_gutter_background_color", editor_gutter_bg_color); LOAD_THEME_COLOR("editor_gutter_foreground_color", editor_gutter_fg_color); + LOAD_THEME_COLOR("note_indicator_color", note_indicator_color); + LOAD_THEME_COLOR("note_indicator_active_color", note_indicator_active_color); + LOAD_THEME_COLOR("note_popover_background_color", note_popover_bg_color); + LOAD_THEME_COLOR("note_popover_foreground_color", note_popover_fg_color); + LOAD_THEME_COLOR("note_popover_border_color", note_popover_border_color); LOAD_THEME_COLOR("editor_current_line_background_color", editor_current_line_bg_color); LOAD_THEME_COLOR("editor_selection_background_color", editor_selection_bg_color); LOAD_THEME_COLOR("editor_selection_foreground_color", editor_selection_fg_color); @@ -976,6 +1002,11 @@ static void theme_state_load_css(ThemeState *state, const char *css) { LOAD_CSS_COLOR("editor_fg", editor_fg_color); LOAD_CSS_COLOR("editor_gutter_bg", editor_gutter_bg_color); LOAD_CSS_COLOR("editor_gutter_fg", editor_gutter_fg_color); + LOAD_CSS_COLOR("note_indicator", note_indicator_color); + LOAD_CSS_COLOR("note_indicator_active", note_indicator_active_color); + LOAD_CSS_COLOR("note_popover_bg", note_popover_bg_color); + LOAD_CSS_COLOR("note_popover_fg", note_popover_fg_color); + LOAD_CSS_COLOR("note_popover_border", note_popover_border_color); LOAD_CSS_COLOR("editor_current_line_bg", editor_current_line_bg_color); LOAD_CSS_COLOR("editor_selection_bg", editor_selection_bg_color); LOAD_CSS_COLOR("editor_selection_fg", editor_selection_fg_color); @@ -1139,6 +1170,9 @@ static void theme_preview_update(ThemeDialogState *dialog) { ".graptos-theme-preview-status-error { color: %s; font-weight: 800; }\n" ".graptos-theme-preview-editor { background: %s; color: %s; padding: 8px; }\n" ".graptos-theme-preview-gutter { background: %s; color: %s; padding: 8px; }\n" + ".graptos-theme-preview-note-dot { color: %s; font-weight: 800; }\n" + ".graptos-theme-preview-note-dot-active { color: %s; font-weight: 800; }\n" + ".graptos-theme-preview-note-popover { background: %s; color: %s; border: 1px solid %s; padding: 8px; }\n" ".graptos-theme-preview-current { background: %s; padding: 2px; }\n" ".graptos-theme-preview-selection { background: %s; color: %s; padding: 2px; }\n" ".graptos-theme-preview-cursor { background: %s; color: %s; padding: 1px 3px; }\n" @@ -1189,6 +1223,11 @@ static void theme_preview_update(ThemeDialogState *dialog) { theme_color(s->status_error_color, "#ff6b6b"), bg, fg, theme_color(s->editor_gutter_bg_color, bg), theme_color(s->editor_gutter_fg_color, "#8b949e"), + theme_color(s->note_indicator_color, "#f9c74f"), + theme_color(s->note_indicator_active_color, "#ffd166"), + theme_color(s->note_popover_bg_color, "#181a1f"), + theme_color(s->note_popover_fg_color, fg), + theme_color(s->note_popover_border_color, "#3a4050"), theme_color(s->editor_current_line_bg_color, "#20232b"), theme_color(s->editor_selection_bg_color, "#3a405c"), theme_color(s->editor_selection_fg_color, "#ffffff"), theme_color(s->editor_cursor_color, "#ffffff"), bg, @@ -2220,7 +2259,7 @@ static GtkWidget *theme_preview_new(void) { theme_preview_section(box, "Editor"); GtkWidget *editor = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - GtkWidget *gutter = gtk_label_new(" 1\n 2\n 3"); + GtkWidget *gutter = gtk_label_new(" 1 •\n 2\n 3 •"); gtk_widget_add_css_class(gutter, "graptos-theme-preview-gutter"); gtk_box_append(GTK_BOX(editor), gutter); GtkWidget *code = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); @@ -2229,8 +2268,11 @@ static GtkWidget *theme_preview_new(void) { theme_preview_label(code, " return 0; ← current line", "graptos-theme-preview-current"); theme_preview_label(code, "selected text", "graptos-theme-preview-selection"); theme_preview_label(code, "cursor sample |", "graptos-theme-preview-cursor"); + theme_preview_label(code, "note marker", "graptos-theme-preview-note-dot"); + theme_preview_label(code, "active note marker", "graptos-theme-preview-note-dot-active"); gtk_box_append(GTK_BOX(editor), code); gtk_box_append(GTK_BOX(box), editor); + theme_preview_label(box, "Note popover\n[[src/app.c]]", "graptos-theme-preview-note-popover"); theme_preview_section(box, "Project Tree and Git"); theme_preview_label(box, "Project Tree\nM src/app.c\n? notes.md", "graptos-theme-preview-project"); @@ -2315,6 +2357,11 @@ static GtkWidget *theme_controls_new(ThemeDialogState *dialog) { theme_append_color(box, dialog, "Editor foreground", &s->editor_fg_color); theme_append_color(box, dialog, "Gutter background", &s->editor_gutter_bg_color); theme_append_color(box, dialog, "Gutter foreground", &s->editor_gutter_fg_color); + theme_append_color(box, dialog, "Note indicator", &s->note_indicator_color); + theme_append_color(box, dialog, "Active note indicator", &s->note_indicator_active_color); + theme_append_color(box, dialog, "Note popover background", &s->note_popover_bg_color); + theme_append_color(box, dialog, "Note popover foreground", &s->note_popover_fg_color); + theme_append_color(box, dialog, "Note popover border", &s->note_popover_border_color); theme_append_color(box, dialog, "Current line", &s->editor_current_line_bg_color); theme_append_color(box, dialog, "Selection background", &s->editor_selection_bg_color); theme_append_color(box, dialog, "Selection foreground", &s->editor_selection_fg_color); diff --git a/src/app/app_preference_actions.inc.c b/src/app/app_preference_actions.inc.c index f6d2863..3aa2316 100644 --- a/src/app/app_preference_actions.inc.c +++ b/src/app/app_preference_actions.inc.c @@ -277,6 +277,11 @@ void action_reset_all_backgrounds(GtkWidget *widget, gpointer user_data) { g_clear_pointer(&win->editor_fg_color, g_free); g_clear_pointer(&win->editor_gutter_bg_color, g_free); g_clear_pointer(&win->editor_gutter_fg_color, g_free); + g_clear_pointer(&win->note_indicator_color, g_free); + g_clear_pointer(&win->note_indicator_active_color, g_free); + g_clear_pointer(&win->note_popover_bg_color, g_free); + g_clear_pointer(&win->note_popover_fg_color, g_free); + g_clear_pointer(&win->note_popover_border_color, g_free); g_clear_pointer(&win->editor_current_line_bg_color, g_free); g_clear_pointer(&win->editor_selection_bg_color, g_free); g_clear_pointer(&win->editor_selection_fg_color, g_free); diff --git a/src/app/app_window_lifecycle.inc.c b/src/app/app_window_lifecycle.inc.c index 5407292..35305bb 100644 --- a/src/app/app_window_lifecycle.inc.c +++ b/src/app/app_window_lifecycle.inc.c @@ -114,6 +114,11 @@ EditorWindow *app_window_new(GtkApplication *application) { win->editor_fg_color = g_strdup("#d4d4d4"); win->editor_gutter_bg_color = g_strdup("#181a1f"); win->editor_gutter_fg_color = g_strdup("#8b949e"); + win->note_indicator_color = g_strdup("#f9c74f"); + win->note_indicator_active_color = g_strdup("#ffd166"); + win->note_popover_bg_color = g_strdup("#181a1f"); + win->note_popover_fg_color = g_strdup("#d4d4d4"); + win->note_popover_border_color = g_strdup("#3a4050"); win->editor_current_line_bg_color = g_strdup("#20232b"); win->editor_selection_bg_color = g_strdup("#3a405c"); win->editor_selection_fg_color = g_strdup("#ffffff"); @@ -347,6 +352,11 @@ void app_window_free(EditorWindow *win) { g_free(win->editor_fg_color); g_free(win->editor_gutter_bg_color); g_free(win->editor_gutter_fg_color); + g_free(win->note_indicator_color); + g_free(win->note_indicator_active_color); + g_free(win->note_popover_bg_color); + g_free(win->note_popover_fg_color); + g_free(win->note_popover_border_color); g_free(win->editor_current_line_bg_color); g_free(win->editor_selection_bg_color); g_free(win->editor_selection_fg_color); diff --git a/src/app/app_window_state.inc.c b/src/app/app_window_state.inc.c index b7b3301..0b33c0a 100644 --- a/src/app/app_window_state.inc.c +++ b/src/app/app_window_state.inc.c @@ -225,6 +225,7 @@ void app_window_note_path_renamed(EditorWindow *win, app_window_set_file_locked(win, old_canonical, FALSE); app_window_set_file_locked(win, new_canonical, TRUE); } + editor_notes_rename_db(win->project_root, old_canonical, new_canonical); /* * Update open tabs that point to the renamed file or to files inside a @@ -256,8 +257,10 @@ void app_window_note_path_renamed(EditorWindow *win, } if (replacement) { + editor_notes_rename_db(win->project_root, tab_canonical, replacement); g_free(tab->file_path); tab->file_path = replacement; + editor_tab_load_notes(tab); /* * A renamed path can change the detected language, so drop manual diff --git a/src/app_core.c b/src/app_core.c index fd63e63..030ecfe 100644 --- a/src/app_core.c +++ b/src/app_core.c @@ -7,6 +7,7 @@ */ #include "app_private.h" +#include "editor_notes.h" #include diff --git a/src/app_state.c b/src/app_state.c index 8c9fc61..ebc65c4 100644 --- a/src/app_state.c +++ b/src/app_state.c @@ -21,6 +21,11 @@ void app_window_apply_css(EditorWindow *win) { graptos_apply_editor_css(win->editor_bg_color, win->editor_fg_color, win->editor_gutter_bg_color, win->editor_gutter_fg_color, + win->note_indicator_color, + win->note_indicator_active_color, + win->note_popover_bg_color, + win->note_popover_fg_color, + win->note_popover_border_color, win->editor_current_line_bg_color, win->editor_selection_bg_color, win->editor_selection_fg_color, diff --git a/src/config.c b/src/config.c index 5292146..d2d01ad 100644 --- a/src/config.c +++ b/src/config.c @@ -254,6 +254,11 @@ static char *build_managed_theme_css(EditorWindow *win) { append_theme_define(css, "editor_fg", win->editor_fg_color, "#d4d4d4"); append_theme_define(css, "editor_gutter_bg", win->editor_gutter_bg_color, "#181a1f"); append_theme_define(css, "editor_gutter_fg", win->editor_gutter_fg_color, "#8b949e"); + append_theme_define(css, "note_indicator", win->note_indicator_color, "#f9c74f"); + append_theme_define(css, "note_indicator_active", win->note_indicator_active_color, "#ffd166"); + append_theme_define(css, "note_popover_bg", win->note_popover_bg_color, "#181a1f"); + append_theme_define(css, "note_popover_fg", win->note_popover_fg_color, "#d4d4d4"); + append_theme_define(css, "note_popover_border", win->note_popover_border_color, "#3a4050"); append_theme_define(css, "editor_current_line_bg", win->editor_current_line_bg_color, "#20232b"); append_theme_define(css, "editor_selection_bg", win->editor_selection_bg_color, "#3a405c"); append_theme_define(css, "editor_selection_fg", win->editor_selection_fg_color, "#ffffff"); @@ -352,11 +357,27 @@ static char *build_managed_theme_css(EditorWindow *win) { " caret-color: @graptos_editor_cursor;\n" "}\n\n" "sourceview.graptos-editor gutter,\n" + ".graptos-gutter,\n" "sourceview.graptos-editor gutter * {\n" " background: @graptos_editor_gutter_bg;\n" " background-color: @graptos_editor_gutter_bg;\n" " color: @graptos_editor_gutter_fg;\n" "}\n\n" + ".graptos-note-popover {\n" + " background: @graptos_note_popover_bg;\n" + " background-color: @graptos_note_popover_bg;\n" + " color: @graptos_note_popover_fg;\n" + " border: 1px solid @graptos_note_popover_border;\n" + "}\n" + ".graptos-note-popover textview,\n" + ".graptos-note-popover textview text,\n" + ".graptos-note-popover .graptos-note-input,\n" + ".graptos-note-popover .graptos-note-status {\n" + " background: @graptos_note_popover_bg;\n" + " background-color: @graptos_note_popover_bg;\n" + " color: @graptos_note_popover_fg;\n" + "}\n\n"); + g_string_append(css, "sourceview.graptos-editor text selection {\n" " background: @graptos_editor_selection_bg;\n" " background-color: @graptos_editor_selection_bg;\n" @@ -574,6 +595,11 @@ gboolean graptos_theme_css_load_into_window(EditorWindow *win, LOAD_THEME_VAR("editor_fg", editor_fg_color); LOAD_THEME_VAR("editor_gutter_bg", editor_gutter_bg_color); LOAD_THEME_VAR("editor_gutter_fg", editor_gutter_fg_color); + LOAD_THEME_VAR("note_indicator", note_indicator_color); + LOAD_THEME_VAR("note_indicator_active", note_indicator_active_color); + LOAD_THEME_VAR("note_popover_bg", note_popover_bg_color); + LOAD_THEME_VAR("note_popover_fg", note_popover_fg_color); + LOAD_THEME_VAR("note_popover_border", note_popover_border_color); LOAD_THEME_VAR("editor_current_line_bg", editor_current_line_bg_color); LOAD_THEME_VAR("editor_selection_bg", editor_selection_bg_color); LOAD_THEME_VAR("editor_selection_fg", editor_selection_fg_color); @@ -803,6 +829,11 @@ void graptos_config_load(EditorWindow *win) { load_color(key_file, "foreground_color", &win->editor_fg_color); load_color(key_file, "editor_gutter_background_color", &win->editor_gutter_bg_color); load_color(key_file, "editor_gutter_foreground_color", &win->editor_gutter_fg_color); + load_color(key_file, "note_indicator_color", &win->note_indicator_color); + load_color(key_file, "note_indicator_active_color", &win->note_indicator_active_color); + load_color(key_file, "note_popover_background_color", &win->note_popover_bg_color); + load_color(key_file, "note_popover_foreground_color", &win->note_popover_fg_color); + load_color(key_file, "note_popover_border_color", &win->note_popover_border_color); load_color(key_file, "editor_current_line_background_color", &win->editor_current_line_bg_color); load_color(key_file, "editor_selection_background_color", &win->editor_selection_bg_color); load_color(key_file, "editor_selection_foreground_color", &win->editor_selection_fg_color); diff --git a/src/editor/editor_tab_context_menu.inc.c b/src/editor/editor_tab_context_menu.inc.c index f64d72a..c1cac83 100644 --- a/src/editor/editor_tab_context_menu.inc.c +++ b/src/editor/editor_tab_context_menu.inc.c @@ -211,6 +211,10 @@ on_text_view_right_click(GtkGestureClick *gesture, context_button(tab, "Auto Complete", G_CALLBACK(menu_complete))); + gtk_box_append(GTK_BOX(box), + context_button(tab, + "Add Note", + G_CALLBACK(menu_add_note))); /* * Use the click position as a tiny target rectangle so the popover opens diff --git a/src/editor/editor_tab_lifecycle.inc.c b/src/editor/editor_tab_lifecycle.inc.c index 90c6eb8..c3c2d61 100644 --- a/src/editor/editor_tab_lifecycle.inc.c +++ b/src/editor/editor_tab_lifecycle.inc.c @@ -140,6 +140,7 @@ void editor_tab_cancel_live_work(EditorTab *tab) { cancel_timeout_id(&tab->hover_hide_timeout); cancel_timeout_id(&tab->hover_lsp_fallback_timeout); cancel_timeout_id(&tab->ui_refresh_timeout); + cancel_timeout_id(&tab->note_autocomplete_timeout); if (tab->completion_popover) graptos_popover_hide(tab->completion_popover); @@ -454,6 +455,7 @@ void editor_tab_free(EditorTab *tab) { if (tab->tile_group) g_ptr_array_free(tab->tile_group, TRUE); if (tab->color_literal_tag_names) g_ptr_array_free(tab->color_literal_tag_names, TRUE); if (tab->diagnostics) g_ptr_array_free(tab->diagnostics, TRUE); + if (tab->notes) g_ptr_array_free(tab->notes, TRUE); if (tab->notebook_refs_held) { if (tab->tab_widget && GTK_IS_WIDGET(tab->tab_widget)) { diff --git a/src/editor/editor_tab_signals_preferences.inc.c b/src/editor/editor_tab_signals_preferences.inc.c index 04df877..3573b73 100644 --- a/src/editor/editor_tab_signals_preferences.inc.c +++ b/src/editor/editor_tab_signals_preferences.inc.c @@ -118,6 +118,27 @@ void on_buffer_changed(GtkTextBuffer *buffer, gpointer user_data) { if (!tab || tab->applying_change) return; gint char_count = gtk_text_buffer_get_char_count(tab->buffer); + guint line_count = (guint)gtk_text_buffer_get_line_count(tab->buffer); + if (tab->last_line_count != 0u && line_count != tab->last_line_count) { + GtkTextIter cursor; + GtkTextMark *mark = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &cursor, mark); + gint edit_line = gtk_text_iter_get_line(&cursor); + gint delta = (gint)line_count - (gint)tab->last_line_count; + for (guint i = 0u; tab->notes && i < tab->notes->len; i++) { + EditorNote *note = g_ptr_array_index(tab->notes, i); + if (!note) continue; + if (note->start_line > edit_line) { + note->start_line = MAX(0, note->start_line + delta); + note->end_line = MAX(note->start_line, note->end_line + delta); + } else if (note->end_line >= edit_line) { + note->end_line = MAX(note->start_line, note->end_line + delta); + } + } + editor_tab_save_notes(tab); + if (tab->gutter) gtk_widget_queue_draw(tab->gutter); + } + tab->last_line_count = line_count; gboolean large_file = (guint)char_count > GRAPTOS_LIVE_FEATURE_MAX_CHARS; /* diff --git a/src/editor_notes.c b/src/editor_notes.c new file mode 100644 index 0000000..37de7ad --- /dev/null +++ b/src/editor_notes.c @@ -0,0 +1,216 @@ +/** + * @file src/editor_notes.c + * @brief Text-backed editor note storage. + * @details Notes need to travel with a project without making source buffers + * dirty. The small text format here keeps that state reviewable and + * mergeable while the editor owns the transient UI around it. + */ + +#include "editor_notes.h" + +#include +#include + +/** + * @brief Escape one note field for TSV storage. + * @param text User note text. + * @return Escaped text. + */ +static char *notes_escape_text(const char *text) { + GString *out = g_string_new(NULL); + if (!out) return NULL; + for (const char *p = text ? text : ""; *p; p++) { + if (*p == '\\') g_string_append(out, "\\\\"); + else if (*p == '\n') g_string_append(out, "\\n"); + else if (*p == '\t') g_string_append(out, "\\t"); + else g_string_append_c(out, *p); + } + return g_string_free(out, FALSE); +} + +/** + * @brief Unescape one note field from TSV storage. + * @param text Escaped field text. + * @return Unescaped text. + */ +static char *notes_unescape_text(const char *text) { + GString *out = g_string_new(NULL); + if (!out) return NULL; + for (const char *p = text ? text : ""; *p; p++) { + if (*p == '\\' && p[1] != '\0') { + p++; + if (*p == 'n') g_string_append_c(out, '\n'); + else if (*p == 't') g_string_append_c(out, '\t'); + else g_string_append_c(out, *p); + } else { + g_string_append_c(out, *p); + } + } + return g_string_free(out, FALSE); +} + +void editor_note_free(gpointer data) { + EditorNote *note = data; + if (!note) return; + g_free(note->note); + g_free(note); +} + +EditorNote *editor_note_new(guint id, + gint start_line, + gint start_col, + gint end_line, + gint end_col, + const char *note) { + EditorNote *item = g_new0(EditorNote, 1); + if (!item) return NULL; + item->id = id; + item->start_line = MAX(0, start_line); + item->start_col = MAX(0, start_col); + item->end_line = MAX(item->start_line, end_line); + item->end_col = MAX(0, end_col); + item->note = g_strdup(note ? note : ""); + return item; +} + +EditorNote *editor_notes_find_for_line(GPtrArray *notes, gint line) { + if (!notes || line < 0) return NULL; + for (guint i = 0u; i < notes->len; i++) { + EditorNote *note = g_ptr_array_index(notes, i); + if (note && line >= note->start_line && line <= note->end_line) { + return note; + } + } + return NULL; +} + +guint editor_notes_next_id(GPtrArray *notes) { + guint next = 1u; + if (!notes) return next; + for (guint i = 0u; i < notes->len; i++) { + EditorNote *note = g_ptr_array_index(notes, i); + if (note && note->id >= next) next = note->id + 1u; + } + return next; +} + +/** + * @brief Return a path relative to the project root when possible. + * @param project_root Project root. + * @param file_path Source file path. + * @return Newly allocated relative or absolute path. + */ +static char *notes_relative_path(const char *project_root, const char *file_path) { + if (!file_path || file_path[0] == '\0') return NULL; + if (project_root && project_root[0] != '\0') { + g_autofree char *root = g_canonicalize_filename(project_root, NULL); + g_autofree char *path = g_canonicalize_filename(file_path, NULL); + if (root && path && g_str_has_prefix(path, root)) { + gsize len = strlen(root); + if (path[len] == G_DIR_SEPARATOR) return g_strdup(path + len + 1u); + if (path[len] == '\0') return g_path_get_basename(path); + } + } + return g_strdup(file_path); +} + +char *editor_notes_db_path(const char *project_root, const char *file_path) { + if (!project_root || project_root[0] == '\0' || !file_path || file_path[0] == '\0') { + return NULL; + } + g_autofree char *relative = notes_relative_path(project_root, file_path); + if (!relative) return NULL; + g_autofree char *basename = g_path_get_basename(file_path); + if (!basename || basename[0] == '\0') return NULL; + guint hash = g_str_hash(relative); + g_autofree char *filename = + g_strdup_printf("%s.%08x.notes.txt", basename, hash); + return g_build_filename(project_root, ".graptos", "notes", filename, NULL); +} + +/** + * @brief Parse one note database line. + * @param line TSV record. + * @return Parsed note, or NULL. + */ +static EditorNote *notes_parse_line(const char *line) { + if (!line || line[0] == '\0') return NULL; + g_auto(GStrv) parts = g_strsplit(line, "\t", 6); + if (!parts || !parts[0] || !parts[1] || !parts[2] || + !parts[3] || !parts[4] || !parts[5]) { + return NULL; + } + char *end = NULL; + guint64 id = g_ascii_strtoull(parts[0], &end, 10); + if (end == parts[0] || *end != '\0' || id == 0u || id > G_MAXUINT) return NULL; + gint64 start_line = g_ascii_strtoll(parts[1], &end, 10); + if (end == parts[1] || *end != '\0' || start_line < 0 || start_line > G_MAXINT) return NULL; + gint64 start_col = g_ascii_strtoll(parts[2], &end, 10); + if (end == parts[2] || *end != '\0' || start_col < 0 || start_col > G_MAXINT) return NULL; + gint64 end_line = g_ascii_strtoll(parts[3], &end, 10); + if (end == parts[3] || *end != '\0' || end_line < start_line || end_line > G_MAXINT) return NULL; + gint64 end_col = g_ascii_strtoll(parts[4], &end, 10); + if (end == parts[4] || *end != '\0' || end_col < 0 || end_col > G_MAXINT) return NULL; + g_autofree char *text = notes_unescape_text(parts[5]); + return editor_note_new((guint)id, (gint)start_line, (gint)start_col, + (gint)end_line, (gint)end_col, text); +} + +GPtrArray *editor_notes_load(const char *db_path) { + GPtrArray *notes = g_ptr_array_new_with_free_func(editor_note_free); + if (!notes || !db_path || db_path[0] == '\0') return notes; + g_autofree char *contents = NULL; + gsize len = 0u; + GError *error = NULL; + if (!g_file_get_contents(db_path, &contents, &len, &error)) { + if (error) g_error_free(error); + return notes; + } + g_auto(GStrv) lines = g_strsplit(contents, "\n", -1); + for (guint i = 0u; lines && lines[i]; i++) { + if (lines[i][0] == '\0') continue; + EditorNote *note = notes_parse_line(lines[i]); + if (note) g_ptr_array_add(notes, note); + } + return notes; +} + +gboolean editor_notes_save(const char *db_path, GPtrArray *notes, GError **error) { + if (!db_path || db_path[0] == '\0') return TRUE; + g_autofree char *dir = g_path_get_dirname(db_path); + if (dir && g_mkdir_with_parents(dir, 0755) != 0) { + g_set_error(error, G_FILE_ERROR, g_file_error_from_errno(errno), + "Could not create note directory %s", dir); + return FALSE; + } + GString *out = g_string_new(NULL); + if (!out) return FALSE; + for (guint i = 0u; notes && i < notes->len; i++) { + EditorNote *note = g_ptr_array_index(notes, i); + if (!note) continue; + g_autofree char *escaped = notes_escape_text(note->note); + g_string_append_printf(out, "%u\t%d\t%d\t%d\t%d\t%s\n", + note->id, + note->start_line, + note->start_col, + note->end_line, + note->end_col, + escaped ? escaped : ""); + } + g_autofree char *contents = g_string_free(out, FALSE); + return g_file_set_contents(db_path, contents ? contents : "", -1, error); +} + +void editor_notes_rename_db(const char *project_root, + const char *old_path, + const char *new_path) { + g_autofree char *old_db = editor_notes_db_path(project_root, old_path); + g_autofree char *new_db = editor_notes_db_path(project_root, new_path); + if (!old_db || !new_db || g_strcmp0(old_db, new_db) == 0) return; + if (!g_file_test(old_db, G_FILE_TEST_EXISTS)) return; + g_autofree char *dir = g_path_get_dirname(new_db); + if (dir) (void)g_mkdir_with_parents(dir, 0755); + if (g_rename(old_db, new_db) != 0 && g_file_test(new_db, G_FILE_TEST_EXISTS)) { + (void)g_unlink(old_db); + } +} diff --git a/src/editor_notes.h b/src/editor_notes.h new file mode 100644 index 0000000..42637df --- /dev/null +++ b/src/editor_notes.h @@ -0,0 +1,100 @@ +/** + * @file src/editor_notes.h + * @brief Text-backed editor note storage. + * @details Notes are project state, not editor-buffer text. This module keeps + * the portable file format and path mapping away from GTK callbacks so + * gutter UI and rename handling share one storage contract. + */ + +#ifndef GRAPTOS_EDITOR_NOTES_H +#define GRAPTOS_EDITOR_NOTES_H + +#include + +/** + * @brief One editor note range. + */ +typedef struct { + guint id; /**< Stable note id inside one file note database. */ + gint start_line; /**< Zero-based first covered line. */ + gint start_col; /**< Zero-based start column. */ + gint end_line; /**< Zero-based last covered line. */ + gint end_col; /**< Zero-based end column. */ + char *note; /**< User note text. */ +} EditorNote; + +/** + * @brief Free one editor note. + * @param data Note pointer supplied by the owning collection. + */ +void editor_note_free(gpointer data); + +/** + * @brief Create a new editor note. + * @param id Stable note id. + * @param start_line Zero-based first covered line. + * @param start_col Zero-based start column. + * @param end_line Zero-based last covered line. + * @param end_col Zero-based end column. + * @param note User note text. + * @return Newly allocated note. + */ +EditorNote *editor_note_new(guint id, + gint start_line, + gint start_col, + gint end_line, + gint end_col, + const char *note); + +/** + * @brief Return the note covering a line. + * @param notes EditorNote array. + * @param line Zero-based line. + * @return Matching note, or NULL. + */ +EditorNote *editor_notes_find_for_line(GPtrArray *notes, gint line); + +/** + * @brief Return the next id for a note array. + * @param notes EditorNote array. + * @return Id greater than every existing note id. + */ +guint editor_notes_next_id(GPtrArray *notes); + +/** + * @brief Build the project note storage path for a source file. + * @details The basename keeps files recognizable while the short hash prevents + * collisions between same-named files in different folders. + * @param project_root Project root used for relative paths. + * @param file_path Source file path. + * @return Newly allocated note database path, or NULL. + */ +char *editor_notes_db_path(const char *project_root, const char *file_path); + +/** + * @brief Load notes from a text database. + * @param db_path Note database path. + * @return Newly allocated EditorNote array. + */ +GPtrArray *editor_notes_load(const char *db_path); + +/** + * @brief Save notes to a text database. + * @param db_path Note database path. + * @param notes EditorNote array. + * @param error Return location for an optional error. + * @return TRUE on success. + */ +gboolean editor_notes_save(const char *db_path, GPtrArray *notes, GError **error); + +/** + * @brief Move a note database after a source path change. + * @param project_root Project root used for relative paths. + * @param old_path Previous source file path. + * @param new_path New source file path. + */ +void editor_notes_rename_db(const char *project_root, + const char *old_path, + const char *new_path); + +#endif /* GRAPTOS_EDITOR_NOTES_H */ diff --git a/src/editor_tab.h b/src/editor_tab.h index a82a9e4..2788139 100644 --- a/src/editor_tab.h +++ b/src/editor_tab.h @@ -13,6 +13,7 @@ #include #include "syntax.h" #include "completion.h" +#include "editor_notes.h" /** * @brief Editor tab type definition. @@ -85,6 +86,7 @@ struct _EditorTab { GPtrArray *tile_group; /**< EditorTab*: saved tile group owned by this tab. */ GPtrArray *color_literal_tag_names; /**< Color literal tag names. */ GPtrArray *diagnostics; /**< EditorDiagnostic*. */ + GPtrArray *notes; /**< EditorNote*. */ guint highlight_timeout; /**< Highlight timeout. */ guint completion_timeout; /**< Completion timeout. */ guint minimap_timeout; /**< Minimap timeout. */ @@ -101,9 +103,11 @@ struct _EditorTab { guint hover_hide_timeout; /**< Hover hide timeout. */ guint hover_lsp_fallback_timeout; /**< Hover LSP fallback timeout. */ guint ui_refresh_timeout; /**< Ui refresh timeout. */ + guint note_autocomplete_timeout; /**< Deferred note autocomplete timeout. */ guint tab_width; /**< Tab width. */ guint diagnostic_warnings; /**< Diagnostic warnings. */ guint cached_gutter_width; /**< Cached gutter width. */ + guint last_line_count; /**< Last line count observed for note shifts. */ guint lsp_version; /**< LSP document version. */ guint hover_lsp_line; /**< Hover LSP line for reference lookup. */ guint hover_lsp_character; /**< Hover LSP character for reference lookup. */ @@ -190,6 +194,20 @@ gboolean editor_tab_confirm_close(EditorTab *tab); * @param tab The editor tab whose buffer or widgets are being inspected. */ void editor_tab_update_title(EditorTab *tab); +/** + * @brief Load project notes for a tab. + * @details Notes are persisted beside the project rather than inside the + * source buffer, so path changes need a public refresh hook. + * @param tab The editor tab whose note database should be loaded. + */ +void editor_tab_load_notes(EditorTab *tab); +/** + * @brief Save project notes for a tab. + * @details Notes save independently from source text and should not mark the + * source buffer modified. + * @param tab The editor tab whose notes should be saved. + */ +void editor_tab_save_notes(EditorTab *tab); /** * @brief Set a generated display title for a tab. * @details Generated tabs, such as Git diff and Codex review tabs, do not have diff --git a/src/editor_tab_build.c b/src/editor_tab_build.c index a43fbc4..0f8957f 100644 --- a/src/editor_tab_build.c +++ b/src/editor_tab_build.c @@ -24,6 +24,7 @@ static void tab_init_state(EditorTab *tab, EditorWindow *win) { tab->undo_stack = g_ptr_array_new_with_free_func(g_free); tab->redo_stack = g_ptr_array_new_with_free_func(g_free); tab->diagnostics = g_ptr_array_new_with_free_func(editor_diagnostic_free); + tab->notes = g_ptr_array_new_with_free_func(editor_note_free); } /** @@ -72,7 +73,7 @@ static void tab_create_text_area(EditorTab *tab, EditorWindow *win) { gtk_text_view_set_bottom_margin(GTK_TEXT_VIEW(tab->text_view), 8); gtk_text_view_set_pixels_above_lines(GTK_TEXT_VIEW(tab->text_view), 3); gtk_text_view_set_pixels_below_lines(GTK_TEXT_VIEW(tab->text_view), 2); - gtk_source_view_set_show_line_numbers(GTK_SOURCE_VIEW(tab->text_view), TRUE); + gtk_source_view_set_show_line_numbers(GTK_SOURCE_VIEW(tab->text_view), FALSE); gtk_source_view_set_highlight_current_line(GTK_SOURCE_VIEW(tab->text_view), TRUE); gtk_source_view_set_auto_indent(GTK_SOURCE_VIEW(tab->text_view), FALSE); gtk_source_view_set_indent_on_tab(GTK_SOURCE_VIEW(tab->text_view), FALSE); @@ -308,7 +309,7 @@ static void tab_pack_widgets(EditorTab *tab) { tab->text_view); gtk_overlay_set_child(GTK_OVERLAY(tab->popover_parent), tab->scrolled); - gtk_widget_set_visible(tab->gutter, FALSE); + gtk_widget_set_visible(tab->gutter, TRUE); gtk_box_append(GTK_BOX(tab->editor_area), tab->gutter); gtk_widget_set_hexpand(tab->popover_parent, TRUE); gtk_widget_set_vexpand(tab->popover_parent, TRUE); @@ -478,6 +479,19 @@ static void tab_connect_signals(EditorTab *tab) { g_signal_connect(tab->buffer, "mark-set", G_CALLBACK(on_mark_set), tab); gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(tab->gutter), on_gutter_draw, tab, NULL); + GtkGesture *gutter_right_click = gtk_gesture_click_new(); + gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gutter_right_click), + GDK_BUTTON_SECONDARY); + gtk_event_controller_set_propagation_phase( + GTK_EVENT_CONTROLLER(gutter_right_click), GTK_PHASE_CAPTURE); + g_signal_connect(gutter_right_click, "pressed", + G_CALLBACK(on_gutter_right_click), tab); + gtk_widget_add_controller(tab->gutter, + GTK_EVENT_CONTROLLER(gutter_right_click)); + GtkEventController *gutter_motion = gtk_event_controller_motion_new(); + g_signal_connect(gutter_motion, "motion", + G_CALLBACK(on_gutter_motion), tab); + gtk_widget_add_controller(tab->gutter, gutter_motion); GtkAdjustment *vadj = gtk_scrolled_window_get_vadjustment( diff --git a/src/editor_tab_gutter.c b/src/editor_tab_gutter.c index 65b2b8c..c430164 100644 --- a/src/editor_tab_gutter.c +++ b/src/editor_tab_gutter.c @@ -78,7 +78,10 @@ static void gutter_draw_background(GtkWidget *widget, cairo_t *cr, static void gutter_draw_line_number(cairo_t *cr, PangoLayout *layout, gint gutter_width, gint draw_y, gint line_height, int line_no, - gboolean current_line) { + gboolean current_line, + gboolean has_note, + const char *note_color, + const char *active_note_color) { if (current_line) { cairo_set_source_rgb(cr, 0.145, 0.154, 0.170); cairo_rectangle(cr, 0.0, (double)draw_y, @@ -100,6 +103,18 @@ static void gutter_draw_line_number(cairo_t *cr, PangoLayout *layout, else cairo_set_source_rgb(cr, 0.540, 0.565, 0.600); cairo_move_to(cr, (double)draw_x, (double)text_y); pango_cairo_show_layout(cr, layout); + if (has_note) { + GdkRGBA rgba; + const char *color = current_line && active_note_color + ? active_note_color : note_color; + if (!gdk_rgba_parse(&rgba, color ? color : "#f9c74f")) { + gdk_rgba_parse(&rgba, "#f9c74f"); + } + cairo_set_source_rgba(cr, rgba.red, rgba.green, rgba.blue, rgba.alpha); + double cy = (double)draw_y + ((double)line_height / 2.0); + cairo_arc(cr, 8.0, cy, 3.0, 0.0, G_PI * 2.0); + cairo_fill(cr); + } g_free(num); } @@ -149,8 +164,11 @@ void on_gutter_draw(GtkDrawingArea *area, cairo_t *cr, int draw_width, gtk_text_view_get_line_yrange(view, &iter, &y, &line_height); if (y > visible.y + visible.height) break; + gboolean has_note = editor_notes_find_for_line(tab->notes, line_no) != NULL; gutter_draw_line_number(cr, layout, width, y - visible.y, line_height, - line_no, line_no == cursor_line); + line_no, line_no == cursor_line, has_note, + tab->win ? tab->win->note_indicator_color : NULL, + tab->win ? tab->win->note_indicator_active_color : NULL); if (line_no >= max_line) break; if (!gtk_text_iter_forward_line(&iter)) break; } diff --git a/src/editor_tab_io.c b/src/editor_tab_io.c index 1d586f0..d8a34e7 100644 --- a/src/editor_tab_io.c +++ b/src/editor_tab_io.c @@ -201,6 +201,7 @@ gboolean editor_tab_load_file(EditorTab *tab, const char *path) { g_clear_pointer(&tab->display_title, g_free); g_clear_pointer(&tab->display_title_markup, g_free); tab->file_path = g_canonicalize_filename(path, NULL); + editor_tab_load_notes(tab); cleanup_legacy_tilde_backup(tab->file_path); tab->modified = FALSE; tab->manual_syntax_override = FALSE; @@ -301,6 +302,7 @@ gboolean save_to_path(EditorTab *tab, const char *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_load_notes(tab); cleanup_legacy_tilde_backup(tab->file_path); tab->modified = FALSE; tab->manual_syntax_override = FALSE; diff --git a/src/editor_tab_keys.c b/src/editor_tab_keys.c index 9e35311..40a8986 100644 --- a/src/editor_tab_keys.c +++ b/src/editor_tab_keys.c @@ -328,6 +328,19 @@ void menu_comment(GtkWidget *w, gpointer data) { (void)w; editor_tab_toggle_comm */ void menu_complete(GtkWidget *w, gpointer data) { (void)w; editor_tab_show_completion(data, TRUE); } +/** + * @brief Menu add note. + * @details The note popover owns range detection and storage. Keeping this + * callback tiny prevents context menus and shortcuts from drifting + * into separate note creation behavior. + * @param w The w supplied by the caller. + * @param data The callback context passed by the caller. + */ +void menu_add_note(GtkWidget *w, gpointer data) { + (void)w; + editor_tab_show_add_note(data); +} + /** * @brief Tab unit. @@ -693,6 +706,12 @@ gboolean on_text_view_key_pressed(GtkEventControllerKey *controller, guint key = gdk_keyval_to_lower(keyval); gboolean alt = (state & GDK_ALT_MASK) != 0; gboolean shift = (state & GDK_SHIFT_MASK) != 0; + gboolean ctrl = (state & GDK_CONTROL_MASK) != 0; + + if (ctrl && shift && key == GDK_KEY_m) { + editor_tab_show_add_note(tab); + 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_notes.c b/src/editor_tab_notes.c new file mode 100644 index 0000000..d3a528c --- /dev/null +++ b/src/editor_tab_notes.c @@ -0,0 +1,522 @@ +/** + * @file src/editor_tab_notes.c + * @brief Graptoς editor tab notes module. + * @details Notes sit beside source text until the user explicitly converts + * them into comments. The tab owns the transient popovers, while the + * storage module owns the project-local text database format. + */ + +#include "editor_tab_private.h" + +#include + +/** + * @brief Note popover state. + */ +typedef struct { + EditorTab *tab; /**< Tab that owns the note edit. */ + EditorNote *note; /**< Existing note when editing, or NULL when adding. */ + GtkWidget *popover; /**< One-shot popover. */ + GtkWidget *view; /**< Text view used for note input. */ + GtkWidget *status; /**< Status label below the note input. */ + gint start_line; /**< Zero-based first covered line. */ + gint start_col; /**< Zero-based start column. */ + gint end_line; /**< Zero-based last covered line. */ + gint end_col; /**< Zero-based end column. */ +} NotePopoverState; + +/** + * @brief Free note popover state. + * @param data Callback data supplied by GTK. + */ +static void note_popover_state_free(gpointer data) { + NotePopoverState *state = data; + g_free(state); +} + +/** + * @brief Return the note database path for a tab. + * @param tab The editor tab whose file path is inspected. + * @return Newly allocated database path, or NULL. + */ +static char *editor_tab_note_db_path(EditorTab *tab) { + if (!tab || !tab->win || !tab->win->project_root || !tab->file_path) return NULL; + return editor_notes_db_path(tab->win->project_root, tab->file_path); +} + +void editor_tab_load_notes(EditorTab *tab) { + if (!tab) return; + g_autofree char *path = editor_tab_note_db_path(tab); + GPtrArray *loaded = editor_notes_load(path); + if (!loaded) loaded = g_ptr_array_new_with_free_func(editor_note_free); + if (tab->notes) g_ptr_array_free(tab->notes, TRUE); + tab->notes = loaded; + tab->last_line_count = tab->buffer ? (guint)gtk_text_buffer_get_line_count(tab->buffer) : 0u; + if (tab->gutter) gtk_widget_queue_draw(tab->gutter); +} + +void editor_tab_save_notes(EditorTab *tab) { + if (!tab) return; + g_autofree char *path = editor_tab_note_db_path(tab); + if (!path) { + app_window_set_status(tab->win, "Open a project before saving notes."); + return; + } + g_autoptr(GError) error = NULL; + if (!editor_notes_save(path, tab->notes, &error)) { + app_window_report_error(tab->win, "Could not save notes", + error ? error->message : path, FALSE); + } +} + +/** + * @brief Compute note range from cursor or selection. + * @param tab The editor tab whose buffer is inspected. + * @param start_line_out Output first line. + * @param start_col_out Output start column. + * @param end_line_out Output last line. + * @param end_col_out Output end column. + */ +static void note_range_from_selection(EditorTab *tab, + gint *start_line_out, + gint *start_col_out, + gint *end_line_out, + gint *end_col_out) { + GtkTextIter start; + GtkTextIter end; + gboolean has_selection = tab && tab->buffer && + gtk_text_buffer_get_selection_bounds(tab->buffer, &start, &end); + if (!has_selection) { + GtkTextMark *mark = gtk_text_buffer_get_insert(tab->buffer); + gtk_text_buffer_get_iter_at_mark(tab->buffer, &start, mark); + end = start; + } + gint start_line = gtk_text_iter_get_line(&start); + gint end_line = gtk_text_iter_get_line(&end); + gint start_col = gtk_text_iter_get_line_offset(&start); + gint end_col = gtk_text_iter_get_line_offset(&end); + if (has_selection && end_col == 0 && end_line > start_line) { + end_line--; + GtkTextIter line_end; + gtk_text_buffer_get_iter_at_line(tab->buffer, &line_end, end_line); + if (!gtk_text_iter_ends_line(&line_end)) gtk_text_iter_forward_to_line_end(&line_end); + end_col = gtk_text_iter_get_line_offset(&line_end); + } + if (start_line_out) *start_line_out = start_line; + if (start_col_out) *start_col_out = start_col; + if (end_line_out) *end_line_out = end_line; + if (end_col_out) *end_col_out = end_col; +} + +/** + * @brief Read note text from a popover text view. + * @param state Note popover state. + * @return Newly allocated note text. + */ +static char *note_popover_text(NotePopoverState *state) { + if (!state || !state->view) return g_strdup(""); + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(state->view)); + GtkTextIter start; + GtkTextIter end; + gtk_text_buffer_get_bounds(buffer, &start, &end); + return gtk_text_buffer_get_text(buffer, &start, &end, FALSE); +} + +/** + * @brief Close a note popover. + * @param state Note popover state. + */ +static void note_popover_close(NotePopoverState *state) { + if (!state || !state->popover) return; + graptos_popover_hide(state->popover); + graptos_widget_destroy(state->popover); +} + +/** + * @brief Close context popovers before a note action continues. + * @details Note actions may be launched from the text view context menu or the + * gutter context menu. Closing the origin first prevents stacked menus + * from staying visible behind the editor popover. + * @param tab The editor tab that owns possible context popovers. + * @param source Button that may carry the origin popover. + */ +static void note_close_origin_popovers(EditorTab *tab, GtkWidget *source) { + if (source) { + GtkWidget *origin = g_object_get_data(G_OBJECT(source), + "graptos-note-origin-popover"); + if (origin && GTK_IS_POPOVER(origin)) { + if (tab && tab->text_view && + g_object_get_data(G_OBJECT(tab->text_view), + "graptos-context-popover") == origin) { + g_object_set_data(G_OBJECT(tab->text_view), + "graptos-context-popover", NULL); + } + if (tab && tab->gutter && + g_object_get_data(G_OBJECT(tab->gutter), + "graptos-context-popover") == origin) { + g_object_set_data(G_OBJECT(tab->gutter), + "graptos-context-popover", NULL); + } + graptos_widget_destroy(origin); + } + } + if (tab && tab->text_view) { + GtkWidget *text_popover = + g_object_get_data(G_OBJECT(tab->text_view), "graptos-context-popover"); + if (text_popover && GTK_IS_POPOVER(text_popover)) { + graptos_widget_destroy(text_popover); + g_object_set_data(G_OBJECT(tab->text_view), + "graptos-context-popover", NULL); + } + } + if (tab && tab->gutter) { + GtkWidget *gutter_popover = + g_object_get_data(G_OBJECT(tab->gutter), "graptos-context-popover"); + if (gutter_popover && GTK_IS_POPOVER(gutter_popover)) { + graptos_widget_destroy(gutter_popover); + g_object_set_data(G_OBJECT(tab->gutter), + "graptos-context-popover", NULL); + } + } +} + +/** + * @brief Save note popover contents. + * @param button Button that emitted the signal. + * @param user_data NotePopoverState. + */ +static void note_popover_save(GtkButton *button, gpointer user_data) { + (void)button; + NotePopoverState *state = user_data; + if (!state || !state->tab) return; + g_autofree char *text = note_popover_text(state); + g_strstrip(text); + if (!text || text[0] == '\0') { + if (state->status) gtk_label_set_text(GTK_LABEL(state->status), "Note text is empty."); + return; + } + if (state->note) { + state->note->start_line = state->start_line; + state->note->start_col = state->start_col; + state->note->end_line = state->end_line; + state->note->end_col = state->end_col; + g_free(state->note->note); + state->note->note = g_strdup(text); + } else { + EditorNote *note = editor_note_new(editor_notes_next_id(state->tab->notes), + state->start_line, + state->start_col, + state->end_line, + state->end_col, + text); + if (note) g_ptr_array_add(state->tab->notes, note); + } + editor_tab_save_notes(state->tab); + if (state->tab->gutter) gtk_widget_queue_draw(state->tab->gutter); + app_window_set_status(state->tab->win, "Note saved."); + note_popover_close(state); +} + +/** + * @brief Cancel note popover edit. + * @param button Button that emitted the signal. + * @param user_data NotePopoverState. + */ +static void note_popover_cancel(GtkButton *button, gpointer user_data) { + (void)button; + note_popover_close(user_data); +} + +/** + * @brief Handle note input key presses. + * @param controller Key controller. + * @param keyval Key value. + * @param keycode Key code. + * @param state Modifier state. + * @param user_data NotePopoverState. + * @return TRUE when the key was handled. + */ +static gboolean note_input_key_pressed(GtkEventControllerKey *controller, + guint keyval, + guint keycode, + GdkModifierType state, + gpointer user_data) { + (void)controller; + (void)keycode; + NotePopoverState *popover_state = user_data; + (void)state; + if (keyval == GDK_KEY_Escape) { + note_popover_close(popover_state); + return TRUE; + } + return FALSE; +} + +/** + * @brief Show a note editor popover. + * @param tab The editor tab that owns the note. + * @param note Existing note, or NULL for a new note. + * @param line_hint Optional zero-based line used for gutter placement. + */ +static void editor_tab_show_note_popover(EditorTab *tab, + EditorNote *note, + gint line_hint) { + if (!tab || !tab->text_view || !tab->popover_parent) return; + NotePopoverState *state = g_new0(NotePopoverState, 1); + if (!state) return; + state->tab = tab; + state->note = note; + if (note) { + state->start_line = note->start_line; + state->start_col = note->start_col; + state->end_line = note->end_line; + state->end_col = note->end_col; + } else { + note_range_from_selection(tab, &state->start_line, &state->start_col, + &state->end_line, &state->end_col); + } + + GtkWidget *popover = gtk_popover_new(); + state->popover = popover; + graptos_popover_attach(popover, tab->popover_parent); + gtk_popover_set_has_arrow(GTK_POPOVER(popover), FALSE); + gtk_widget_add_css_class(popover, "graptos-note-popover"); + + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + graptos_set_all_margins(box, 8); + GtkWidget *view = gtk_text_view_new(); + state->view = view; + gtk_widget_add_css_class(view, "graptos-note-input"); + gtk_widget_set_size_request(view, 360, 130); + gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(view), GTK_WRAP_WORD_CHAR); + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); + if (note && note->note) gtk_text_buffer_set_text(buffer, note->note, -1); + GtkEventController *keys = gtk_event_controller_key_new(); + gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_CAPTURE); + g_signal_connect(keys, "key-pressed", + G_CALLBACK(note_input_key_pressed), state); + gtk_widget_add_controller(view, keys); + gtk_box_append(GTK_BOX(box), view); + + state->status = gtk_label_new("Escape closes without saving."); + gtk_widget_add_css_class(state->status, "graptos-note-status"); + gtk_label_set_xalign(GTK_LABEL(state->status), 0.0f); + gtk_box_append(GTK_BOX(box), state->status); + + GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget *cancel = graptos_flat_button_new("Cancel", NULL, + G_CALLBACK(note_popover_cancel), + state); + GtkWidget *save = graptos_flat_button_new("Save", NULL, + G_CALLBACK(note_popover_save), + state); + gtk_box_append(GTK_BOX(actions), cancel); + gtk_box_append(GTK_BOX(actions), save); + gtk_box_append(GTK_BOX(box), actions); + gtk_popover_set_child(GTK_POPOVER(popover), box); + g_object_set_data_full(G_OBJECT(popover), "graptos-note-state", + state, note_popover_state_free); + + gint target = note ? note->start_line : line_hint; + if (target < 0) target = state->start_line; + GtkTextIter iter; + gtk_text_buffer_get_iter_at_line(tab->buffer, &iter, MAX(0, target)); + gtk_text_buffer_place_cursor(tab->buffer, &iter); + editor_tab_place_popover_at_cursor(tab, popover); + graptos_popover_show(popover); + gtk_widget_grab_focus(view); +} + +void editor_tab_show_add_note(EditorTab *tab) { + note_close_origin_popovers(tab, NULL); + editor_tab_show_note_popover(tab, NULL, -1); +} + +/** + * @brief Delete a note. + * @param button Button that emitted the signal. + * @param user_data Note pointer stored on the button. + */ +static void note_delete_clicked(GtkButton *button, gpointer user_data) { + EditorTab *tab = g_object_get_data(G_OBJECT(button), "graptos-note-tab"); + note_close_origin_popovers(tab, GTK_WIDGET(button)); + EditorNote *note = user_data; + if (!tab || !tab->notes || !note) return; + for (guint i = 0u; i < tab->notes->len; i++) { + if (g_ptr_array_index(tab->notes, i) == note) { + g_ptr_array_remove_index(tab->notes, i); + break; + } + } + editor_tab_save_notes(tab); + if (tab->gutter) gtk_widget_queue_draw(tab->gutter); + app_window_set_status(tab->win, "Note deleted."); +} + +/** + * @brief Edit a note from a context popover. + * @param button Button that emitted the signal. + * @param user_data Note pointer. + */ +static void note_edit_clicked(GtkButton *button, gpointer user_data) { + EditorTab *tab = g_object_get_data(G_OBJECT(button), "graptos-note-tab"); + note_close_origin_popovers(tab, GTK_WIDGET(button)); + editor_tab_show_note_popover(tab, user_data, -1); +} + +/** + * @brief Convert one note to language comments. + * @param button Button that emitted the signal. + * @param user_data Note pointer. + */ +static void note_convert_clicked(GtkButton *button, gpointer user_data) { + EditorTab *tab = g_object_get_data(G_OBJECT(button), "graptos-note-tab"); + note_close_origin_popovers(tab, GTK_WIDGET(button)); + EditorNote *note = user_data; + if (!tab || !note || tab->locked || !tab->buffer) return; + const char *comment = "#"; + if (tab->active_syntax && tab->active_syntax->line_comment && + tab->active_syntax->line_comment[0] != '\0') { + comment = tab->active_syntax->line_comment; + } + GtkTextIter line_start; + gtk_text_buffer_get_iter_at_line(tab->buffer, &line_start, note->start_line); + GtkTextIter probe = line_start; + while (!gtk_text_iter_ends_line(&probe)) { + gunichar ch = gtk_text_iter_get_char(&probe); + if (ch != ' ' && ch != '\t') break; + gtk_text_iter_forward_char(&probe); + } + g_autofree char *indent = gtk_text_buffer_get_text(tab->buffer, + &line_start, + &probe, + FALSE); + g_auto(GStrv) lines = g_strsplit(note->note ? note->note : "", "\n", -1); + gtk_text_buffer_begin_user_action(tab->buffer); + for (guint i = 0u; lines && lines[i]; i++) { + g_autofree char *line = g_strdup_printf("%s%s %s\n", + indent ? indent : "", + comment, + lines[i]); + gtk_text_buffer_insert(tab->buffer, &line_start, line, -1); + } + gtk_text_buffer_end_user_action(tab->buffer); + app_window_set_status(tab->win, "Note converted to comment."); +} + +/** + * @brief Add a context button bound to a note. + * @param box Context popover box. + * @param tab Editor tab. + * @param note Note pointer. + * @param label Button label. + * @param callback Button callback. + */ +static void note_context_append(GtkWidget *box, + EditorTab *tab, + EditorNote *note, + const char *label, + GCallback callback, + GtkWidget *origin_popover) { + GtkWidget *button = graptos_flat_button_new(label, NULL, callback, note); + g_object_set_data(G_OBJECT(button), "graptos-note-tab", tab); + g_object_set_data(G_OBJECT(button), "graptos-note-origin-popover", + origin_popover); + gtk_box_append(GTK_BOX(box), button); +} + +/** + * @brief Destroy a one-shot note context popover. + * @param popover Popover supplied by GTK. + * @param user_data Callback data supplied by GTK. + */ +static void note_context_popover_closed(GtkPopover *popover, + gpointer user_data) { + GtkWidget *owner = user_data; + if (owner) { + GtkWidget *stored = g_object_get_data(G_OBJECT(owner), + "graptos-context-popover"); + if (stored == GTK_WIDGET(popover)) { + g_object_set_data(G_OBJECT(owner), "graptos-context-popover", NULL); + } + } + graptos_widget_destroy(GTK_WIDGET(popover)); +} + +void on_gutter_right_click(GtkGestureClick *gesture, + int n_press, + double x, + double y, + gpointer user_data) { + (void)n_press; + (void)x; + EditorTab *tab = user_data; + GtkWidget *widget = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture)); + if (!tab || !widget || !tab->text_view || !tab->buffer) return; + gtk_gesture_set_state(GTK_GESTURE(gesture), GTK_EVENT_SEQUENCE_CLAIMED); + + GtkTextView *view = GTK_TEXT_VIEW(tab->text_view); + GdkRectangle visible; + gtk_text_view_get_visible_rect(view, &visible); + GtkTextIter iter; + gint line_y = 0; + gtk_text_view_get_line_at_y(view, &iter, visible.y + (gint)y, &line_y); + gint line = gtk_text_iter_get_line(&iter); + EditorNote *note = editor_notes_find_for_line(tab->notes, line); + + GtkWidget *popover = gtk_popover_new(); + graptos_popover_attach(popover, widget); + gtk_popover_set_has_arrow(GTK_POPOVER(popover), FALSE); + gtk_widget_add_css_class(popover, "graptos-context-popover"); + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); + graptos_set_all_margins(box, 6); + if (note) { + note_context_append(box, tab, note, "Convert to Comment", + G_CALLBACK(note_convert_clicked), popover); + note_context_append(box, tab, note, "Edit Note", + G_CALLBACK(note_edit_clicked), popover); + note_context_append(box, tab, note, "Delete Note", + G_CALLBACK(note_delete_clicked), popover); + } else { + GtkTextIter target; + gtk_text_buffer_get_iter_at_line(tab->buffer, &target, line); + gtk_text_buffer_place_cursor(tab->buffer, &target); + GtkWidget *add = graptos_flat_button_new("Add Note", NULL, + G_CALLBACK(menu_add_note), tab); + g_object_set_data(G_OBJECT(add), "graptos-note-origin-popover", + popover); + gtk_box_append(GTK_BOX(box), add); + } + GdkRectangle rect = { .x = 1, .y = (int)y, .width = 1, .height = 1 }; + gtk_popover_set_child(GTK_POPOVER(popover), box); + gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect); + g_object_set_data(G_OBJECT(widget), "graptos-context-popover", popover); + g_signal_connect(popover, "closed", + G_CALLBACK(note_context_popover_closed), widget); + graptos_popover_show(popover); +} + +void on_gutter_motion(GtkEventControllerMotion *controller, + double x, + double y, + gpointer user_data) { + EditorTab *tab = user_data; + GtkWidget *widget = gtk_event_controller_get_widget( + GTK_EVENT_CONTROLLER(controller)); + if (!tab || !widget || !tab->text_view || !tab->buffer) return; + + if (x > 18.0) { + gtk_widget_set_tooltip_text(widget, NULL); + return; + } + + GtkTextView *view = GTK_TEXT_VIEW(tab->text_view); + GdkRectangle visible; + gtk_text_view_get_visible_rect(view, &visible); + GtkTextIter iter; + gint line_y = 0; + gtk_text_view_get_line_at_y(view, &iter, visible.y + (gint)y, &line_y); + EditorNote *note = editor_notes_find_for_line(tab->notes, + gtk_text_iter_get_line(&iter)); + gtk_widget_set_tooltip_text(widget, note ? note->note : NULL); +} diff --git a/src/editor_tab_private.h b/src/editor_tab_private.h index a2e4828..348a66f 100644 --- a/src/editor_tab_private.h +++ b/src/editor_tab_private.h @@ -15,6 +15,7 @@ #include "formatter.h" #include "index.h" #include "import_complete.h" +#include "editor_notes.h" #include "lsp_client.h" #include "ui.h" @@ -300,6 +301,13 @@ void menu_comment(GtkWidget *w, gpointer data); * @param data The callback context passed by the caller. */ void menu_complete(GtkWidget *w, gpointer data); +/** + * @brief Menu add note. + * @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 w The w supplied by the caller. + * @param data The callback context passed by the caller. + */ +void menu_add_note(GtkWidget *w, gpointer data); /** * @brief Hex to rgba. * @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. @@ -512,6 +520,25 @@ void on_minimap_draw(GtkDrawingArea *area, cairo_t *cr, int width, int height, g * @param user_data The callback context passed through GTK signal data. */ void on_gutter_draw(GtkDrawingArea *area, cairo_t *cr, int width, int height, gpointer user_data); +/** + * @brief On gutter 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. + * @param user_data The callback context passed through GTK signal data. + */ +void on_gutter_right_click(GtkGestureClick *gesture, int n_press, double x, double y, gpointer user_data); +/** + * @brief On gutter motion. + * @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 controller The controller 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. + */ +void on_gutter_motion(GtkEventControllerMotion *controller, double x, double y, gpointer user_data); /** * @brief On vadjustment value changed. * @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. @@ -898,6 +925,24 @@ gboolean on_text_view_key_pressed(GtkEventControllerKey *controller, guint keyva * @param user_data The callback context passed through GTK signal data. */ void on_text_view_key_released(GtkEventControllerKey *controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data); +/** + * @brief Load notes 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. + * @param tab The editor tab whose buffer or widgets are being inspected. + */ +void editor_tab_load_notes(EditorTab *tab); +/** + * @brief Save notes 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. + * @param tab The editor tab whose buffer or widgets are being inspected. + */ +void editor_tab_save_notes(EditorTab *tab); +/** + * @brief Show add note popover. + * @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 tab The editor tab whose buffer or widgets are being inspected. + */ +void editor_tab_show_add_note(EditorTab *tab); /** * @brief On close button clicked. * @details Editor code runs in response to fast input, delayed timeouts, and background language work. The notes here mark the boundary between immediate GTK state and deferred refresh paths so latency fixes do not turn into stale-widget bugs. diff --git a/src/ui.h b/src/ui.h index f6d2f8a..1292884 100644 --- a/src/ui.h +++ b/src/ui.h @@ -115,6 +115,11 @@ void graptos_apply_editor_css(const char *editor_bg_color, const char *editor_fg_color, const char *editor_gutter_bg_color, const char *editor_gutter_fg_color, + const char *note_indicator_color, + const char *note_indicator_active_color, + const char *note_popover_bg_color, + const char *note_popover_fg_color, + const char *note_popover_border_color, const char *editor_current_line_bg_color, const char *editor_selection_bg_color, const char *editor_selection_fg_color, diff --git a/src/ui/ui_editor_css.inc.c b/src/ui/ui_editor_css.inc.c index a34a4a0..767ebb1 100644 --- a/src/ui/ui_editor_css.inc.c +++ b/src/ui/ui_editor_css.inc.c @@ -86,6 +86,11 @@ static void append_font_rule(GString *css, * @param editor_fg_color The editor fg color supplied by the caller. * @param editor_gutter_bg_color The editor gutter bg color supplied by the caller. * @param editor_gutter_fg_color The editor gutter fg color supplied by the caller. + * @param note_indicator_color The note indicator color supplied by the caller. + * @param note_indicator_active_color The active note indicator color supplied by the caller. + * @param note_popover_bg_color The note popover background color supplied by the caller. + * @param note_popover_fg_color The note popover foreground color supplied by the caller. + * @param note_popover_border_color The note popover border color supplied by the caller. * @param editor_current_line_bg_color The editor current line bg color supplied by the caller. * @param editor_selection_bg_color The editor selection bg color supplied by the caller. * @param editor_selection_fg_color The editor selection fg color supplied by the caller. @@ -170,6 +175,11 @@ void graptos_apply_editor_css(const char *editor_bg_color, const char *editor_fg_color, const char *editor_gutter_bg_color, const char *editor_gutter_fg_color, + const char *note_indicator_color, + const char *note_indicator_active_color, + const char *note_popover_bg_color, + const char *note_popover_fg_color, + const char *note_popover_border_color, const char *editor_current_line_bg_color, const char *editor_selection_bg_color, const char *editor_selection_fg_color, @@ -269,6 +279,16 @@ void graptos_apply_editor_css(const char *editor_bg_color, (editor_gutter_bg_color && editor_gutter_bg_color[0] == '#') ? editor_gutter_bg_color : effective_bg; const char *effective_gutter_fg = (editor_gutter_fg_color && editor_gutter_fg_color[0] == '#') ? editor_gutter_fg_color : "#8b949e"; + const char *effective_note_indicator = + (note_indicator_color && note_indicator_color[0] == '#') ? note_indicator_color : "#f9c74f"; + const char *effective_note_indicator_active = + (note_indicator_active_color && note_indicator_active_color[0] == '#') ? note_indicator_active_color : "#ffd166"; + const char *effective_note_popover_bg = + (note_popover_bg_color && note_popover_bg_color[0] == '#') ? note_popover_bg_color : effective_bg; + const char *effective_note_popover_fg = + (note_popover_fg_color && note_popover_fg_color[0] == '#') ? note_popover_fg_color : effective_fg; + const char *effective_note_popover_border = + (note_popover_border_color && note_popover_border_color[0] == '#') ? note_popover_border_color : "#3a4050"; const char *effective_current_line = (editor_current_line_bg_color && editor_current_line_bg_color[0] == '#') ? editor_current_line_bg_color : "#20232b"; const char *effective_selection_bg = @@ -550,6 +570,8 @@ void graptos_apply_editor_css(const char *editor_bg_color, "background: %s; background-color: %s; color: %s; caret-color: %s; }\n" "sourceview.graptos-editor gutter, sourceview.graptos-editor gutter * { " "background: %s; background-color: %s; color: %s; }\n" + ".graptos-gutter { " + "background: %s; background-color: %s; color: %s; }\n" "sourceview.graptos-editor gutter renderer, " "sourceview.graptos-editor gutter lines, " "sourceview.graptos-editor gutter marks { " @@ -559,7 +581,20 @@ void graptos_apply_editor_css(const char *editor_bg_color, effective_bg, effective_bg, effective_fg, effective_cursor, effective_gutter_bg, effective_gutter_bg, effective_gutter_fg, effective_gutter_bg, effective_gutter_bg, effective_gutter_fg, + effective_gutter_bg, effective_gutter_bg, effective_gutter_fg, effective_selection_bg, effective_selection_bg, effective_selection_fg); + g_string_append_printf(css, + ".graptos-note-popover, .graptos-note-popover contents, " + ".graptos-note-popover textview, .graptos-note-popover textview text, " + ".graptos-note-popover .graptos-note-input, " + ".graptos-note-popover .graptos-note-status { " + "background: %s; background-color: %s; color: %s; " + "border-color: %s; }\n" + ".graptos-note-indicator { color: %s; }\n" + ".graptos-note-indicator-active { color: %s; }\n", + effective_note_popover_bg, effective_note_popover_bg, + effective_note_popover_fg, effective_note_popover_border, + effective_note_indicator, effective_note_indicator_active); if (effective_editor_font) { append_font_rule(css, ".graptos-editor, .graptos-editor text, " diff --git a/src/ui_css.c b/src/ui_css.c index 9d7fb91..1a2920e 100644 --- a/src/ui_css.c +++ b/src/ui_css.c @@ -34,6 +34,13 @@ static const char *graptos_theme_css_template(void) { "/* GRAPTOS THEME BEGIN */\n" "@define-color graptos_editor_bg #181a1f;\n" "@define-color graptos_editor_fg #d4d4d4;\n" + "@define-color graptos_editor_gutter_bg #181a1f;\n" + "@define-color graptos_editor_gutter_fg #8b949e;\n" + "@define-color graptos_note_indicator #f9c74f;\n" + "@define-color graptos_note_indicator_active #ffd166;\n" + "@define-color graptos_note_popover_bg #181a1f;\n" + "@define-color graptos_note_popover_fg #d4d4d4;\n" + "@define-color graptos_note_popover_border #3a4050;\n" "@define-color graptos_sidebar_bg #111318;\n" "@define-color graptos_tabbar_bg #111318;\n" "@define-color graptos_tabbar_border #00000000;\n" @@ -57,6 +64,10 @@ static const char *graptos_theme_css_template(void) { " background: @graptos_editor_bg;\n" " color: @graptos_editor_fg;\n" "}\n\n" + ".graptos-gutter {\n" + " background: @graptos_editor_gutter_bg;\n" + " color: @graptos_editor_gutter_fg;\n" + "}\n\n" ".graptos-project-pane, .graptos-project-tree, .graptos-project-tree row {\n" " background: @graptos_sidebar_bg;\n" "}\n\n" @@ -88,6 +99,13 @@ static const char *graptos_theme_css_template(void) { "popover.graptos-completion-popover, popover.graptos-hover-popover {\n" " background: @graptos_popover_bg;\n" "}\n\n" + "popover.graptos-note-popover, popover.graptos-note-popover textview,\n" + "popover.graptos-note-popover textview text,\n" + "popover.graptos-note-popover .graptos-note-status {\n" + " background: @graptos_note_popover_bg;\n" + " color: @graptos_note_popover_fg;\n" + " border-color: @graptos_note_popover_border;\n" + "}\n\n" ".graptos-dialog-root { background: @graptos_dialog_bg; }\n" ".graptos-tab-tiled { box-shadow: inset 0 -2px @graptos_tab_tiled_indicator; }\n" ".graptos-completion-list row:selected { background: @graptos_accent; }\n" diff --git a/tests/unit_tests.c b/tests/unit_tests.c index c42baaf..48fc63b 100644 --- a/tests/unit_tests.c +++ b/tests/unit_tests.c @@ -11,6 +11,7 @@ #include #include "../src/codex_protocol.h" +#include "../src/editor_notes.h" #include "../src/formatter.h" #include "../src/project_init.h" #include "../src/syntax.h" @@ -764,6 +765,53 @@ static void test_project_init_language_templates(void) { test_remove_tree(parent); } +/** + * @brief Verifies note text databases round-trip escaped text. + * @details Notes are meant to be reviewed and shared as text files. This test + * covers the escaping that keeps tabs, newlines, and backslashes in a + * single database record. + */ +static void test_editor_notes_round_trip(void) { + g_autoptr(GError) error = NULL; + g_autofree char *dir = g_dir_make_tmp("graptos-notes-XXXXXX", &error); + g_assert_no_error(error); + g_autofree char *path = g_build_filename(dir, "main.c.12345678.notes.txt", NULL); + GPtrArray *notes = g_ptr_array_new_with_free_func(editor_note_free); + g_ptr_array_add(notes, editor_note_new(7u, 2, 4, 5, 8, "one\ttwo\nthree\\four")); + + g_assert_true(editor_notes_save(path, notes, &error)); + g_assert_no_error(error); + GPtrArray *loaded = editor_notes_load(path); + g_assert_cmpuint(loaded->len, ==, 1u); + EditorNote *note = g_ptr_array_index(loaded, 0u); + g_assert_cmpuint(note->id, ==, 7u); + g_assert_cmpint(note->start_line, ==, 2); + g_assert_cmpint(note->end_line, ==, 5); + g_assert_cmpstr(note->note, ==, "one\ttwo\nthree\\four"); + + g_ptr_array_free(loaded, TRUE); + g_ptr_array_free(notes, TRUE); + test_remove_tree(dir); +} + +/** + * @brief Verifies note path names avoid same-basename collisions. + * @details The database filename keeps the source basename for readability and + * appends a relative-path hash so two folders can both contain main.c. + */ +static void test_editor_notes_path_collision(void) { + const char *root = "/tmp/graptos-project"; + g_autofree char *first = editor_notes_db_path(root, "/tmp/graptos-project/src/main.c"); + g_autofree char *second = editor_notes_db_path(root, "/tmp/graptos-project/tests/main.c"); + g_assert_nonnull(first); + g_assert_nonnull(second); + g_assert_true(g_str_has_suffix(first, ".notes.txt")); + g_assert_true(g_str_has_suffix(second, ".notes.txt")); + g_assert_nonnull(strstr(first, "main.c.")); + g_assert_nonnull(strstr(second, "main.c.")); + g_assert_cmpstr(first, !=, second); +} + /** * @brief Registers and runs the unit test suite. * @@ -798,5 +846,7 @@ int main(int argc, char **argv) { g_test_add_func("/project-init/apply/success", test_project_init_apply_success); g_test_add_func("/project-init/templates/template-test", test_project_init_builtin_template_test); g_test_add_func("/project-init/templates/languages", test_project_init_language_templates); + g_test_add_func("/editor-notes/round-trip", test_editor_notes_round_trip); + g_test_add_func("/editor-notes/path-collision", test_editor_notes_path_collision); return g_test_run(); }