Editor window and tabbed code editor UI. Implements CodeWindow which manages tabs (CodeTab), opening/saving/reloading files, diagnostics and outline docks, find/go-to, status bar and a painted CodeTabStrip; includes a simple CodeOutline scanner for symbols.
using Editor.Prism.Core;
using Editor.Prism.Integration;
using Editor.Prism.Ui;
using Margin = Sandbox.UI.Margin;
using System.IO;
using System.Text;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;
namespace Editor.Prism.Text;
/// <summary>One open file or buffer in the <see cref="CodeWindow"/>.</summary>
public sealed class CodeTab
{
/// <summary>The editor widget showing this buffer.</summary>
public CodeEditorWidget Editor { get; init; }
/// <summary>The document. Shorthand for <c>Editor.Document</c>.</summary>
public TextDocument Document => Editor?.Document;
/// <summary>Absolute path on disk, or null for a synthetic buffer such as generated code.</summary>
public string FilePath { get; set; }
/// <summary>Tab caption.</summary>
public string Title { get; set; } = "untitled";
/// <summary>Language id driving highlighting.</summary>
public string Language { get; set; } = "hlsl";
/// <summary>Whether the buffer can be edited.</summary>
public bool ReadOnly { get; set; }
/// <summary>Whether the buffer differs from disk.</summary>
public bool IsModified => Document is { IsModified: true };
/// <summary>
/// Set when the file changed on disk while this buffer had unsaved edits, so the tab can say the
/// two have diverged. Cleared by a reload or a save.
/// </summary>
public bool ChangedOnDisk { get; set; }
/// <summary>Caption with the modified and diverged markers, as drawn on the tab.</summary>
public string DisplayTitle => ChangedOnDisk ? Title + " ⚠" : IsModified ? Title + " •" : Title;
/// <summary>
/// Completion, hover and background validation for this buffer. Owned by the tab and disposed with
/// it, because every part of it holds a reference to the editor widget.
/// </summary>
internal Completion.CodeIntelligence Intelligence { get; set; }
/// <summary>Cached tab width from the last paint, used for hit testing.</summary>
internal Rect TabRect { get; set; }
/// <summary>Diagnostic rendering.</summary>
public override string ToString() => Title;
}
/// <summary>One entry in the outline dock.</summary>
public sealed record CodeSymbol( string Name, string Detail, int Line, string Icon, int Depth );
/// <summary>
/// The code editor window: a tab strip over a stack of <see cref="CodeEditorWidget"/>s, a find bar, a
/// diagnostics dock and an outline dock. This is the shell WP-11 owns; the graph window docks its own
/// generated-code panel separately.
/// </summary>
public sealed class CodeWindow : DockWindow
{
static CodeWindow s_instance;
readonly List<CodeTab> _tabs = new();
CodeTabStrip _strip;
FindReplaceBar _findBar;
Widget _editorStack;
ListView _diagnosticsList;
ListView _outlineList;
LineEdit _outlineFilter;
Label _statusPosition;
Label _statusSelection;
Label _statusLanguage;
Label _statusEncoding;
Widget _diagnosticsPanel;
Widget _outlinePanel;
CodeTab _active;
RealTimeSince _sinceOutlineRefresh;
int _outlineVersion = -1;
/// <summary>The live window, or null when it has never been opened or was closed.</summary>
public static CodeWindow Instance => s_instance is { IsValid: true } ? s_instance : null;
/// <summary>Opens the window, or raises it when it is already open.</summary>
public static CodeWindow Open()
{
if ( Instance is not null )
{
Instance.Show();
Instance.Focus();
return Instance;
}
var window = new CodeWindow();
window.Show();
return window;
}
/// <summary>Opens a file in the window, creating the window if needed. Line and column are one-based.</summary>
public static CodeWindow OpenFile( string absolutePath, int line = 0, int column = 1 )
{
var window = Open();
if ( window is null )
return null;
var tab = window.OpenDocument( absolutePath );
if ( tab is not null && line > 0 )
tab.Editor.GoToLine( line, Math.Max( 1, column ) );
return window;
}
/// <summary>Creates the window. Prefer <see cref="Open"/>.</summary>
public CodeWindow()
{
s_instance = this;
DeleteOnClose = true;
Title = $"{PrismConstants.ProductName} — Code";
Size = new Vector2( 1280, 820 );
PrismLog.Guard( "Prism.Text: window icon", () => SetWindowIcon( "code" ) );
BuildMenu();
BuildStatusBar();
var host = BuildHost();
DockManager.SetCentralWidget( host );
BuildDocks();
// Assigning the cookie restores window geometry and the saved dock layout, so every dock has
// to exist by now or the restore has nothing to place.
StateCookie = "PrismCodeWindow";
// External-change detection. AssetHooks watches the content folder; without this subscriber a
// file edited in another program stayed stale in the buffer here and was silently overwritten
// by the next save. Nothing reloads behind the user's back — an unmodified buffer refreshes in
// place, a modified one is marked and says so.
AssetHooks.ShaderSourceChangedOnDisk += OnFileChangedOnDisk;
AssetHooks.DocumentChangedOnDisk += OnFileChangedOnDisk;
UpdateStatus();
}
/// <summary>
/// A file this window has open was changed by something else.
/// <para>
/// An untouched buffer is re-read on the spot: it has nothing to lose and showing stale text is
/// strictly worse. A buffer with unsaved edits is left exactly as it is and the status bar says the
/// file moved underneath it, because silently discarding the user's work — or silently keeping it
/// and overwriting theirs — are both worse than telling them.
/// </para>
/// </summary>
void OnFileChangedOnDisk( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) || !this.IsValid() ) return;
PrismLog.Guard( "Handling an external file change", () =>
{
var full = Path.GetFullPath( absolutePath );
foreach ( var tab in _tabs )
{
if ( tab?.FilePath is null ) continue;
if ( !string.Equals( Path.GetFullPath( tab.FilePath ), full, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( tab.IsModified )
{
tab.ChangedOnDisk = true;
_strip?.Update();
StatusBar?.ShowMessage(
$"\"{tab.Title}\" changed on disk and has unsaved edits — File ▸ Reload From Disk to take theirs" );
continue;
}
ReloadTab( tab );
}
} );
}
/// <summary>Every open tab, in strip order.</summary>
public IReadOnlyList<CodeTab> Tabs => _tabs;
/// <summary>The tab currently showing, or null.</summary>
public CodeTab ActiveTab => _active;
/// <summary>The editor currently showing, or null.</summary>
public CodeEditorWidget ActiveEditor => _active?.Editor;
// ---- construction -----------------------------------------------------
Widget BuildHost()
{
var host = new Widget( null );
host.Layout = Layout.Column();
host.Layout.Margin = 0;
host.Layout.Spacing = 0;
_strip = new CodeTabStrip( host );
_strip.TabSelected = SetActiveTab;
_strip.TabClosed = tab => CloseTab( tab );
_strip.NewTabRequested = () => NewDocument();
host.Layout.Add( _strip );
_findBar = new FindReplaceBar( host );
host.Layout.Add( _findBar );
_editorStack = new Widget( host );
_editorStack.Layout = Layout.Column();
_editorStack.Layout.Margin = 0;
host.Layout.Add( _editorStack, 1 );
return host;
}
void BuildDocks()
{
_diagnosticsPanel = BuildDiagnosticsPanel();
_outlinePanel = BuildOutlinePanel();
DockManager.AddDock( "Diagnostics", "error_outline", _diagnosticsPanel, DockArea.Bottom );
DockManager.AddDock( "Outline", "list", _outlinePanel, DockArea.Right );
}
Widget BuildDiagnosticsPanel()
{
var panel = new Widget( null );
panel.Layout = Layout.Column();
panel.Layout.Margin = 0;
_diagnosticsList = new ListView( panel )
{
ItemSize = new Vector2( -1, 24 ),
ItemPaint = PaintDiagnosticRow,
ItemActivated = OnDiagnosticActivated,
ItemClicked = OnDiagnosticActivated
};
panel.Layout.Add( _diagnosticsList, 1 );
return panel;
}
Widget BuildOutlinePanel()
{
var panel = new Widget( null );
panel.Layout = Layout.Column();
panel.Layout.Margin = new Margin( 4, 4, 4, 4 );
panel.Layout.Spacing = 4;
_outlineFilter = new LineEdit( panel ) { PlaceholderText = "Filter symbols" };
_outlineFilter.TextEdited += _ => RefreshOutline( true );
panel.Layout.Add( _outlineFilter );
_outlineList = new ListView( panel )
{
ItemSize = new Vector2( -1, 22 ),
ItemPaint = PaintOutlineRow,
ItemActivated = OnOutlineActivated,
ItemClicked = OnOutlineActivated
};
panel.Layout.Add( _outlineList, 1 );
return panel;
}
/// <summary>Places the docks in their default arrangement.</summary>
protected override void BuildDefaultLayout()
{
var diagnostics = DockManager.OpenDock( "Diagnostics", DockArea.Bottom );
var outline = DockManager.OpenDock( "Outline", DockArea.Right );
PrismLog.Guard( "Prism.Text: default layout", () =>
{
DockManager.SetSplitterProportions( outline, 0.78f, 0.22f );
DockManager.SetSplitterProportions( diagnostics, 0.76f, 0.24f );
} );
}
void BuildStatusBar()
{
StatusBar = new StatusBar( this );
_statusPosition = new Label( "Ln 1, Col 1" ) { Color = PrismTheme.TextSecondary };
_statusSelection = new Label( "" ) { Color = PrismTheme.TextMuted };
_statusLanguage = new Label( "" ) { Color = PrismTheme.TextSecondary };
_statusEncoding = new Label( "" ) { Color = PrismTheme.TextMuted };
StatusBar.AddWidgetLeft( _statusPosition );
StatusBar.AddWidgetLeft( _statusSelection );
StatusBar.AddWidgetRight( _statusLanguage );
StatusBar.AddWidgetRight( _statusEncoding );
}
void BuildMenu()
{
var menu = new MenuBar( this );
MenuBar = menu;
menu.AddOption( "File/New", "note_add", () => NewDocument(), "Ctrl+N" );
menu.AddOption( "File/Open…", "folder_open", PromptOpen, "Ctrl+O" );
menu.AddSeparator();
menu.AddOption( "File/Save", "save", () => SaveActive(), "Ctrl+S" );
menu.AddOption( "File/Save As…", "save_as", PromptSaveAs );
menu.AddOption( "File/Save All", "done_all", () => SaveAll(), "Ctrl+Shift+S" );
menu.AddSeparator();
menu.AddOption( "File/Reload From Disk", "refresh", () => ReloadTab( _active ) );
menu.AddOption( "File/Open in External Editor", "open_in_new", OpenExternally );
menu.AddSeparator();
menu.AddOption( "File/Close Tab", "close", () => { if ( _active is not null ) CloseTab( _active ); }, "Ctrl+W" );
menu.AddOption( "File/Close Window", "logout", Close );
menu.AddOption( "Edit/Undo", "undo", () => WithEditor( e => { e.Controller.PerformUndo(); e.EnsureCaretVisible(); } ), "Ctrl+Z" );
menu.AddOption( "Edit/Redo", "redo", () => WithEditor( e => { e.Controller.PerformRedo(); e.EnsureCaretVisible(); } ), "Ctrl+Shift+Z" );
menu.AddSeparator();
menu.AddOption( "Edit/Cut", "content_cut", () => WithEditor( e => e.Controller.Cut() ), "Ctrl+X" );
menu.AddOption( "Edit/Copy", "content_copy", () => WithEditor( e => e.Controller.Copy() ), "Ctrl+C" );
menu.AddOption( "Edit/Paste", "content_paste", () => WithEditor( e => e.Controller.Paste() ), "Ctrl+V" );
menu.AddSeparator();
menu.AddOption( "Edit/Find…", "search", () => ShowFind( false ), "Ctrl+F" );
menu.AddOption( "Edit/Replace…", "find_replace", () => ShowFind( true ), "Ctrl+H" );
menu.AddOption( "Edit/Go To Line…", "my_location", ShowGoToLine, "Ctrl+G" );
menu.AddSeparator();
menu.AddOption( "Edit/Toggle Comment", "comment", () => WithEditor( e => e.Controller.ToggleLineComment() ), "Ctrl+/" );
menu.AddOption( "Edit/Toggle Block Comment", "notes", () => WithEditor( e => e.Controller.ToggleBlockComment() ) );
menu.AddOption( "Edit/Trim Trailing Whitespace", "cleaning_services", () => WithEditor( e => e.Controller.TrimTrailingWhitespace() ) );
AddToggle( menu, "View/Line Numbers", () => ActiveEditor?.ShowLineNumbers ?? true, value => ForEachEditor( e => e.ShowLineNumbers = value ) );
AddToggle( menu, "View/Indent Guides", () => ActiveEditor?.ShowIndentGuides ?? true, value => ForEachEditor( e => e.ShowIndentGuides = value ) );
AddToggle( menu, "View/Whitespace", () => ActiveEditor?.ShowWhitespace ?? false, value => ForEachEditor( e => e.ShowWhitespace = value ) );
AddToggle( menu, "View/Current Line Highlight", () => ActiveEditor?.HighlightCurrentLine ?? true, value => ForEachEditor( e => e.HighlightCurrentLine = value ) );
AddToggle( menu, "View/Occurrence Highlight", () => ActiveEditor?.HighlightOccurrences ?? true, value => ForEachEditor( e => e.HighlightOccurrences = value ) );
AddToggle( menu, "View/Column Ruler", () => ActiveEditor?.ShowRuler ?? false, value => ForEachEditor( e => e.ShowRuler = value ) );
menu.AddSeparator();
menu.AddOption( "View/Zoom In", "zoom_in", () => ForEachEditor( e => e.FontSize++ ), "Ctrl++" );
menu.AddOption( "View/Zoom Out", "zoom_out", () => ForEachEditor( e => e.FontSize-- ), "Ctrl+-" );
menu.AddOption( "View/Reset Zoom", "search", () => ForEachEditor( e => e.FontSize = PrismTheme.CodeSize ) );
menu.AddSeparator();
menu.AddOption( "View/Fold All", "unfold_less", () => WithEditor( e => { e.Folding?.CollapseAll(); e.LayoutScrollbars(); e.Update(); } ) );
menu.AddOption( "View/Unfold All", "unfold_more", () => WithEditor( e => { e.Folding?.ExpandAll(); e.LayoutScrollbars(); e.Update(); } ) );
var view = menu.FindOrCreateMenu( "View" );
if ( view is not null )
{
view.AddSeparator();
var docks = view.AddMenu( "Panels", "dashboard" );
docks.AboutToShow += () => CreateDynamicViewMenu( docks );
}
menu.AddOption( "Go/Next Problem", "arrow_downward", () => StepDiagnostic( 1 ), "F8" );
menu.AddOption( "Go/Previous Problem", "arrow_upward", () => StepDiagnostic( -1 ), "Shift+F8" );
menu.AddSeparator();
menu.AddOption( "Go/Next Match", "navigate_next", () => _findBar?.FindNext(), "F3" );
menu.AddOption( "Go/Previous Match", "navigate_before", () => _findBar?.FindNext( false ), "Shift+F3" );
}
static void AddToggle( MenuBar menu, string path, Func<bool> get, Action<bool> set )
{
var option = menu.AddOption( path, null, null );
option.Checkable = true;
option.FetchCheckedState = get;
option.Toggled += set;
}
// ---- tabs -------------------------------------------------------------
/// <summary>Creates an empty buffer and focuses it.</summary>
public CodeTab NewDocument( string language = "hlsl" )
{
var tab = CreateTab( new TextDocument(), "untitled", null, language, false );
SetActiveTab( tab );
return tab;
}
/// <summary>
/// Opens a file, focusing the existing tab when it is already open. Returns null when the file
/// could not be read.
/// </summary>
public CodeTab OpenDocument( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) )
return null;
var full = PrismLog.Guard( "Prism.Text: resolve path", () => Path.GetFullPath( absolutePath ), absolutePath );
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( !string.Equals( _tabs[i].FilePath, full, StringComparison.OrdinalIgnoreCase ) )
continue;
SetActiveTab( _tabs[i] );
return _tabs[i];
}
// Breadcrumbs. Opening a file walks straight into the engine's shader compiler, and a native fault
// there takes the process down with no managed exception and nothing in the log — so the log has
// to say how far we got before it happened.
PrismLog.Info( $"Prism.Text: opening '{full}'" );
var document = new TextDocument();
if ( !document.Load( full ) )
{
StatusBar?.ShowMessage( $"Could not open {Path.GetFileName( full )}: {document.LoadError}" );
return null;
}
var language = LanguageForPath( full );
PrismLog.Info( $"Prism.Text: loaded {document.LineCount} line(s), language '{language}' — building the tab" );
var tab = CreateTab( document, Path.GetFileName( full ), full, language, false );
SetActiveTab( tab );
PrismLog.Info( $"Prism.Text: '{Path.GetFileName( full )}' is open" );
return tab;
}
/// <summary>Opens an in-memory buffer, such as generated shader text. Returns the new tab.</summary>
public CodeTab OpenText( string title, string text, string language, bool readOnly = true )
{
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].FilePath is not null || !string.Equals( _tabs[i].Title, title, StringComparison.Ordinal ) )
continue;
_tabs[i].Editor.SetText( text, language );
SetActiveTab( _tabs[i] );
return _tabs[i];
}
var document = new TextDocument( text ?? string.Empty );
document.MarkSaved();
var tab = CreateTab( document, title, null, language, readOnly );
SetActiveTab( tab );
return tab;
}
CodeTab CreateTab( TextDocument document, string title, string path, string language, bool readOnly )
{
var editor = new CodeEditorWidget( _editorStack )
{
ReadOnly = readOnly
};
editor.SetDocument( document, language );
editor.ReadOnly = readOnly;
var tab = new CodeTab
{
Editor = editor,
FilePath = path,
Title = string.IsNullOrEmpty( title ) ? "untitled" : title,
Language = language,
ReadOnly = readOnly
};
editor.UnhandledKey = ( _, key ) => HandleWindowKey( key );
editor.SaveRequested += _ => SaveTab( tab );
editor.FindRequested += ( _, replace ) => ShowFind( replace );
editor.GoToLineRequested += _ => ShowGoToLine();
editor.FindStepRequested += ( _, direction ) => _findBar?.FindNext( direction >= 0 );
editor.CaretMoved += _ => UpdateStatus();
editor.TextChanged += _ =>
{
_strip?.Update();
UpdateStatus();
};
// Completion, signature help, hover and background validation, all in one attach. A read-only
// buffer still gets hover and highlighting, but not the compiler tier: it is generated text the
// user cannot fix, and probe-compiling it on every keystroke it will never receive is waste.
tab.Intelligence = PrismLog.Guard( "Prism.Text: attach code intelligence",
() => Completion.CodeIntelligence.Attach( editor, path, !readOnly ), null );
_tabs.Add( tab );
_editorStack.Layout.Add( editor, 1 );
editor.Visible = false;
_strip.SetTabs( _tabs );
return tab;
}
/// <summary>Shows one tab and hides the rest.</summary>
public void SetActiveTab( CodeTab tab )
{
if ( tab is null || !_tabs.Contains( tab ) )
return;
_active = tab;
for ( var i = 0; i < _tabs.Count; i++ )
_tabs[i].Editor.Visible = ReferenceEquals( _tabs[i], tab );
_findBar.Editor = tab.Editor;
if ( _findBar.Visible )
_findBar.Refresh();
_strip.SetActive( tab );
_outlineVersion = -1;
RefreshDiagnostics();
RefreshOutline( true );
UpdateStatus();
tab.Editor.Focus();
tab.Editor.Update();
}
/// <summary>
/// Closes a tab. A modified buffer raises a non-blocking prompt and returns false; answering the
/// prompt closes the tab. Pass <paramref name="force"/> to skip the prompt.
/// </summary>
public bool CloseTab( CodeTab tab, bool force = false )
{
if ( tab is null || !_tabs.Contains( tab ) )
return false;
if ( !force && tab.IsModified && !tab.ReadOnly )
{
PromptUnsaved( $"\"{tab.Title}\" has unsaved changes.",
() => { if ( SaveTab( tab ) ) CloseTab( tab, true ); },
() => CloseTab( tab, true ) );
return false;
}
var index = _tabs.IndexOf( tab );
_tabs.Remove( tab );
PrismLog.Guard( "Prism.Text: destroy editor", () =>
{
// Before the widget, not after: the completion popup, the hover watcher and the validator
// all hold the editor and all unsubscribe from it on dispose.
tab.Intelligence?.Dispose();
tab.Intelligence = null;
tab.Editor.Teardown();
tab.Editor.Destroy();
} );
_strip.SetTabs( _tabs );
if ( ReferenceEquals( _active, tab ) )
{
_active = null;
if ( _tabs.Count > 0 )
SetActiveTab( _tabs[Math.Clamp( index, 0, _tabs.Count - 1 )] );
else
UpdateStatus();
}
return true;
}
/// <summary>Saves the active tab.</summary>
public bool SaveActive() => SaveTab( _active );
/// <summary>Saves one tab, prompting for a path when it has none.</summary>
public bool SaveTab( CodeTab tab )
{
if ( tab is null || tab.ReadOnly )
return false;
if ( string.IsNullOrEmpty( tab.FilePath ) )
return SaveTabAs( tab );
if ( !tab.Document.Save( tab.FilePath ) )
{
StatusBar?.ShowMessage( $"Could not save {tab.Title}: {tab.Document.SaveError}" );
return false;
}
// Whatever the file said before, this buffer is now what is on disk.
tab.ChangedOnDisk = false;
StatusBar?.ShowMessage( $"Saved {tab.Title}" );
_strip?.Update();
UpdateStatus();
return true;
}
/// <summary>Saves one tab to a path chosen by the user.</summary>
public bool SaveTabAs( CodeTab tab )
{
if ( tab is null )
return false;
var dialog = new FileDialog( this ) { Title = "Save Shader Source" };
dialog.SetModeSave();
dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );
dialog.DefaultSuffix = tab.Language == "slang" ? PrismConstants.SlangExtension : PrismConstants.HlslExtension;
if ( !string.IsNullOrEmpty( tab.FilePath ) )
dialog.SelectFile( tab.FilePath );
if ( !dialog.Execute() )
return false;
var path = dialog.SelectedFile;
if ( string.IsNullOrWhiteSpace( path ) )
return false;
if ( !tab.Document.Save( path ) )
{
StatusBar?.ShowMessage( $"Could not save: {tab.Document.SaveError}" );
return false;
}
tab.FilePath = path;
tab.Title = Path.GetFileName( path );
tab.Language = LanguageForPath( path );
tab.Editor.Language = tab.Language;
// Include resolution is relative to the including file's own directory, so a buffer that just
// moved resolves its includes from somewhere else now.
if ( tab.Intelligence is not null ) tab.Intelligence.FilePath = path;
_strip.SetTabs( _tabs );
UpdateStatus();
return true;
}
/// <summary>
/// Re-reads a tab from disk, keeping the viewport. A modified buffer prompts first; answering the
/// prompt performs the reload.
/// </summary>
public bool ReloadTab( CodeTab tab )
{
if ( tab?.FilePath is null )
return false;
if ( tab.IsModified )
{
PromptUnsaved( $"\"{tab.Title}\" has unsaved changes that reloading will discard.",
() => { if ( SaveTab( tab ) ) ReloadTab( tab ); },
() => { tab.Document.MarkSaved(); ReloadTab( tab ); } );
return false;
}
var fresh = new TextDocument();
if ( !fresh.Load( tab.FilePath ) )
{
StatusBar?.ShowMessage( $"Could not reload {tab.Title}: {fresh.LoadError}" );
return false;
}
tab.Document.LineEnding = fresh.LineEnding;
tab.Document.Encoding = fresh.Encoding;
tab.Document.HasByteOrderMark = fresh.HasByteOrderMark;
tab.Editor.SetText( fresh.Text, tab.Language );
tab.ChangedOnDisk = false;
StatusBar?.ShowMessage( $"Reloaded {tab.Title}" );
_strip?.Update();
UpdateStatus();
return true;
}
/// <summary>Saves every modified tab that has a path.</summary>
public int SaveAll()
{
var saved = 0;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].IsModified && !_tabs[i].ReadOnly && SaveTab( _tabs[i] ) )
saved++;
}
return saved;
}
void PromptOpen()
{
var dialog = new FileDialog( this ) { Title = "Open Shader Source" };
dialog.SetModeOpen();
dialog.SetFindExistingFile();
dialog.SetNameFilter( "Shader source (*.hlsl *.slang *.shader *.fxc *.h *.txt)" );
if ( dialog.Execute() )
OpenDocument( dialog.SelectedFile );
}
void PromptSaveAs() => SaveTabAs( _active );
void OpenExternally()
{
var tab = _active;
if ( tab?.FilePath is null )
return;
PrismLog.Guard( "Prism.Text: external editor",
() => CodeEditor.OpenFile( tab.FilePath, tab.Editor.CaretPosition.Line + 1, tab.Editor.CaretPosition.Column + 1 ) );
}
/// <summary>Maps a file extension to a lexer language id.</summary>
public static string LanguageForPath( string path )
{
if ( string.IsNullOrEmpty( path ) )
return "hlsl";
var extension = Path.GetExtension( path ).ToLowerInvariant();
return extension switch
{
".slang" => "slang",
".shader" => "vfx",
".vfx" => "vfx",
_ => "hlsl"
};
}
// ---- find and navigation ----------------------------------------------
/// <summary>Shows the find bar, optionally with the replace row.</summary>
public void ShowFind( bool replace )
{
if ( _active is null )
return;
_findBar.Editor = _active.Editor;
_findBar.Open( replace );
}
/// <summary>Opens the go-to-line prompt.</summary>
public void ShowGoToLine()
{
var editor = ActiveEditor;
if ( editor is null )
return;
var popup = new PopupWidget( this );
popup.Layout = Layout.Row();
popup.Layout.Margin = new Margin( 8, 6, 8, 6 );
popup.Layout.Spacing = 6;
popup.Layout.Add( new Label( $"Go to line (1 – {editor.Document.LineCount}):" ) );
var entry = new LineEdit( popup ) { PlaceholderText = "line[:column]" };
entry.MinimumWidth = 120;
entry.ReturnPressed += () =>
{
var parts = (entry.Text ?? string.Empty).Split( ':', StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length > 0 && int.TryParse( parts[0].Trim(), out var line ) )
{
var column = 1;
if ( parts.Length > 1 )
int.TryParse( parts[1].Trim(), out column );
editor.GoToLine( line, Math.Max( 1, column ) );
}
popup.Destroy();
};
popup.Layout.Add( entry );
popup.OpenAtCursor();
entry.Focus();
}
void StepDiagnostic( int direction )
{
var editor = ActiveEditor;
if ( editor is null || editor.Diagnostics.Count == 0 )
return;
var ordered = new List<CodeDiagnostic>( editor.Diagnostics );
ordered.Sort( static ( a, b ) => a.Range.Min.CompareTo( b.Range.Min ) );
var caret = editor.CaretPosition;
var target = -1;
if ( direction >= 0 )
{
for ( var i = 0; i < ordered.Count; i++ )
{
if ( ordered[i].Range.Min > caret )
{
target = i;
break;
}
}
if ( target < 0 )
target = 0;
}
else
{
for ( var i = ordered.Count - 1; i >= 0; i-- )
{
if ( ordered[i].Range.Min < caret )
{
target = i;
break;
}
}
if ( target < 0 )
target = ordered.Count - 1;
}
editor.Reveal( ordered[target].Range, true, 4 );
_diagnosticsList?.SelectItem( ordered[target] );
}
// ---- diagnostics ------------------------------------------------------
/// <summary>Pushes pipeline diagnostics onto a tab and refreshes the dock.</summary>
public void SetDiagnostics( CodeTab tab, IEnumerable<PrismDiagnostic> diagnostics )
{
if ( tab is null )
return;
tab.Editor.SetDiagnostics( diagnostics );
if ( ReferenceEquals( tab, _active ) )
RefreshDiagnostics();
}
/// <summary>Rebuilds the diagnostics dock from the active tab.</summary>
public void RefreshDiagnostics()
{
if ( _diagnosticsList is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_diagnosticsList.SetItems( Array.Empty<object>() );
return;
}
var items = new List<CodeDiagnostic>( editor.Diagnostics );
items.Sort( static ( a, b ) =>
{
var bySeverity = b.Severity.CompareTo( a.Severity );
return bySeverity != 0 ? bySeverity : a.Range.Min.CompareTo( b.Range.Min );
} );
_diagnosticsList.SetItems( items );
}
void OnDiagnosticActivated( object item )
{
if ( item is not CodeDiagnostic diagnostic || ActiveEditor is null )
return;
ActiveEditor.Reveal( diagnostic.Range, true, 4 );
ActiveEditor.Focus();
}
void PaintDiagnosticRow( VirtualWidget item )
{
if ( item.Object is not CodeDiagnostic diagnostic )
return;
var rect = item.Rect;
if ( item.Selected )
item.PaintBackground( PrismTheme.AccentSoft, 3f );
else if ( item.Hovered )
item.PaintBackground( PrismTheme.PanelAlt, 3f );
var color = PrismTheme.ForSeverity( diagnostic.Severity );
Paint.SetPen( color );
Paint.DrawIcon( new Rect( rect.Left + 4f, rect.Top, 18f, rect.Height ), IconFor( diagnostic.Severity ), 13f, TextFlag.Center );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
var lineText = $"{diagnostic.Range.Min.Line + 1}";
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawText( new Rect( rect.Left + 24f, rect.Top, 42f, rect.Height ), lineText, TextFlag.LeftCenter | TextFlag.SingleLine );
if ( !string.IsNullOrEmpty( diagnostic.Code ) )
{
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( new Rect( rect.Left + 66f, rect.Top, 54f, rect.Height ), diagnostic.Code, TextFlag.LeftCenter | TextFlag.SingleLine );
}
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + 124f, rect.Top, rect.Width - 130f, rect.Height ),
diagnostic.Message ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );
}
static string IconFor( DiagnosticSeverity severity ) => severity switch
{
DiagnosticSeverity.Error => "error",
DiagnosticSeverity.Warning => "warning",
_ => "info"
};
// ---- outline ----------------------------------------------------------
/// <summary>Rebuilds the outline dock from the active document.</summary>
public void RefreshOutline( bool force = false )
{
if ( _outlineList is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_outlineList.SetItems( Array.Empty<object>() );
return;
}
if ( !force && _outlineVersion == editor.Document.Version )
return;
_outlineVersion = editor.Document.Version;
// The real parser rather than the regex fallback: it runs over the token stream the lexer has
// already produced, so it is not fooled by a declaration inside a comment or a string, and it
// knows about containers and parameters the regex pass cannot see.
var symbols = PrismLog.Guard( "Prism.Text: outline",
() => Completion.DocumentSymbols.For( editor.Document, editor.Language ).ToOutline(),
null ) ?? CodeOutline.Scan( editor.Document );
var filter = _outlineFilter?.Text;
if ( !string.IsNullOrWhiteSpace( filter ) )
{
var narrowed = new List<CodeSymbol>();
for ( var i = 0; i < symbols.Count; i++ )
{
if ( symbols[i].Name is not null &&
symbols[i].Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) )
narrowed.Add( symbols[i] );
}
symbols = narrowed;
}
_outlineList.SetItems( symbols );
}
void OnOutlineActivated( object item )
{
if ( item is not CodeSymbol symbol || ActiveEditor is null )
return;
ActiveEditor.GoToLine( symbol.Line + 1 );
}
void PaintOutlineRow( VirtualWidget item )
{
if ( item.Object is not CodeSymbol symbol )
return;
var rect = item.Rect;
if ( item.Selected )
item.PaintBackground( PrismTheme.AccentSoft, 3f );
else if ( item.Hovered )
item.PaintBackground( PrismTheme.PanelAlt, 3f );
var indent = 6f + symbol.Depth * 12f;
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawIcon( new Rect( rect.Left + indent, rect.Top, 16f, rect.Height ), symbol.Icon, 12f, TextFlag.Center );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + indent + 20f, rect.Top, rect.Width - indent - 26f, rect.Height ),
symbol.Name ?? string.Empty, TextFlag.LeftCenter | TextFlag.SingleLine );
if ( string.IsNullOrEmpty( symbol.Detail ) )
return;
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( new Rect( rect.Left, rect.Top, rect.Width - 8f, rect.Height ),
symbol.Detail, TextFlag.RightCenter | TextFlag.SingleLine );
}
// ---- status -----------------------------------------------------------
void UpdateStatus()
{
if ( _statusPosition is not { IsValid: true } )
return;
var editor = ActiveEditor;
if ( editor is null )
{
_statusPosition.Text = "";
_statusSelection.Text = "";
_statusLanguage.Text = "";
_statusEncoding.Text = "";
Title = $"{PrismConstants.ProductName} — Code";
return;
}
var caret = editor.CaretPosition;
_statusPosition.Text = $"Ln {caret.Line + 1}, Col {caret.Column + 1}";
var selection = editor.Selection;
var selected = 0;
for ( var i = 0; i < selection.Count; i++ )
selected += editor.Document.GetText( selection[i].Selection ).Length;
if ( selection.Count > 1 )
_statusSelection.Text = $"{selection.Count} carets · {selected} selected";
else if ( selected > 0 )
_statusSelection.Text = $"{selected} selected";
else
_statusSelection.Text = "";
var indent = editor.Controller.UseTabs ? "Tabs" : $"Spaces: {editor.Controller.IndentSize}";
_statusLanguage.Text = $"{editor.Language.ToUpperInvariant()} · {indent}";
var ending = editor.Document.LineEnding switch
{
LineEndingStyle.Lf => "LF",
LineEndingStyle.Cr => "CR",
_ => "CRLF"
};
_statusEncoding.Text = $"{ending} · {(editor.Document.HasByteOrderMark ? "UTF-8 BOM" : "UTF-8")}";
var title = _active?.DisplayTitle ?? string.Empty;
Title = string.IsNullOrEmpty( title )
? $"{PrismConstants.ProductName} — Code"
: $"{title} — {PrismConstants.ProductName}";
}
/// <summary>
/// Window-level accelerators, routed from the focused editor because it consumes every shortcut.
/// Returns true when the key was consumed.
/// </summary>
bool HandleWindowKey( CodeKeyInfo key )
{
if ( key.Ctrl && !key.Alt )
{
switch ( key.Key )
{
case KeyCode.N when !key.Shift:
NewDocument();
return true;
case KeyCode.O when !key.Shift:
PromptOpen();
return true;
case KeyCode.W when !key.Shift:
if ( _active is not null )
CloseTab( _active );
return true;
case KeyCode.S when key.Shift:
SaveAll();
return true;
case KeyCode.Tab:
case KeyCode.Backtab:
StepTab( key.Shift ? -1 : 1 );
return true;
}
}
if ( key.Key == KeyCode.F8 )
{
StepDiagnostic( key.Shift ? -1 : 1 );
return true;
}
return false;
}
/// <summary>Moves to the next or previous tab, wrapping around.</summary>
public void StepTab( int direction )
{
if ( _tabs.Count < 2 || _active is null )
return;
var index = _tabs.IndexOf( _active );
if ( index < 0 )
return;
index = (index + direction + _tabs.Count) % _tabs.Count;
SetActiveTab( _tabs[index] );
}
void WithEditor( Action<CodeEditorWidget> action )
{
var editor = ActiveEditor;
if ( editor is null )
return;
PrismLog.Guard( "Prism.Text: command", () => action( editor ) );
editor.Update();
}
void ForEachEditor( Action<CodeEditorWidget> action )
{
for ( var i = 0; i < _tabs.Count; i++ )
{
var editor = _tabs[i].Editor;
if ( editor is { IsValid: true } )
PrismLog.Guard( "Prism.Text: view option", () => action( editor ) );
}
}
[EditorEvent.Frame]
void CodeWindowFrame()
{
if ( !IsValid || _sinceOutlineRefresh < 0.75f )
return;
_sinceOutlineRefresh = 0;
RefreshOutline();
}
/// <summary>Shows the non-blocking save / discard / cancel prompt.</summary>
void PromptUnsaved( string message, Action onSave, Action onDiscard )
{
PrismLog.Guard( "Prism.Text: unsaved prompt", () =>
{
var popup = new PopupDialogWidget( "❓" );
popup.WindowTitle = "Unsaved Changes";
popup.MessageLabel.Text = message;
popup.ButtonLayout.AddStretchCell();
popup.ButtonLayout.Add( new Button( "Cancel" ) { Clicked = () => popup.Destroy() } );
popup.ButtonLayout.Add( new Button( "Discard" ) { Clicked = () => { popup.Destroy(); onDiscard?.Invoke(); } } );
popup.ButtonLayout.Add( new Button.Primary( "Save" ) { Clicked = () => { popup.Destroy(); onSave?.Invoke(); } } );
popup.SetModal( true, true );
popup.Hide();
popup.Show();
} );
}
bool _forceClose;
protected override bool OnClose()
{
if ( _forceClose )
return base.OnClose();
var modified = 0;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( _tabs[i].IsModified && !_tabs[i].ReadOnly )
modified++;
}
if ( modified == 0 )
return base.OnClose();
PromptUnsaved( $"{modified} file(s) have unsaved changes.",
() => { SaveAll(); _forceClose = true; Close(); },
() => { _forceClose = true; Close(); } );
return false;
}
protected override void OnClosed()
{
// AssetHooks is static and would otherwise pin this window, its tabs and every document in them
// for the rest of the session.
AssetHooks.ShaderSourceChangedOnDisk -= OnFileChangedOnDisk;
AssetHooks.DocumentChangedOnDisk -= OnFileChangedOnDisk;
if ( ReferenceEquals( s_instance, this ) )
s_instance = null;
base.OnClosed();
}
}
/// <summary>
/// The tab strip. Painted rather than composed, so it matches <see cref="PrismTheme"/> exactly — the
/// engine's <c>TabBar</c> binding exposes no managed API at all.
/// </summary>
internal sealed class CodeTabStrip : Widget
{
readonly List<CodeTab> _tabs = new();
CodeTab _active;
CodeTab _hovered;
bool _hoverClose;
float _scroll;
public CodeTabStrip( Widget parent ) : base( parent )
{
FixedHeight = 30f;
MouseTracking = true;
Cursor = CursorShape.Finger;
}
public Action<CodeTab> TabSelected { get; set; }
public Action<CodeTab> TabClosed { get; set; }
public Action NewTabRequested { get; set; }
public void SetTabs( IReadOnlyList<CodeTab> tabs )
{
_tabs.Clear();
if ( tabs is not null )
_tabs.AddRange( tabs );
Update();
}
public void SetActive( CodeTab tab )
{
_active = tab;
Update();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
Paint.SetPen( PrismTheme.BorderSubtle, 1f );
Paint.DrawLine( new Vector2( 0f, LocalRect.Bottom - 0.5f ), new Vector2( LocalRect.Right, LocalRect.Bottom - 0.5f ) );
Paint.SetDefaultFont( PrismTheme.BodySize, 400, false, true );
var x = 4f - _scroll;
for ( var i = 0; i < _tabs.Count; i++ )
{
var tab = _tabs[i];
var caption = tab.DisplayTitle;
var width = Math.Clamp( Paint.MeasureText( caption ).x + 46f, 90f, 240f );
var rect = new Rect( x, 3f, width, LocalRect.Height - 3f );
tab.TabRect = rect;
x += width + 2f;
if ( rect.Right < 0f || rect.Left > LocalRect.Right )
continue;
var isActive = ReferenceEquals( tab, _active );
var isHovered = ReferenceEquals( tab, _hovered );
Paint.ClearPen();
Paint.SetBrush( isActive ? PrismTheme.Code.Background : isHovered ? PrismTheme.PanelAlt : PrismTheme.Panel );
Paint.DrawRect( rect, PrismTheme.RadiusChip );
if ( isActive )
{
Paint.SetBrush( PrismTheme.Accent );
Paint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, 2f ), 1f );
}
Paint.SetPen( isActive ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.DrawText( new Rect( rect.Left + 10f, rect.Top, rect.Width - 34f, rect.Height ),
caption, TextFlag.LeftCenter | TextFlag.SingleLine );
var closeRect = CloseRect( rect );
Paint.SetPen( isHovered && _hoverClose ? PrismTheme.Error : PrismTheme.TextMuted );
Paint.DrawIcon( closeRect, "close", 12f, TextFlag.Center );
}
var plus = new Rect( x + 4f, 4f, 22f, LocalRect.Height - 8f );
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawIcon( plus, "add", 14f, TextFlag.Center );
}
static Rect CloseRect( Rect tabRect ) => new( tabRect.Right - 24f, tabRect.Top + 4f, 18f, tabRect.Height - 8f );
protected override void OnMouseMove( MouseEvent e )
{
var previous = _hovered;
var previousClose = _hoverClose;
_hovered = HitTest( e.LocalPosition, out _hoverClose );
if ( !ReferenceEquals( previous, _hovered ) || previousClose != _hoverClose )
Update();
}
protected override void OnMouseLeave()
{
_hovered = null;
_hoverClose = false;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
var tab = HitTest( e.LocalPosition, out var onClose );
if ( tab is null )
{
if ( e.LeftMouseButton && e.LocalPosition.x > LastTabRight() )
NewTabRequested?.Invoke();
e.Accepted = true;
return;
}
if ( e.MiddleMouseButton || (e.LeftMouseButton && onClose) )
TabClosed?.Invoke( tab );
else if ( e.LeftMouseButton )
TabSelected?.Invoke( tab );
e.Accepted = true;
}
protected override void OnMouseWheel( WheelEvent e )
{
_scroll = Math.Max( 0f, _scroll + (e.Delta > 0 ? -40f : 40f) );
Update();
e.Accept();
}
float LastTabRight() => _tabs.Count == 0 ? 4f : _tabs[^1].TabRect.Right;
CodeTab HitTest( Vector2 local, out bool onClose )
{
onClose = false;
for ( var i = 0; i < _tabs.Count; i++ )
{
if ( !_tabs[i].TabRect.IsInside( local ) )
continue;
onClose = CloseRect( _tabs[i].TabRect ).IsInside( local );
return _tabs[i];
}
return null;
}
}
/// <summary>
/// A deliberately small structural scanner that feeds the outline dock: VFX block headers, macros,
/// structs, constant buffers and top-level function definitions. It is a placeholder for the richer
/// document-symbol parser the language-intelligence package owns; swapping it out only changes this
/// file.
/// </summary>
internal static class CodeOutline
{
static readonly string[] s_blocks =
{
"HEADER", "MODES", "FEATURES", "COMMON", "VS", "PS", "GS", "CS", "PS_RENDER_STATE", "RTX"
};
static readonly HashSet<string> s_notFunctions = new( StringComparer.Ordinal )
{
"if", "for", "while", "switch", "return", "else", "do", "case", "sizeof", "defined"
};
public static List<CodeSymbol> Scan( TextDocument document )
{
var symbols = new List<CodeSymbol>();
if ( document is null )
return symbols;
var depth = 0;
for ( var line = 0; line < document.LineCount; line++ )
{
var raw = document.GetLine( line );
var text = raw.Trim();
var startDepth = depth;
depth += CountUnquoted( raw, '{' ) - CountUnquoted( raw, '}' );
if ( text.Length == 0 || text.StartsWith( "//", StringComparison.Ordinal ) )
continue;
if ( startDepth == 0 && TryBlock( text, out var block ) )
{
symbols.Add( new CodeSymbol( block, "block", line, "widgets", 0 ) );
continue;
}
if ( text.StartsWith( "#define ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 8 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "define", line, "tag", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( text.StartsWith( "struct ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 7 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "struct", line, "data_object", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( text.StartsWith( "cbuffer ", StringComparison.Ordinal ) )
{
var name = ReadIdentifier( text, 8 );
if ( !string.IsNullOrEmpty( name ) )
symbols.Add( new CodeSymbol( name, "cbuffer", line, "view_list", startDepth > 0 ? 1 : 0 ) );
continue;
}
if ( startDepth > 1 )
continue;
if ( TryFunction( text, out var function, out var signature ) )
symbols.Add( new CodeSymbol( function, signature, line, "functions", startDepth > 0 ? 1 : 0 ) );
}
return symbols;
}
static bool TryBlock( string text, out string block )
{
block = null;
var candidate = text.EndsWith( "{", StringComparison.Ordinal ) ? text[..^1].Trim() : text;
for ( var i = 0; i < s_blocks.Length; i++ )
{
if ( !string.Equals( candidate, s_blocks[i], StringComparison.Ordinal ) )
continue;
block = s_blocks[i];
return true;
}
return false;
}
static bool TryFunction( string text, out string name, out string signature )
{
name = null;
signature = null;
if ( text.StartsWith( "#", StringComparison.Ordinal ) )
return false;
var open = text.IndexOf( '(' );
if ( open <= 0 )
return false;
if ( text.EndsWith( ";", StringComparison.Ordinal ) )
return false;
var head = text[..open].Trim();
if ( head.Length == 0 )
return false;
var lastSpace = head.LastIndexOfAny( new[] { ' ', '\t', '*', '&', ':' } );
if ( lastSpace <= 0 || lastSpace >= head.Length - 1 )
return false;
var candidate = head[(lastSpace + 1)..].Trim();
if ( candidate.Length == 0 || s_notFunctions.Contains( candidate ) )
return false;
for ( var i = 0; i < candidate.Length; i++ )
{
if ( !char.IsLetterOrDigit( candidate[i] ) && candidate[i] != '_' )
return false;
}
name = candidate;
signature = head[..lastSpace].Trim();
return true;
}
static string ReadIdentifier( string text, int start )
{
var index = start;
while ( index < text.Length && char.IsWhiteSpace( text[index] ) )
index++;
var builder = new StringBuilder();
while ( index < text.Length && (char.IsLetterOrDigit( text[index] ) || text[index] == '_') )
builder.Append( text[index++] );
return builder.ToString();
}
static int CountUnquoted( string text, char target )
{
var count = 0;
var inString = false;
for ( var i = 0; i < text.Length; i++ )
{
var c = text[i];
if ( c == '"' && (i == 0 || text[i - 1] != '\\') )
{
inString = !inString;
continue;
}
if ( inString )
continue;
if ( c == '/' && i + 1 < text.Length && text[i + 1] == '/' )
break;
if ( c == target )
count++;
}
return count;
}
}