diff --git a/plugins/word-completion/completion-provider.vala b/plugins/word-completion/completion-provider.vala index d9abbc7b77..fad3ffe6bd 100644 --- a/plugins/word-completion/completion-provider.vala +++ b/plugins/word-completion/completion-provider.vala @@ -1,4 +1,5 @@ /* + * Copyright 2024 elementary, Inc. * Copyright (c) 2013 Mario Guerriero * * This is a free software; you can redistribute it and/or @@ -18,50 +19,68 @@ * */ -public class Scratch.Plugins.CompletionProvider : Gtk.SourceCompletionProvider, Object { - public string name; - public int priority; +public class Scratch.Plugins.CompletionProvider : Gtk.SourceCompletionProvider, GLib.Object { + private const int MAX_COMPLETIONS = 10; + public string name { get; construct; } + public int priority { get; construct; } + public int interactive_delay { get; construct; } + public Gtk.SourceCompletionActivation activation { get; construct; } public const string COMPLETION_END_MARK_NAME = "ScratchWordCompletionEnd"; public const string COMPLETION_START_MARK_NAME = "ScratchWordCompletionStart"; - private Gtk.TextView? view; - private Gtk.TextBuffer? buffer; - private Euclide.Completion.Parser parser; + public Gtk.TextView? view { get; construct; } + public Euclide.Completion.Parser parser { get; construct; } + + private unowned Gtk.TextBuffer buffer { + get { + return view.buffer; + } + } + private Gtk.TextMark completion_end_mark; private Gtk.TextMark completion_start_mark; + private string current_text_to_find = ""; public signal void can_propose (bool b); - public CompletionProvider (Scratch.Plugins.Completion completion) { - this.view = completion.current_view as Gtk.TextView; - this.buffer = completion.current_view.buffer; - this.parser = completion.parser; + public CompletionProvider ( + Euclide.Completion.Parser _parser, + Scratch.Services.Document _doc + ) { + + Object ( + parser: _parser, + view: _doc.source_view, + name: _("%s - Word Completion").printf (_doc.get_basename ()) + ); + } + + construct { + interactive_delay = (int) Completion.INTERACTIVE_DELAY; + activation = INTERACTIVE | USER_REQUESTED; Gtk.TextIter iter; - buffer.get_iter_at_offset (out iter, 0); + view.buffer.get_iter_at_offset (out iter, 0); completion_end_mark = buffer.create_mark (COMPLETION_END_MARK_NAME, iter, false); completion_start_mark = buffer.create_mark (COMPLETION_START_MARK_NAME, iter, false); } - public string get_name () { - return this.name; - } + public override bool match (Gtk.SourceCompletionContext context) { + var iter = context.iter; + var start_iter = iter; + var end_iter = iter; + bool found = false; - public int get_priority () { - return this.priority; - } - - public bool match (Gtk.SourceCompletionContext context) { - Gtk.TextIter start, end; - buffer.get_iter_at_offset (out end, buffer.cursor_position); - start = end.copy (); - Euclide.Completion.Parser.back_to_word_start (ref start); - string text = buffer.get_text (start, end, true); + var preceding_word = parser.get_word_immediately_before (iter); + if (preceding_word != "") { + found = parser.match (preceding_word); + current_text_to_find = found ? preceding_word : ""; + } - return parser.match (text); + return found; } - public void populate (Gtk.SourceCompletionContext context) { + public override void populate (Gtk.SourceCompletionContext context) { /*Store current insertion point for use in activate_proposal */ GLib.List? file_props; bool no_minimum = (context.get_activation () == Gtk.SourceCompletionActivation.USER_REQUESTED); @@ -69,43 +88,40 @@ public class Scratch.Plugins.CompletionProvider : Gtk.SourceCompletionProvider, context.add_proposals (this, file_props, true); } - public bool activate_proposal (Gtk.SourceCompletionProposal proposal, Gtk.TextIter iter) { + public override bool activate_proposal (Gtk.SourceCompletionProposal proposal, Gtk.TextIter iter) { Gtk.TextIter start; - Gtk.TextIter end; + Gtk.TextIter end_iter; Gtk.TextMark mark; mark = buffer.get_mark (COMPLETION_END_MARK_NAME); - buffer.get_iter_at_mark (out end, mark); + buffer.get_iter_at_mark (out end_iter, mark); + var end_pos = end_iter.get_offset (); + // If inserting in middle of word then completion overwrites end of word + var following_word = parser.get_word_immediately_after (end_iter); + if (following_word != "") { + buffer.get_iter_at_offset (out end_iter, end_pos + following_word.length); + } mark = buffer.get_mark (COMPLETION_START_MARK_NAME); buffer.get_iter_at_mark (out start, mark); - buffer.@delete (ref start, ref end); + buffer.@delete (ref start, ref end_iter); buffer.insert (ref start, proposal.get_text (), proposal.get_text ().length); return true; } - public Gtk.SourceCompletionActivation get_activation () { - return Gtk.SourceCompletionActivation.INTERACTIVE | - Gtk.SourceCompletionActivation.USER_REQUESTED; - } - - public int get_interactive_delay () { - return 0; - } - - public bool get_start_iter (Gtk.SourceCompletionContext context, + public override bool get_start_iter (Gtk.SourceCompletionContext context, Gtk.SourceCompletionProposal proposal, out Gtk.TextIter iter) { - var mark = buffer.get_insert (); - Gtk.TextIter cursor_iter; - buffer.get_iter_at_mark (out cursor_iter, mark); - iter = cursor_iter; - Euclide.Completion.Parser.back_to_word_start (ref iter); + var word_start = buffer.cursor_position; + buffer.get_iter_at_offset (out iter, word_start); return true; } + public override string get_name () { + return name; + } private bool get_proposals (out GLib.List? props, bool no_minimum) { string to_find = ""; @@ -114,36 +130,38 @@ public class Scratch.Plugins.CompletionProvider : Gtk.SourceCompletionProvider, Gtk.TextIter start, end; buffer.get_selection_bounds (out start, out end); - to_find = temp_buffer.get_text (start, end, true); if (to_find.length == 0) { - temp_buffer.get_iter_at_offset (out end, buffer.cursor_position); - - start = end; - Euclide.Completion.Parser.back_to_word_start (ref start); - - to_find = buffer.get_text (start, end, false); + to_find = current_text_to_find; } buffer.move_mark_by_name (COMPLETION_END_MARK_NAME, end); buffer.move_mark_by_name (COMPLETION_START_MARK_NAME, start); /* There is no minimum length of word to find if the user requested a completion */ - if (no_minimum || to_find.length >= Euclide.Completion.Parser.MINIMUM_WORD_LENGTH) { + if (no_minimum || to_find.length >= Euclide.Completion.Parser.MINIMUM_PREFIX_LENGTH) { /* Get proposals, if any */ - List prop_word_list; - if (parser.get_for_word (to_find, out prop_word_list)) { - foreach (var word in prop_word_list) { - var item = new Gtk.SourceCompletionItem (); - item.label = word; - item.text = word; - props.append (item); + var completions = parser.get_current_completions (to_find); + if (completions.length () > 0) { + var index = 0; + foreach (var completion in completions) { + if (completion.length > 0) { + var item = new Gtk.SourceCompletionItem (); + var word = to_find + completion; + item.label = word; + item.text = completion; + props.append (item); + if (++index > MAX_COMPLETIONS) { + break; + } + } } return true; } } + return false; } } diff --git a/plugins/word-completion/engine.vala b/plugins/word-completion/engine.vala index eff236042f..8efdbf5799 100644 --- a/plugins/word-completion/engine.vala +++ b/plugins/word-completion/engine.vala @@ -1,6 +1,7 @@ /* - * Copyright (c) 2011 Lucas Baudin - * + * Copyright 2024 elementary, Inc. + * 2011 Lucas Baudin + * * * This is a free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of the @@ -19,83 +20,214 @@ */ public class Euclide.Completion.Parser : GLib.Object { - public const int MINIMUM_WORD_LENGTH = 1; - public const int MAX_TOKENS = 1000000; + // DELIMITERS used for word completion are not necessarily the same Pango word breaks + // Therefore, we reimplement some iter functions to move between words here below + public const string DELIMITERS = " .,;:?{}[]()+=&|<>*\\/\r\n\t`\"\'"; + public const int MINIMUM_WORD_LENGTH = 3; + public const int MAXIMUM_WORD_LENGTH = 50; + public const int MINIMUM_PREFIX_LENGTH = 1; + public const int MAX_TOKENS = 100000; + public static bool is_delimiter (unichar? uc) { + return uc == null || DELIMITERS.index_of_char (uc) > -1; + } - private Scratch.Plugins.PrefixTree prefix_tree; + private Scratch.Plugins.PrefixTree? current_tree = null; + public Gee.HashMap text_view_words; + public bool parsing_cancelled = false; - public const string DELIMITERS = " .,;:?{}[]()0123456789+=&|<>*\\/\r\n\t\'\"`"; - public static bool is_delimiter (unichar c) { - return DELIMITERS.index_of_char (c) >= 0; + public Parser () { + text_view_words = new Gee.HashMap (); } - public static void back_to_word_start (ref Gtk.TextIter iter) { - iter.backward_find_char (is_delimiter, null); - iter.forward_char (); + public bool select_current_tree (Gtk.TextView view) { + bool pre_existing = true; + if (!text_view_words.has_key (view)) { + var new_treemap = new Scratch.Plugins.PrefixTree (); + text_view_words.@set (view, new_treemap); + pre_existing = false; + } + + lock (current_tree) { + current_tree = text_view_words.@get (view); + parsing_cancelled = false; + } + + return pre_existing && get_initial_parsing_completed (); } - public Gee.HashMap text_view_words; - public bool parsing_cancelled = false; + public void set_initial_parsing_completed (bool completed) requires (current_tree != null) { + lock (current_tree) { + current_tree.completed = completed; + } + } - public Parser () { - text_view_words = new Gee.HashMap (); - prefix_tree = new Scratch.Plugins.PrefixTree (); + public bool get_initial_parsing_completed () requires (current_tree != null) { + return current_tree.completed; } - public bool match (string to_find) { - return prefix_tree.find_prefix (to_find); + // This gets called from a thread + public void initial_parse_buffer_text (string buffer_text) { + parsing_cancelled = false; + clear (); + if (buffer_text.length > 0) { + var parsed = parse_text_and_add (buffer_text); + set_initial_parsing_completed (parsed); + } else { + // Assume any buffer text would have been loaded when this is called + // so definitely no initial parse needed + set_initial_parsing_completed (true); + } + + debug ("initial parsing %s", get_initial_parsing_completed () ? "completed" : "INCOMPLETE"); } - public bool get_for_word (string to_find, out List list) { - list = prefix_tree.get_all_matches (to_find); - return list.first () != null; + // Returns true if text was completely parsed + public bool parse_text_and_add (string text) { + int index = 0; + string[] words = text.split_set (DELIMITERS); + uint n_words = words.length; + while (!parsing_cancelled && index < n_words) { + add_word (words[index++]); // only valid words will be added + } + + return index == n_words; } - public void rebuild_word_list (Gtk.TextView view) { - prefix_tree.clear (); - parse_text_view (view); + public void parse_text_and_remove (string text) { + if (text.length < MINIMUM_WORD_LENGTH) { + return; + } + + int index = 0; + string[] words = text.split_set (DELIMITERS); + uint n_words = words.length; + while (index < n_words) { + remove_word (words[index++]); + } + + return; } - public void parse_text_view (Gtk.TextView view) { - /* If this view has already been parsed, restore the word list */ - lock (prefix_tree) { - if (text_view_words.has_key (view)) { - prefix_tree = text_view_words.@get (view); - } else { - /* Else create a new word list and parse the buffer text */ - prefix_tree = new Scratch.Plugins.PrefixTree (); - } + public string get_word_immediately_before (Gtk.TextIter iter) { + int end_pos; + var text = get_sentence_at_iter (iter, out end_pos); + var pos = end_pos; + unichar uc; + text.get_prev_char (ref pos, out uc); + if (is_delimiter (uc)) { + return ""; } - if (view.buffer.text.length > 0) { - parse_string (view.buffer.text); - text_view_words.@set (view, prefix_tree); + pos = (end_pos - MAXIMUM_WORD_LENGTH - 1).clamp (0, end_pos); + if (pos >= end_pos) { + critical ("pos after end_pos"); + return ""; } + + var sliced_text = text.slice (pos, end_pos); + var words = sliced_text.split_set (DELIMITERS); + var previous_word = words[words.length - 1]; // Maybe "" + return previous_word; } - public void add_word (string word) { - if (word.length < MINIMUM_WORD_LENGTH) - return; + public string get_word_immediately_after (Gtk.TextIter iter) { + int start_pos; + var text = get_sentence_at_iter (iter, out start_pos); + var pos = start_pos; + unichar uc; + text.get_next_char (ref pos, out uc); + if (is_delimiter (uc)) { + return ""; + } + + // Find end of search range + pos = (start_pos + MAXIMUM_WORD_LENGTH + 1).clamp (start_pos, text.length); + if (start_pos >= pos) { + critical ("start pos after pos"); + return ""; + } + + // Find first word in range + var words = text.slice (start_pos, pos).split_set (DELIMITERS, 2); + var next_word = words[0]; // Maybe "" + return next_word; + } + + private string get_sentence_at_iter (Gtk.TextIter iter, out int iter_sentence_offset) { + var start_iter = iter; + var end_iter = iter; + start_iter.backward_sentence_start (); + end_iter.forward_sentence_end (); + var text = start_iter.get_text (end_iter); + iter_sentence_offset = iter.get_offset () - start_iter.get_offset (); + return text; + } + + public void clear () requires (current_tree != null) { + cancel_parsing (); + lock (current_tree) { + current_tree.clear (); // Sets completed false + set_initial_parsing_completed (false); - lock (prefix_tree) { - prefix_tree.insert (word); } + + parsing_cancelled = false; } public void cancel_parsing () { + // Do not need to cancel reaping - this continues when prefix_tree is not current parsing_cancelled = true; } - private bool parse_string (string text) { - parsing_cancelled = false; - string [] word_array = text.split_set (DELIMITERS, MAX_TOKENS); - foreach (var current_word in word_array ) { - if (parsing_cancelled) { - debug ("Cancelling parse"); - return false; + private List current_completions; + private string current_prefix; + public bool match (string prefix) requires (current_tree != null) { + lock (current_tree) { + current_completions = current_tree.get_all_completions (prefix); + current_prefix = prefix; + } + + return current_completions != null && current_completions.first ().data != null; + } + + public List get_current_completions (string prefix) requires (current_tree != null) { + // Assume always preceded by match and current_completions up to date + if (current_prefix != prefix) { + critical ("current prefix does not match"); + match (prefix); + } + + return (owned)current_completions; + } + + public void add_word (string word_to_add) requires (current_tree != null) { + if (is_valid_word (word_to_add)) { + // warning ("ADD WORD %s", word_to_add); + lock (current_tree) { + current_tree.add_word (word_to_add); + } + } + } + + public void remove_word (string word_to_remove) requires (current_tree != null) { + if (is_valid_word (word_to_remove)) { + // warning ("REMOVE WORD %s", word_to_remove); + lock (current_tree) { + current_tree.remove_word (word_to_remove); } - add_word (current_word); } + } + + private bool is_valid_word (string word) { + if (word.strip ().length < MINIMUM_WORD_LENGTH) { + return false; + } + + // Exclude words beginning with digit + if (word.get_char (0).isdigit ()) { + return false; + } + return true; } } diff --git a/plugins/word-completion/plugin.vala b/plugins/word-completion/plugin.vala index d227d12a6a..69c97b6d63 100644 --- a/plugins/word-completion/plugin.vala +++ b/plugins/word-completion/plugin.vala @@ -19,16 +19,10 @@ */ public class Scratch.Plugins.Completion : Peas.ExtensionBase, Peas.Activatable { - public Object object { owned get; construct; } - - private List text_view_list = new List (); - public Euclide.Completion.Parser parser {get; private set;} - public Gtk.SourceView? current_view {get; private set;} - public Scratch.Services.Document current_document {get; private set;} - private MainWindow main_window; - private Scratch.Services.Interface plugins; - private bool completion_in_progress = false; + public const int MAX_TOKENS = 1000000; + public const uint INTERACTIVE_DELAY = 500; + public const int INITIAL_PARSE_DELAY_MSEC = 1000; private const uint [] ACTIVATE_KEYS = { Gdk.Key.Return, @@ -41,6 +35,19 @@ public class Scratch.Plugins.Completion : Peas.ExtensionBase, Peas.Activatable { private const uint REFRESH_SHORTCUT = Gdk.Key.bar; //"|" in combination with will cause refresh + + public Object object { owned get; construct; } + + private List text_view_list = new List (); + private Euclide.Completion.Parser parser; + private Gtk.SourceView? current_view; + private Gtk.SourceCompletion? current_completion; + private Scratch.Plugins.CompletionProvider current_provider; + private Scratch.Services.Document current_document {get; private set;} + private MainWindow main_window; + private Scratch.Services.Interface plugins; + private bool completion_in_progress = false; + private uint timeout_id = 0; public void activate () { @@ -62,124 +69,157 @@ public class Scratch.Plugins.Completion : Peas.ExtensionBase, Peas.Activatable { } public void on_new_source_view (Scratch.Services.Document doc) { + debug ("new source_view %s", doc != null ? doc.title : "null"); if (current_view != null) { - if (current_view == doc.source_view) + if (current_view == doc.source_view) { return; + } parser.cancel_parsing (); - - if (timeout_id > 0) - GLib.Source.remove (timeout_id); - - cleanup (current_view); + cleanup (); } current_document = doc; current_view = doc.source_view; - current_view.key_press_event.connect (on_key_press); - current_view.completion.show.connect (() => { - completion_in_progress = true; - }); - current_view.completion.hide.connect (() => { - completion_in_progress = false; - }); - + current_completion = current_view.completion; - if (text_view_list.find (current_view) == null) + if (text_view_list.find (current_view) == null) { text_view_list.append (current_view); + } - var comp_provider = new Scratch.Plugins.CompletionProvider (this); - comp_provider.priority = 1; - comp_provider.name = provider_name_from_document (doc); + current_provider = new Scratch.Plugins.CompletionProvider (parser, doc); try { - current_view.completion.add_provider (comp_provider); - current_view.completion.show_headers = true; - current_view.completion.show_icons = true; - /* Wait a bit to allow text to load then run parser*/ - timeout_id = Timeout.add (1000, on_timeout_update); - + current_completion.add_provider (current_provider); + current_completion.show_headers = true; + current_completion.show_icons = true; + current_completion.accelerators = 9; + current_completion.select_on_show = true; } catch (Error e) { - warning (e.message); + critical ( + "Could not add completion provider to %s. %s\n", + current_document.title, + e.message + ); + cleanup (); + return; } - } - private bool on_timeout_update () { - try { - new Thread.try ("word-completion-thread", () => { - if (current_view != null) - parser.parse_text_view (current_view as Gtk.TextView); + // Check whether there is already a parsed tree + if (!parser.select_current_tree (current_view)) { + // If not, start initial parsing after timeout to ensure text loaded + var view_to_parse = current_view; + timeout_id = Timeout.add (INITIAL_PARSE_DELAY_MSEC, () => { + timeout_id = 0; + // Check view has not been switched + if (view_to_parse == current_view) { + try { + new Thread.try ("word-completion-thread", () => { + // The initial parse gets cancelled if view switched before complete + parser.initial_parse_buffer_text (view_to_parse.buffer.text); + return null; + }); + } catch (Error e) { + warning (e.message); + } + } - return null; + return Source.REMOVE; }); - } catch (Error e) { - warning (e.message); } - timeout_id = 0; - return false; + // Always connect signals - they are disconnected in cleanup + connect_signals (); } - private bool on_key_press (Gtk.Widget view, Gdk.EventKey event) { - var kv = event.keyval; - /* Pass through any modified keypress except Shift or Capslock */ - Gdk.ModifierType mods = event.state & Gdk.ModifierType.MODIFIER_MASK - & ~Gdk.ModifierType.SHIFT_MASK - & ~Gdk.ModifierType.LOCK_MASK; - if (mods > 0 ) { - /* Default key for USER_REQUESTED completion is ControlSpace - * but this is trapped elsewhere. Control + USER_REQUESTED_KEY acts as an - * alternative and also purges spelling mistakes and unused words from the list. - * If used when a word or part of a word is selected, the selection will be - * used as the word to find. */ - - if ((mods & Gdk.ModifierType.CONTROL_MASK) > 0 && - (kv == REFRESH_SHORTCUT)) { - - parser.rebuild_word_list (current_view); - current_view.show_completion (); - return true; - } + // Runs before default handler so buffer text not yet modified. @pos must not be invalidated + private void on_insert_text (Gtk.TextIter iter, string new_text, int new_text_length) { + if (!parser.get_initial_parsing_completed ()) { + return; } + // Determine whether insertion point ends and/or starts a word + var word_before = parser.get_word_immediately_before (iter); + var word_after = parser.get_word_immediately_after (iter); + var text_to_add = (word_before + new_text + word_after); + var text_to_remove = (word_before + word_after); + // Only update if words have changed + debug ("insert text - add '%s' + '%s' + '%s'", word_before, new_text, word_after); + if (text_to_add != text_to_remove) { + parser.parse_text_and_add (text_to_add); + parser.remove_word (text_to_remove); + } + } - var uc = (unichar)(Gdk.keyval_to_unicode (kv)); - if (!completion_in_progress && Euclide.Completion.Parser.is_delimiter (uc) && - (uc.isprint () || uc.isspace ())) { - - var buffer = current_view.buffer; - var mark = buffer.get_insert (); - Gtk.TextIter cursor_iter; - buffer.get_iter_at_mark (out cursor_iter, mark); - - var word_start = cursor_iter; - Euclide.Completion.Parser.back_to_word_start (ref word_start); - - string word = buffer.get_text (word_start, cursor_iter, false); - parser.add_word (word); + private void on_delete_range (Gtk.TextIter del_start_iter, Gtk.TextIter del_end_iter) { + if (!parser.get_initial_parsing_completed ()) { + return; } - return false; + var del_text = del_start_iter.get_text (del_end_iter); + var word_before = parser.get_word_immediately_before (del_start_iter); + var word_after = parser.get_word_immediately_after (del_end_iter); + var to_remove = word_before + del_text + word_after; + var to_add = word_before + word_after; + + // More than one word could be deleted so parse. + debug ("delete range - remove '%s' + '%s' + '%s'", word_before, del_text, word_after); + parser.parse_text_and_remove (to_remove); + // Only one at most new words + parser.add_word (to_add); + + // Completions not usually shown after deletions so trigger it ourselves + if (del_text.length == 1) { + schedule_completion (); + } } - private string provider_name_from_document (Scratch.Services.Document doc) { - return _("%s - Word Completion").printf (doc.get_basename ()); + uint completion_timeout_id = 0; + bool wait = true; + // Wait until after buffer has finished being amended then trigger completion + private void schedule_completion () { + if (completion_timeout_id == 0) { + completion_timeout_id = Timeout.add (current_provider.interactive_delay, () => { + if (wait) { + wait = false; + return Source.CONTINUE; + } else { + completion_timeout_id = 0; + wait = true; + current_view.show_completion (); + return Source.REMOVE; + } + }); + } else { + wait = true; + } } - private void cleanup (Gtk.SourceView view) { - current_view.key_press_event.disconnect (on_key_press); + private void cleanup () { + if (timeout_id > 0) { + GLib.Source.remove (timeout_id); + } + + disconnect_signals (); - current_view.completion.get_providers ().foreach ((p) => { + current_completion.get_providers ().foreach ((p) => { try { /* Only remove provider added by this plug in */ - if (p.get_name () == provider_name_from_document (current_document)) { - debug ("removing provider %s", p.get_name ()); - current_view.completion.remove_provider (p); - } + current_completion.remove_provider (current_provider); } catch (Error e) { warning (e.message); } }); } + + private void connect_signals () { + current_view.buffer.insert_text.connect (on_insert_text); + current_view.buffer.delete_range.connect (on_delete_range); + } + + private void disconnect_signals () { + current_view.buffer.insert_text.disconnect (on_insert_text); + current_view.buffer.delete_range.disconnect (on_delete_range); + } } [ModuleInit] diff --git a/plugins/word-completion/prefix-tree-node.vala b/plugins/word-completion/prefix-tree-node.vala index d64ba5c0de..34c4b7d6c6 100644 --- a/plugins/word-completion/prefix-tree-node.vala +++ b/plugins/word-completion/prefix-tree-node.vala @@ -20,50 +20,223 @@ */ public class Scratch.Plugins.PrefixNode : Object { - public enum NodeType { + private const unichar WORD_END_CHAR = '\0'; + private enum NodeType { ROOT, CHAR, WORD_END } - private const unichar WORD_END_CHAR = '\0'; - private uint occurrences; // Only used for WORD_END nodes + private Gee.ArrayList children; + private unichar? uc = null; + private NodeType type = ROOT; + public uint occurrences { get; set construct; default = 0; } + public PrefixNode? parent { get; construct; default = null; } + + protected bool is_word_end { + get { + return type == WORD_END; + } + } - public unichar uc { get; construct; } - public NodeType node_type { get; construct; } - public PrefixNode? parent { get; construct; } - public unichar value { get; construct; } + protected string char_s { + owned get { + if (uc != null) { + return uc.to_string (); + } else { + return ""; + } + } + } - public Gee.ArrayList children; + protected bool has_children { + get { + return type != WORD_END && children.size > 0; + } + } public PrefixNode.from_unichar (unichar c, PrefixNode? _parent) requires (c != WORD_END_CHAR) { Object ( - value: c, parent: _parent, - uc: c, - node_type: NodeType.CHAR + occurrences: 1 ); + + uc = c; + type = CHAR; } public PrefixNode.root () { Object ( parent: null, - uc: WORD_END_CHAR, - node_type: NodeType.ROOT + occurrences: 0 ); + + type = ROOT; } public PrefixNode.word_end (PrefixNode _parent) { Object ( parent: _parent, - uc: WORD_END_CHAR, - node_type: NodeType.WORD_END + occurrences: 1 ); - occurrences = 1; + uc = WORD_END_CHAR; + type = WORD_END; } construct { children = new Gee.ArrayList (); } + + private bool has_char (unichar c) { + return uc == c; + } + + private void increment () requires (type == WORD_END) { + lock (occurrences) { + occurrences++; + } + } + + // Returns true if word still occurs + public bool decrement () requires (type == WORD_END) { + if (occurrences == 0) { + warning ("decrementing non-occurring node"); + return false; + } + + lock (occurrences) { + occurrences--; + } + + return occurrences > 0; + } + + public bool occurs () requires (type == WORD_END) { + return occurrences > 0; + } + + // Only called to add a complete word to the tree + public void insert_word (string text) requires (type == ROOT) { + debug ("rootnode: insert word %s", text); + int index = 0; + insert_word_internal (text, ref index); + } + + public void remove_child (PrefixNode child) requires (type != WORD_END) { + lock (children) { + children.remove (child); + debug ("removed child '%s'", child.char_s); + if (children.is_empty && type != ROOT) { + debug ("remove this from parent"); + parent.remove_child (this); + } + } + } + + // We return any end_node for @text even if occurences == 0 + // because we may be re-adding it before it is reaped + public PrefixNode? find_end_node_for (string text) { + debug ("find_end_node_for %s", text); + var last_node = find_last_node_for (text); + if (last_node != null) { + var end_node = last_node.find_or_append_char_child (WORD_END_CHAR, false); + return end_node; + } + + return null; + } + + // Returns node corresponding to last char in @text (or null if not in tree) + public PrefixNode? find_last_node_for (string text) { + int index = 0; + return find_last_node_for_internal (text, ref index); + } + + //PROTECTED METHODS + + protected void insert_word_internal (string text, ref int index) { + unichar? uc = null; + if (text.get_next_char (ref index, out uc)) { + var child = find_or_append_char_child (uc, true); // Appends if not found + child.insert_word_internal (text, ref index); + } else { + append_or_increment_word_end (); + } + } + + protected PrefixNode? find_last_node_for_internal (string text, ref int index) requires (type != WORD_END) { + unichar? uc = null; + if (text.get_next_char (ref index, out uc)) { + debug ("find char_child '%s'", uc.to_string ()); + var child = find_or_append_char_child (uc, false); + if (child == null ) { + debug ("child not found"); + return null; + } else { + return child.find_last_node_for_internal (text, ref index); + } + } else { + debug ("end of text - current node type %s", this.type.to_string ()); + return this; + } + } + //PRIVATE METHODS + + private void append_or_increment_word_end () requires (type != WORD_END && type != ROOT) { + foreach (var child in children) { + if (child.type == WORD_END) { + debug ("incrementing end node occurrence"); + child.increment (); + return; + } + } + + var new_child = new PrefixNode.word_end (this); + debug ("append new word end"); + append_child (new_child); + } + + private PrefixNode? find_or_append_char_child (unichar c, bool append_if_not_found = false) + requires (type != WORD_END) { + + foreach (var child in children) { + if (child.has_char (c)) { + return child; + } + } + + if (append_if_not_found) { + var new_child = new PrefixNode.from_unichar (c, this); + append_child (new_child); + return new_child; + } else { + return null; + } + } + + // Children are only appended if they do not already exist + private void append_child (owned PrefixNode child) requires (type != WORD_END) { + lock (children) { + children.add (child); + } + } + + public void get_all_completions (ref List completions, ref StringBuilder sb) { + if (type == WORD_END) { + return; + } + + var initial_sb_str = sb.str; + foreach (var child in children) { + if (child.type == WORD_END && child.occurs ()) { + completions.prepend (sb.str); + } else { + sb.append (child.char_s); + child.get_all_completions (ref completions, ref sb); + } + + sb.assign (initial_sb_str); + } + } } diff --git a/plugins/word-completion/prefix-tree.vala b/plugins/word-completion/prefix-tree.vala index 916ed9612d..4cd34852e6 100644 --- a/plugins/word-completion/prefix-tree.vala +++ b/plugins/word-completion/prefix-tree.vala @@ -1,103 +1,114 @@ +/* + * Copyright 2024 elementary, Inc. + * 2011 Lucas Baudin + * * + * This is a free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; see the file COPYING. If not, + * write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + + public class Scratch.Plugins.PrefixTree : Object { + private PrefixNode? root = null; + private GLib.StringBuilder sb; + private uint reaper_timeout_id = 0; + private bool delay_reaping = false; + // Reaping need not occur before completions shown since non-occurring words are + // not shown anyway. + private const uint REAPING_THROTTLE_MS = Completion.INTERACTIVE_DELAY * 2; + private bool reaping_cancelled = false; + + public bool completed { get; set; default = false; } + // We just store the end_nodes corresponding to words to be removed + public Gee.LinkedList words_to_remove { get; construct; } + + construct { + clear (); + sb = new GLib.StringBuilder (""); + words_to_remove = new Gee.LinkedList (); + } -namespace Scratch.Plugins { - public class PrefixTree : Object { - private PrefixNode root; - - construct { - clear (); - } - - public void clear () { - root = new PrefixNode (); - } - - public void insert (string word) { - if (word.length == 0) { - return; - } - - this.insert_at (word, this.root); - } - - private void insert_at (string word, PrefixNode node, int i = 0) { - unichar curr = '\0'; - - bool has_next_character = false; - do { - has_next_character = word.get_next_char (ref i, out curr); - } while (has_next_character && Euclide.Completion.Parser.is_delimiter (curr)); - - foreach (var child in node.children) { - if (child.value == curr) { - if (curr != '\0') { - insert_at (word, child, i); - } - return; - } - } + public void clear () { + root = new PrefixNode.root (); + completed = false; + } - var new_child = new PrefixNode.from_unichar (curr, null); - node.children.insert (0, new_child); - node.children.sort ((c1, c2) => { - if (c1.value > c2.value) { - return 1; - } else if (c1.value == c2.value) { - return 0; - } - return -1; - }); - if (curr != '\0') { - insert_at (word, new_child, i); - } - } + public void add_word (string word) requires (word.length > 0) { + debug ("add '%s' to root", word); + root.insert_word (word); + } - public bool find_prefix (string prefix) { - return find_prefix_at (prefix, root) != null? true : false; + public List get_all_completions (string prefix) { + var list = new List (); + var last_node = root.find_last_node_for (prefix); + if (last_node != null) { + sb.erase (); + last_node.get_all_completions (ref list, ref sb); } - private PrefixNode? find_prefix_at (string prefix, PrefixNode node, int i = 0) { - unichar curr; - - prefix.get_next_char (ref i, out curr); - if (curr == '\0') { - return node; - } - - foreach (var child in node.children) { - if (child.value == curr) { - return find_prefix_at (prefix, child, i); - } - } - - return null; - } + return (owned)list; + } - public List get_all_matches (string prefix) { - var list = new List (); - var node = find_prefix_at (prefix, root, 0); - if (node != null) { - var sb = new StringBuilder (prefix); - get_all_matches_rec (node, ref sb, ref list); + public void remove_word (string word_to_remove) { + debug ("prefix tree: remove word %s", word_to_remove); + var end_node = root.find_end_node_for (word_to_remove); + if (end_node != null) { + if (!end_node.decrement ()) { + debug ("schedule remove %s", word_to_remove); + words_to_remove.add (end_node); + schedule_reaping (); + } else { + debug ("not removing %s - still occurs", word_to_remove); } - - return list; + } else { + debug ("%s end node not found in tree", word_to_remove); } + } - private void get_all_matches_rec ( - PrefixNode node, - ref StringBuilder sbuilder, - ref List matches) { - - foreach (var child in node.children) { - if (child.value == '\0') { - matches.append (sbuilder.str); + private void schedule_reaping () { + reaping_cancelled = false; + if (reaper_timeout_id > 0) { + delay_reaping = true; + return; + } else { + reaper_timeout_id = Timeout.add (REAPING_THROTTLE_MS, () => { + if (delay_reaping) { + delay_reaping = false; + return Source.CONTINUE; } else { - sbuilder.append_unichar (child.value); - get_all_matches_rec (child, ref sbuilder, ref matches); - var length = child.value.to_string ().length; - sbuilder.erase (sbuilder.len - length, -1); + reaper_timeout_id = 0; + debug ("reaping"); + words_to_remove.foreach ((end_node) => { + if (reaping_cancelled) { + debug ("reaping was cancelled"); + return false; + } + + if (!end_node.occurs ()) { + end_node.parent.remove_child (end_node); + } else { + debug ("still occurs when reaping"); + } + + return true; + }); + + // Cannot clear list while inside @foreach loop so do it now + words_to_remove.clear (); + return Source.REMOVE; } - } + }); } } } diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 79cb340450..37a9199f8a 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -621,6 +621,7 @@ namespace Scratch { title = _("%s - %s").printf (doc.get_basename (), base_title); toolbar.set_document_focus (doc); + folder_manager_view.select_path (doc.file.get_path ()); // Must follow setting focus document for editorconfig plug diff --git a/src/Widgets/SourceList/SourceList.vala b/src/Widgets/SourceList/SourceList.vala index 0ecff6daea..06fdcc783b 100644 --- a/src/Widgets/SourceList/SourceList.vala +++ b/src/Widgets/SourceList/SourceList.vala @@ -870,7 +870,11 @@ public class SourceList : Gtk.ScrolledWindow { return items.has_key (item); } - public void update_item (Item item) requires (has_item (item)) { + public void update_item (Item item) { + if (!has_item (item)) { + return; + } + assert (root != null); // Emitting row_changed() for this item's row in the child model causes the filter