using System; using System.Timers; using AppKit; using CoreGraphics; using Foundation; namespace Cauldron.Macos.SourceWriter; [Register("SourceTextView")] public class SourceTextView : NSTextView { #region Static Constants /// Defines the constant Unicode value of the enter key. public const int EnterKey = 13; /// Defines the constant Unicode value of the tab key. public const int TabKey = 9; /// Defines the constant Unicode value of the shift-tab key. public const int ShiftTabKey = 25; #endregion #region Private Variables /// The current language formatter used to highlight syntax. private LanguageFormatter _formatter; /// Should the editor auto complete closures. private bool _completeClosures = true; /// Should the editor auto wrap selected text in. private bool _wrapClosures = true; /// /// Should the edit select the section of text that has just been wrapped in a closure. /// private bool _selectAfterWrap = true; /// Should the editor provide auto completion of partial words. private bool _allowAutoComplete = true; /// /// Should the editor auto complete keywords as defined in the current language. /// private bool _autoCompleteKeywords = true; /// Should the editor use the default words list for auto complete. private bool _autoCompleteDefaultWords = true; /// Should the editor only use default words if the keyword list is empty. private bool _defaultWordsOnlyIfKeywordsEmpty = true; private LineNumberRuler LineNumberRuler; #endregion #region Computed Properties /// /// Gets or sets the used to perform /// syntax highlighting on this NSTextView containing the contents of the document being /// edited. /// /// The for the selected language. [Export("Formatter")] public LanguageFormatter Formatter { get { return _formatter; } set { WillChangeValue("Formatter"); _formatter = value; DidChangeValue("Formatter"); } } /// /// Gets or sets a value indicating whether this allows auto complete /// of partial words. /// /// true if allows auto complete; otherwise, false. [Export("AllowAutoComplete")] public bool AllowAutoComplete { get { return _allowAutoComplete; } set { WillChangeValue("AllowAutoComplete"); _allowAutoComplete = value; DidChangeValue("AllowAutoComplete"); } } /// /// Gets or sets a value indicating whether this auto completes keywords. /// /// true if auto completes keywords; otherwise, false. [Export("AutoCompleteKeywords")] public bool AutoCompleteKeywords { get { return _autoCompleteKeywords; } set { WillChangeValue("AutoCompleteKeywords"); _autoCompleteKeywords = value; DidChangeValue("AutoCompleteKeywords"); } } /// /// Gets or sets a value indicating whether this auto completes /// default words. /// /// true if auto complete default words; otherwise, false. [Export("AutoCompleteDefaultWords")] public bool AutoCompleteDefaultWords { get { return _autoCompleteDefaultWords; } set { WillChangeValue("AutoCompleteDefaultWords"); _autoCompleteDefaultWords = value; DidChangeValue("AutoCompleteDefaultWords"); } } /// /// Gets or sets a value indicating whether this /// uses the default words (provided by OS X) only if keywords empty. /// /// true if use the default words only if keywords empty; otherwise, false. [Export("DefaultWordsOnlyIfKeywordsEmpty")] public bool DefaultWordsOnlyIfKeywordsEmpty { get { return _defaultWordsOnlyIfKeywordsEmpty; } set { WillChangeValue("DefaultWordsOnlyIfKeywordsEmpty"); _defaultWordsOnlyIfKeywordsEmpty = value; DidChangeValue("DefaultWordsOnlyIfKeywordsEmpty"); } } /// /// Gets or sets a value indicating whether this complete closures. /// /// true if complete closures; otherwise, false. [Export("CompleteClosures")] public bool CompleteClosures { get { return _completeClosures; } set { WillChangeValue("CompleteClosures"); _completeClosures = value; DidChangeValue("CompleteClosures"); } } /// /// Gets or sets a value indicating whether this wrap closures. /// /// true if wrap closures; otherwise, false. [Export("WrapClosures")] public bool WrapClosures { get { return _wrapClosures; } set { WillChangeValue("WrapClosures"); _wrapClosures = true; DidChangeValue("WrapClosures"); } } /// /// Gets or sets a value indicating whether this selects /// the text that has just been wrapped in a closure. /// /// true if select after wrap; otherwise, false. [Export("SelectAfterWrap")] public bool SelectAfterWrap { get { return _selectAfterWrap; } set { WillChangeValue("SelectAfterWrap"); _selectAfterWrap = value; DidChangeValue("SelectAfterWrap"); } } #endregion #region Constructors /// Initializes a new instance of the class. public SourceTextView() { // Init Initialize(); } /// Initializes a new instance of the class. /// Frame rect. public SourceTextView(CGRect frameRect) : base(frameRect) { // Init Initialize(); } /// Initializes a new instance of the class. /// Frame rect. /// Container. public SourceTextView(CGRect frameRect, NSTextContainer container) : base(frameRect, container) { // Init Initialize(); } /// Initializes a new instance of the class. /// Coder. public SourceTextView(NSCoder coder) : base(coder) { // Init Initialize(); } /// Initializes a new instance of the class. /// Handle. public SourceTextView(IntPtr handle) : base(handle) { Initialize(); } public SourceTextView(ObjCRuntime.NativeHandle handle) : base(handle) { Initialize(); } /// Initialize this instance. private void Initialize() { this.Delegate = new SourceTextViewDelegate(this); this.UsesAdaptiveColorMappingForDarkAppearance = true; } public override void AwakeFromNib() { base.AwakeFromNib(); this.LineNumberRuler = new LineNumberRuler(this); this.EnclosingScrollView.VerticalRulerView = this.LineNumberRuler; this.EnclosingScrollView.HasVerticalRuler = true; this.EnclosingScrollView.RulersVisible = true; this.PostsFrameChangedNotifications = true; NSView.Notifications.ObserveBoundsChanged((_, _) => this.DrawGutter()); this.OnTextChanged += (_, _) => this.DrawGutter(); } [Export("drawGutter")] public void DrawGutter() { if (this.LineNumberRuler is not null) this.LineNumberRuler.NeedsDisplay = true; } #endregion #region Private Methods /// /// Calculates the indent level by counting the number of tab characters /// at the start of the current line. /// /// The indent level as the number of tabs. /// The line of text being processed. private static int CalculateIndentLevel(string line) { int indent = 0; // Process all characters in the line for (int n = 0; n < line.Length; ++n) { var code = (int)line[n]; // Are we on a tab character? if (code == TabKey) { ++indent; } else { break; } } // Return result return indent; } /// /// Creates a string of n number of tab characters that will be used to keep /// the tab level of the current line of text. /// /// A string of n tab characters. /// The number of tab characters to insert in the string. private static string TabIndent(int indentLevel) { string indent = ""; // Assemble string for (int n = 0; n < indentLevel; ++n) { indent += (char)TabKey; } // Return indention return indent; } /// /// Increases the tab indent on the given section of text. /// /// The text with the tab indent increased by one. /// The text to indent. private string IncreaseTabIndent(string text) { string output = ""; // Add first intent output += (char)TabKey; for (int n = 0; n < text.Length; ++n) { var c = text[n]; bool found = c == Formatter.Newline || c == Formatter.LineSeparator || c == Formatter.ParagraphSeparator; // Include char in output output += c; // Increase tab level? if (found) { // Yes output += (char)TabKey; } } // Return results return output; } /// Decreases the tab indent for the given text /// The text with the tab indent decreased by one. /// The text to outdent. private string DecreaseTabIndent(string text) { string output = ""; bool consume = true; // Add first intent for (int n = 0; n < text.Length; ++n) { var c = text[n]; bool found = (c == Formatter.Newline || c == Formatter.LineSeparator || c == Formatter.ParagraphSeparator); // Include char in output? if ((int)c == TabKey && consume) { consume = false; } else { output += c; } // Decrease tab level? if (found) { // Yes consume = true; } } // Return results return output; } #endregion #region Public Methods /// Indents the currently selected text. public void IndentText() { // Grab range var range = Formatter.FindLineBoundries(TextStorage.Value, SelectedRange); var line = TextStorage.Value.Substring((int)range.Location, (int)range.Length); // Increase tab indent var output = IncreaseTabIndent(line); // Reformat section TextStorage.BeginEditing(); Replace(range, output); TextStorage.EndEditing(); SelectedRange = new NSRange(range.Location, output.Length); Formatter.HighlightSyntaxRegion(TextStorage.Value, SelectedRange); } /// Outdents the currently selected text. public void OutdentText() { // Grab range NSRange range = Formatter.FindLineBoundries(TextStorage.Value, SelectedRange); string line = TextStorage.Value.Substring((int)range.Location, (int)range.Length); // Decrease tab indent string output = DecreaseTabIndent(line); // reformat section TextStorage.BeginEditing(); Replace(range, output); TextStorage.EndEditing(); SelectedRange = new NSRange(range.Location, output.Length); Formatter.HighlightSyntaxRegion(TextStorage.Value, SelectedRange); } /// Performs the formatting command on the currectly selected range of text. /// /// The to apply. /// public void PerformFormattingCommand(LanguageFormatCommand command) { NSRange range = SelectedRange; // Apply to start of line? if (command.Postfix == "") { // Yes, find start range = Formatter.FindLineBoundries(TextStorage.Value, SelectedRange); } // Yes, get selected text string line = TextStorage.Value.Substring((int)range.Location, (int)range.Length); // Apply command string output = command.Prefix; output += line; output += command.Postfix; TextStorage.BeginEditing(); Replace(range, output); TextStorage.EndEditing(); Formatter.HighlightSyntaxRegion(TextStorage.Value, range); } #endregion #region Override Methods private Timer InputTimoutTimer { get; set; } /// /// The amount of time with no user input after which will be run /// public TimeSpan InputTimeoutInterval { get; set; } = new TimeSpan(0, 0, 1); /// /// An event triggered when the user has stopped typing for a period of time defined by /// /// public event ElapsedEventHandler OnFinishedTyping; /// Triggered when the value in the textbox is changed public event EventHandler OnTextChanged; /// /// Look for special keys being pressed and does specific processing based on the key. /// /// The event. public override void KeyDown(NSEvent theEvent) { NSRange range; string line; int indentLevel = 0; bool consumeKeystroke = false; // Avoid processing if no Formatter has been attached if (Formatter == null) return; // Trap all errors try { // Get the code of current character char c = theEvent.Characters[0]; int charCode = (int)theEvent.Characters[0]; // Preprocess based on character code switch (charCode) { case EnterKey: // Get the tab indent level range = Formatter.FindLineBoundries(TextStorage.Value, SelectedRange); line = TextStorage.Value.Substring((int)range.Location, (int)range.Length); indentLevel = CalculateIndentLevel(line); break; case TabKey: // Is a range selected? if (SelectedRange.Length > 0) { // Increase tab indent over the entire selection IndentText(); consumeKeystroke = true; } break; case ShiftTabKey: // Is a range selected? if (SelectedRange.Length > 0) { // Increase tab indent over the entire selection OutdentText(); consumeKeystroke = true; } break; default: // Are we completing closures if (CompleteClosures) { if (WrapClosures && SelectedRange.Length > 0) { // Yes, see if we are starting a closure foreach (LanguageClosure closure in Formatter.Language.Closures) { // Found? if (closure.StartingCharacter == c) { // Yes, get selected text nint location = SelectedRange.Location; line = TextStorage.Value.Substring((int)SelectedRange.Location, (int)SelectedRange.Length); string output = ""; output += closure.StartingCharacter; output += line; output += closure.EndingCharacter; TextStorage.BeginEditing(); Replace(SelectedRange, output); TextStorage.EndEditing(); if (SelectAfterWrap) { SelectedRange = new NSRange(location, output.Length); } consumeKeystroke = true; Formatter.HighlightSyntaxRegion(TextStorage.Value, SelectedRange); } } } else { // Yes, see if we are in a language defined closure foreach (LanguageClosure closure in Formatter.Language.Closures) { // Found? if (closure.StartingCharacter == c) { // Is this a valid location for a completion? if (Formatter.TrailingCharacterIsWhitespaceOrTerminator(TextStorage.Value, SelectedRange)) { // Yes, complete closure consumeKeystroke = true; string output = ""; output += closure.StartingCharacter; output += closure.EndingCharacter; TextStorage.BeginEditing(); InsertText(new NSString(output)); TextStorage.EndEditing(); SelectedRange = new NSRange(SelectedRange.Location - 1, 0); } } } } } break; } // Call base to handle event if (!consumeKeystroke) base.KeyDown(theEvent); // Post process based on character code switch (charCode) { case EnterKey: // Tab indent the new line to the same level if (indentLevel > 0) { string indent = TabIndent(indentLevel); TextStorage.BeginEditing(); InsertText(new NSString(indent)); TextStorage.EndEditing(); } break; } } catch { // Call base to process on any error base.KeyDown(theEvent); } this.Formatter.Reformat(); this.OnTextChanged.Invoke(this, null); this.InputTimoutTimer?.Stop(); this.InputTimoutTimer?.Close(); this.InputTimoutTimer = new(this.InputTimeoutInterval) { AutoReset = false }; this.InputTimoutTimer.Elapsed += this.OnFinishedTyping; this.InputTimoutTimer.Start(); } /// /// Called when a drag operation is started for this . /// /// The entered. /// Sender. /// /// See Apple's drag and drop docs for more details (https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/DragandDrop/DragandDrop.html) /// //public override NSDragOperation DraggingEntered(NSDraggingInfo sender) //{ // // When we start dragging, inform the system that we will be handling this as // // a copy/paste // return NSDragOperation.Copy; //} /// /// Process any drag operations initialized by the user to this . /// If one or more files have dragged in, the contents of those files will be copied into the document at the /// current cursor location. /// /// true, if drag operation was performed, false otherwise. /// The caller that initiated the drag operation. /// /// See Apple's drag and drop docs for more details (https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/DragandDrop/DragandDrop.html) /// //public override bool PerformDragOperation(NSDraggingInfo sender) //{ // // Attempt to read filenames from pasteboard // var plist = (NSArray)sender.DraggingPasteboard.GetPropertyListForType(NSPasteboard.NSFilenamesType); // // Was a list of files returned from Finder? // if (plist != null) // { // // Yes, process list // for (nuint n = 0; n < plist.Count; ++n) // { // // Get the current file // var path = plist.GetItem(n); // var url = NSUrl.FromString(path); // var contents = File.ReadAllText(path); // // Insert contents at cursor // NSRange range = SelectedRange; // TextStorage.BeginEditing(); // Replace(range, contents); // TextStorage.EndEditing(); // // Expand range to fully encompass new content and // // reformat // range = new NSRange(range.Location, contents.Length); // range = Formatter.FindLineBoundries(TextStorage.Value, range); // Formatter.HighlightSyntaxRegion(TextStorage.Value, range); // } // // Inform caller of success // return true; // } // else // { // // No, allow base class to handle // return base.PerformDragOperation(sender); // } //} /// Reads the selection from pasteboard. /// /// true, if the selection was read from the pasteboard, false otherwise. /// /// The pasteboard being read. /// /// This method is overridden to update the formatting after the user pastes text into the view. /// public override bool ReadSelectionFromPasteboard(NSPasteboard pboard) { // Console.WriteLine ("Read selection from pasteboard"); bool result = base.ReadSelectionFromPasteboard(pboard); Formatter?.Reformat(); this.OnTextChanged?.Invoke(this, null); return result; } /// Reads the selection from pasteboard. /// /// true, if the selection was read from the pasteboard, false otherwise. /// /// The pasteboard being read. /// The type of data being read from the pasteboard. /// /// This method is overridden to update the formatting after the user pastes text into the view. /// public override bool ReadSelectionFromPasteboard(NSPasteboard pboard, string type) { // Console.WriteLine ("Read selection from pasteboard also"); var result = base.ReadSelectionFromPasteboard(pboard, type); Formatter?.Reformat(); this.OnTextChanged?.Invoke(this, null); return result; } #endregion #region Events /// Occurs when source cell clicked. /// NOTE: This replaces the built-in CellClicked event because we /// are providing a custom NSTextViewDelegate and it is unavialable. public event EventHandler SourceCellClicked; /// Raises the source cell clicked event. /// The controller raising the event. /// Arguments defining the event. internal void RaiseSourceCellClicked(object sender, NSTextViewClickedEventArgs e) { this.SourceCellClicked?.Invoke(sender, e); } /// Occurs when source cell double clicked. /// NOTE: This replaces the built-in CellDoubleClicked event because we /// are providing a custom NSTextViewDelegate and it is unavialable. public event EventHandler SourceCellDoubleClicked; /// Raises the source cell double clicked event. /// The controller raising the event. /// Arguments defining the event. internal void RaiseSourceCellDoubleClicked(object sender, NSTextViewDoubleClickEventArgs e) { this.SourceCellDoubleClicked?.Invoke(sender, e); } /// Occurs when source cell dragged. /// /// NOTE: This replaces the built-in DragCell event because we are providing a custom /// NSTextViewDelegate and it is unavialable. /// public event EventHandler SourceCellDragged; /// Raises the source cell dragged event. /// The controller raising the event. /// Arguments defining the event. internal void RaiseSourceCellDragged(object sender, NSTextViewDraggedCellEventArgs e) { this.SourceCellDragged?.Invoke(sender, e); } /// Occurs when source selection changed. /// /// NOTE: This replaces the built-in DidChangeSelection event because we are providing a /// custom NSTextViewDelegate and it is unavialable. /// public event EventHandler SourceSelectionChanged; /// Raises the source selection changed event. /// The controller raising the event. /// Arguments defining the event. internal void RaiseSourceSelectionChanged(object sender, EventArgs e) { this.SourceSelectionChanged?.Invoke(sender, e); } /// Occurs when source typing attributes changed. /// NOTE: This replaces the built-in DidChangeTypingAttributes event because we /// are providing a custom NSTextViewDelegate and it is unavialable. public event EventHandler SourceTypingAttributesChanged; /// Raises the source typing attributes changed event. /// The controller raising the event. /// Arguments defining the event. internal void RaiseSourceTypingAttributesChanged(object sender, EventArgs e) { this.SourceTypingAttributesChanged?.Invoke(sender, e); } #endregion }