├── .editorconfig ├── .gitignore ├── README.md ├── exe ├── gtkscratch.exe ├── gtkscratch.exe.config ├── gtkscratch.settings └── scratch.exe ├── gtk-ui ├── .editorconfig ├── BookView.cs ├── GtkScratch.csproj ├── LogView.cs ├── MainWindow.cs ├── Program.cs ├── Resources.Designer.cs ├── Resources.resx ├── Scratch.cs ├── ScratchConf.cs ├── ScratchLegacy.cs ├── ScratchLib.cs ├── ScratchRootController.cs ├── ScratchScopes.cs ├── ScratchView.cs ├── SearchWindow.cs ├── SnippetWindow.cs ├── app.config ├── bin │ └── Debug │ │ └── dir │ │ └── 0-globalconfig.txt ├── make └── packages.config ├── normalize-line-endings ├── scratch.sln └── scratchlog ├── Program.cs ├── Properties └── AssemblyInfo.cs ├── app.config ├── packages.config └── scratchlog.csproj /.editorconfig: -------------------------------------------------------------------------------- 1 | end_of_line = lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.user 2 | bin/ 3 | obj/ 4 | test/ 5 | packages/ 6 | Backup/ 7 | .vs/ 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScratchPad for Windows and Mono 2 | 3 | Simple keyboard-driven scratchpad application with a Gtk# UI that works 4 | in Windows on .NET and Linux and macOS using Mono. 5 | 6 | Designed with constant persistence and unlimited history logging for 7 | persistent undo across sessions. 8 | 9 | Uses text-based storage and edit log to minimize risk of data loss. Text can 10 | be recovered by replaying log in case the .txt is lost; if the .log alone is 11 | lost, the most current text should still be OK. If the two get out of sync 12 | (e.g. modifying the .txt file independently), the conflict is resolved by 13 | replaying the log and and making the diff to the current text the final edit. 14 | 15 | The diff algorithm that produces the edit log is naive and very simple, in 16 | the interests of reducing code complexity and eliminating third-party 17 | dependencies. 18 | 19 | Navigation is expected to be done with the keyboard: 20 | 21 | * Alt+Up/Down navigates history backwards and forwards. 22 | * Alt+Left/Right navigates pages backwards and forwards. 23 | * F12 for simple title search dialog 24 | * Pages sorted in most recently edited order 25 | 26 | Run the application with a directory argument. The default tab will use that 27 | directory for storage. Any subdirectories found will be used as names for 28 | additional tabs. All .txt files in directories will be added as pages; all 29 | .log files will be replayed to recreate any .txt files if missing. 30 | 31 | There's a simple configuration language, ScratchConf, for tweaking the UI 32 | and encoding macro actions. 33 | 34 | ## ScratchConf 35 | 36 | Config files are read from pages which start with .config or .globalconfig. 37 | Settings from .config are scoped to a tab (book), while .globalconfig is 38 | shared across all tabs. 39 | 40 | The configuration language is designed to have no execution up front so that 41 | startup stays fast. Only symbol bindings are allowed at the top level. 42 | However, anonymous functions can be defined and functions which are bound 43 | to the names of keys (using Emacs conventions for C- M- S- modifiers) 44 | will be executed when that key (combination) is pressed. 45 | 46 | Comments are line-oriented and introduced by # or //. 47 | 48 | The language has no arithmetic, but the default imported library has functions 49 | for add(), sub(), mul(), div(), eq(), ne(), lt(), gt() and so on. 50 | The only literals are strings, non-negative integers and anonymous functions. 51 | Identifiers may include '-'. Functions take parameters Ruby-style, with || inside {}. 52 | 53 | The language uses dynamic scope. Function activation records are pushed on a 54 | stack of symbol tables on function entry and popped on exit. This means that locals 55 | defined in a function will temporarily hide globals for called functions. 56 | This is similar to the behaviour of Emacs Lisp. Binding within nested anonymous 57 | functions work as downward funargs, but the scope is not closed over - the bindings 58 | are looked up in a new context if the nested function is stored and executed after 59 | its inclosing activation record is popped (i.e. the function that created it returned). 60 | 61 | There are two binding syntaxes inside function bodies, '=' and ':='. 62 | The difference is that '=' will redefine an existing binding in the scope it's found 63 | in if it already exists, while ':=' always creates a new binding. 64 | In effect, always use ':=' inside function bodies unless you're trying to change 65 | the value of a global. 66 | 67 | There are two 'global' scopes: the root scope, where .globalconfig configs are loaded, 68 | and '.config', where book scopes are loaded. The idea is you can customize settings 69 | on a per-book basis where this makes sense, depending on what the book is used for. 70 | 71 | ### Grammar 72 | 73 | ``` 74 | =~ [a-zA-Z_-][a-z0-9_-]* ; 75 | =~ '[^']*'|"[^"]*" ; 76 | =~ [0-9]+ ; 77 | 78 | file ::= { setting } . 79 | setting ::= ( | ) '=' ( literal | ) ; 80 | literal ::= | | block | 'nil' ; 81 | block ::= '{' [paramList] exprList '}' ; 82 | paramList ::= '|' [ { ',' } ] '|' ; 83 | exprList = { expr } ; 84 | expr ::= if | orExpr | return | while | 'break' | 'continue' ; 85 | while ::= 'while' orExpr '{' exprList '}' ; 86 | // consider removing this ambiguity on the basis of semantics 87 | // control will not flow to the next line, so what if we eat it 88 | return ::= 'return' [ '(' orExpr ')' ] ; 89 | orExpr ::= andExpr { '||' andExpr } ; 90 | andExpr ::= factor { '&&' factor } ; 91 | factor ::= [ '!' ] ( callOrAssign | literal | '(' expr ')' ) ; 92 | callOrAssign ::= 93 | ( ['(' [ expr { ',' expr } ] ')'] 94 | | '=' expr 95 | | ':=' expr 96 | | 97 | ) ; 98 | if ::= 99 | 'if' orExpr '{' exprList '}' 100 | [ 'else' (if | '{' exprList '}') ] 101 | ; 102 | ``` 103 | 104 | ### Examples 105 | 106 | #### Set preferences for font, background color 107 | ``` 108 | text-font = "Consolas, 9" 109 | info-font = "Verdana, 12" 110 | log-font = "Consolas, 8" 111 | background-color = '#d1efcf' 112 | ``` 113 | 114 | #### Create a standalone read-only snippet window containing selected text when F1 is pressed 115 | 116 | ``` 117 | get-input-text = { |current| 118 | # get-input is a simple (and ugly) builtin text input dialog 119 | get-input({ 120 | init-text := current 121 | }) 122 | } 123 | 124 | show-snippet = { |snippet-text| 125 | # launch-snippet is a builtin function to create and show a new window 126 | # with read-only text 127 | # Its argument is an anonymous function whose bindings are used to 128 | # configure the window. 129 | launch-snippet({ 130 | text := snippet-text 131 | snippet-color := '#FFFFC0' 132 | }) 133 | } 134 | 135 | F1 = { 136 | sel := get-view-selected-text() 137 | if sel && ne(sel, '') { 138 | title := get-input-text("Snippet") 139 | launch-snippet({ 140 | text := sel 141 | snippet-color := '#FFFFC0' 142 | }) 143 | } 144 | } 145 | ``` 146 | 147 | #### Replace text within a selected block of text using dialogs for search and replacement 148 | 149 | ``` 150 | replace-command = { 151 | sel := get-view-selected-text() 152 | if !sel || eq(sel, '') { return } 153 | foo := get-input-text('Regex') 154 | if !foo { return } 155 | dst := get-input-text('Replacement') 156 | if !dst { return } 157 | ensure-saved() 158 | set-view-selected-text(gsub(sel, foo, dst)) 159 | } 160 | ``` 161 | 162 | -------------------------------------------------------------------------------- /exe/gtkscratch.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barrkel/scratch/64014a1090fb16ba6b3e6f6d972fca5cc92521a9/exe/gtkscratch.exe -------------------------------------------------------------------------------- /exe/gtkscratch.exe.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /exe/gtkscratch.settings: -------------------------------------------------------------------------------- 1 | info-font=Verdana 2 | text-font=Monospace, 11px 3 | -------------------------------------------------------------------------------- /exe/scratch.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barrkel/scratch/64014a1090fb16ba6b3e6f6d972fca5cc92521a9/exe/scratch.exe -------------------------------------------------------------------------------- /gtk-ui/.editorconfig: -------------------------------------------------------------------------------- 1 | end_of_line = lf 2 | 3 | [*.{cs,vb}] 4 | 5 | end_of_line = lf 6 | 7 | # IDE0044: Add readonly modifier 8 | dotnet_style_readonly_field = false:suggestion 9 | 10 | # IDE0040: Add accessibility modifiers 11 | dotnet_style_require_accessibility_modifiers = omit_if_default:silent 12 | 13 | # IDE0010: Add missing cases 14 | dotnet_diagnostic.IDE0010.severity = suggestion 15 | -------------------------------------------------------------------------------- /gtk-ui/BookView.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Gtk; 6 | using Barrkel.ScratchPad; 7 | using System.IO; 8 | 9 | namespace Barrkel.GtkScratchPad 10 | { 11 | public static class StringHelper 12 | { 13 | public static string EscapeMarkup(this string text) 14 | { 15 | return (text ?? "").Replace("&", "&").Replace("<", "<"); 16 | } 17 | } 18 | 19 | public static class GdkHelper 20 | { 21 | private static Dictionary _keyNameMap = BuildKeyNameMap(); 22 | 23 | public static bool TryGetKeyName(Gdk.Key key, out string name) 24 | { 25 | return _keyNameMap.TryGetValue(key, out name); 26 | } 27 | 28 | private static Dictionary BuildKeyNameMap() 29 | { 30 | Dictionary result = new Dictionary(); 31 | Dictionary enumMap = new Dictionary(); 32 | 33 | void tryAddNamed(string enumName, string name) 34 | { 35 | if (enumMap.TryGetValue(enumName, out Gdk.Key key)) 36 | result.Add(key, name); 37 | } 38 | 39 | void tryAdd(string name) 40 | { 41 | tryAddNamed(name, name); 42 | } 43 | 44 | foreach (Gdk.Key key in Enum.GetValues(typeof(Gdk.Key))) 45 | { 46 | string enumName = Enum.GetName(typeof(Gdk.Key), key); 47 | enumMap[enumName] = key; 48 | } 49 | for (int i = 1; i <= 12; ++i) 50 | { 51 | string name = string.Format("F{0}", i); 52 | tryAdd(name); 53 | } 54 | for (char ch = 'A'; ch <= 'Z'; ++ch) 55 | { 56 | string name = ch.ToString(); 57 | // Copy emacs logic for shift on normal characters. 58 | // map A to A 59 | tryAdd(name); 60 | // and a to a 61 | tryAdd(name.ToLower()); 62 | } 63 | for (char ch = '0'; ch <= '9'; ++ch) 64 | { 65 | string name = ch.ToString(); 66 | tryAddNamed("Key_" + name, name); 67 | } 68 | result.Add(Gdk.Key.quoteleft, "`"); 69 | result.Add(Gdk.Key.quoteright, "'"); 70 | result.Add(Gdk.Key.quotedbl, "\""); 71 | result.Add(Gdk.Key.exclam, "!"); 72 | result.Add(Gdk.Key.at, "@"); 73 | result.Add(Gdk.Key.numbersign, "#"); 74 | result.Add(Gdk.Key.dollar, "$"); 75 | result.Add(Gdk.Key.percent, "%"); 76 | result.Add(Gdk.Key.asciicircum, "^"); 77 | result.Add(Gdk.Key.ampersand, "&"); 78 | result.Add(Gdk.Key.asterisk, "*"); 79 | result.Add(Gdk.Key.parenleft, "("); 80 | result.Add(Gdk.Key.parenright, ")"); 81 | result.Add(Gdk.Key.bracketleft, "["); 82 | result.Add(Gdk.Key.bracketright, "]"); 83 | result.Add(Gdk.Key.braceleft, "{"); 84 | result.Add(Gdk.Key.braceright, "}"); 85 | result.Add(Gdk.Key.plus, "+"); 86 | result.Add(Gdk.Key.minus, "-"); 87 | result.Add(Gdk.Key.underscore, "_"); 88 | result.Add(Gdk.Key.equal, "="); 89 | result.Add(Gdk.Key.slash, "/"); 90 | result.Add(Gdk.Key.backslash, "\\"); 91 | result.Add(Gdk.Key.bar, "|"); 92 | result.Add(Gdk.Key.period, "."); 93 | result.Add(Gdk.Key.comma, ","); 94 | result.Add(Gdk.Key.less, "<"); 95 | result.Add(Gdk.Key.greater, ">"); 96 | result.Add(Gdk.Key.colon, ":"); 97 | result.Add(Gdk.Key.semicolon, ";"); 98 | result.Add(Gdk.Key.question, "?"); 99 | result.Add(Gdk.Key.Escape, "Esc"); 100 | result.Add(Gdk.Key.asciitilde, "~"); 101 | result.Add(Gdk.Key.Page_Up, "PgUp"); 102 | result.Add(Gdk.Key.Page_Down, "PgDn"); 103 | result.Add(Gdk.Key.Home, "Home"); 104 | result.Add(Gdk.Key.End, "End"); 105 | 106 | result.Add(Gdk.Key.Up, "Up"); 107 | result.Add(Gdk.Key.Down, "Down"); 108 | result.Add(Gdk.Key.Left, "Left"); 109 | result.Add(Gdk.Key.Right, "Right"); 110 | 111 | result.Add(Gdk.Key.Return, "Return"); 112 | result.Add(Gdk.Key.Delete, "Delete"); 113 | result.Add(Gdk.Key.Insert, "Insert"); 114 | result.Add(Gdk.Key.BackSpace, "BackSpace"); 115 | result.Add(Gdk.Key.space, "Space"); 116 | result.Add(Gdk.Key.Tab, "Tab"); 117 | // this comes out with S-Tab 118 | result.Add(Gdk.Key.ISO_Left_Tab, "Tab"); 119 | 120 | // Clobber synonym. L1 and F11 have same value. 121 | result[Gdk.Key.L1] = "F11"; 122 | result[Gdk.Key.L2] = "F12"; 123 | 124 | return result; 125 | } 126 | } 127 | 128 | public delegate void KeyEventHandler(Gdk.EventKey evnt, ref bool handled); 129 | 130 | public class MyTextView : TextView 131 | { 132 | public event KeyEventHandler KeyDownEvent; 133 | 134 | [GLib.DefaultSignalHandler(Type = typeof(Widget), ConnectionMethod = "OverrideKeyPressEvent")] 135 | protected override bool OnKeyPressEvent(Gdk.EventKey evnt) 136 | { 137 | bool handled = false; 138 | KeyDownEvent?.Invoke(evnt, ref handled); 139 | if (handled) 140 | return true; 141 | else 142 | return base.OnKeyPressEvent(evnt); 143 | } 144 | } 145 | 146 | public class BookView : Frame, IScratchBookView 147 | { 148 | private static readonly Gdk.Atom GlobalClipboard = Gdk.Atom.Intern("CLIPBOARD", false); 149 | 150 | DateTime _lastModification; 151 | DateTime _lastSave; 152 | bool _dirty; 153 | int _currentPage; 154 | // If non-null, then browsing history. 155 | ScratchIterator _currentIterator; 156 | MyTextView _textView; 157 | bool _settingText; 158 | Label _titleLabel; 159 | Label _dateLabel; 160 | Label _pageLabel; 161 | Label _versionLabel; 162 | List _deferred = new List(); 163 | 164 | public BookView(ScratchBook book, ScratchBookController controller, Window appWindow) 165 | { 166 | AppWindow = appWindow; 167 | Book = book; 168 | Controller = controller; 169 | AppSettings = controller.Scope; 170 | InitComponent(); 171 | _currentPage = book.Pages.Count > 0 ? book.Pages.Count - 1 : 0; 172 | UpdateViewLabels(); 173 | UpdateTextBox(); 174 | 175 | Controller.ConnectView(this); 176 | } 177 | 178 | public ScratchBookController Controller { get; } 179 | public Window AppWindow { get; private set; } 180 | public ScratchScope AppSettings { get; } 181 | 182 | private void UpdateTextBox() 183 | { 184 | _settingText = true; 185 | try 186 | { 187 | if (_currentPage >= Book.Pages.Count) 188 | _textView.Buffer.Text = ""; 189 | else if (_currentIterator != null) 190 | _textView.Buffer.Text = _currentIterator.Text; 191 | else 192 | _textView.Buffer.Text = Book.Pages[_currentPage].Text; 193 | TextIter iter = _textView.Buffer.GetIterAtOffset(0); 194 | _textView.Buffer.SelectRange(iter, iter); 195 | _textView.ScrollToIter(iter, 0, false, 0, 0); 196 | } 197 | finally 198 | { 199 | _settingText = false; 200 | } 201 | } 202 | 203 | private void UpdateTitle() 204 | { 205 | _titleLabel.Markup = GetTitleMarkup(new StringReader(_textView.Buffer.Text).ReadLine()); 206 | } 207 | 208 | private void UpdateViewLabels() 209 | { 210 | if (_currentPage >= Book.Pages.Count) 211 | { 212 | _dateLabel.Text = ""; 213 | _pageLabel.Text = ""; 214 | _versionLabel.Text = ""; 215 | } 216 | else 217 | { 218 | _pageLabel.Markup = GetPageMarkup(_currentPage + 1, Book.Pages.Count); 219 | if (_currentIterator == null) 220 | { 221 | _versionLabel.Markup = GetInfoMarkup("Latest"); 222 | _dateLabel.Markup = GetInfoMarkup( 223 | Book.Pages[_currentPage].ChangeStamp.ToLocalTime().ToString("F")); 224 | } 225 | else 226 | { 227 | _versionLabel.Markup = GetInfoMarkup(string.Format("Version {0} of {1}", 228 | _currentIterator.Position, _currentIterator.Count)); 229 | _dateLabel.Markup = GetInfoMarkup(_currentIterator.Stamp.ToLocalTime().ToString("F")); 230 | } 231 | } 232 | } 233 | 234 | public void AddNewPage() 235 | { 236 | EnsureSaved(); 237 | _currentPage = Book.Pages.Count; 238 | UpdateTextBox(); 239 | UpdateViewLabels(); 240 | _dirty = false; 241 | } 242 | 243 | public void EnsureSaved() 244 | { 245 | Book.EnsureSaved(); 246 | if (!_dirty) 247 | return; 248 | string currentText = _textView.Buffer.Text; 249 | if (_currentPage >= Book.Pages.Count) 250 | { 251 | if (currentText == "") 252 | return; 253 | Book.AddPage(); 254 | UpdateViewLabels(); 255 | } 256 | Book.Pages[_currentPage].Text = _textView.Buffer.Text; 257 | Book.SaveLatest(); 258 | _lastSave = DateTime.UtcNow; 259 | _dirty = false; 260 | _currentPage = Book.MoveToEnd(_currentPage); 261 | UpdateViewLabels(); 262 | } 263 | 264 | private static string GetTitleMarkup(string title) 265 | { 266 | return string.Format("{0}", title.EscapeMarkup()); 267 | } 268 | 269 | private static string GetPageMarkup(int page, int total) 270 | { 271 | return string.Format("Page {0} of {1}", 272 | page, total); 273 | } 274 | 275 | private static string GetInfoMarkup(string info) 276 | { 277 | return string.Format("{0}", info.EscapeMarkup()); 278 | } 279 | 280 | private static readonly Gdk.Color LightBlue = new Gdk.Color(207, 207, 239); 281 | 282 | private void InitComponent() 283 | { 284 | Gdk.Color grey = new Gdk.Color(0xA0, 0xA0, 0xA0); 285 | Gdk.Color noteBackground = LightBlue; 286 | if (!Gdk.Color.Parse(AppSettings.GetOrDefault("background-color", "#cfcfef"), ref noteBackground)) 287 | noteBackground = LightBlue; 288 | 289 | var infoFont = Pango.FontDescription.FromString(Controller.Scope.GetOrDefault("info-font", "Verdana")); 290 | var textFont = Pango.FontDescription.FromString(Controller.Scope.GetOrDefault("text-font", "Courier New")); 291 | 292 | _textView = new MyTextView 293 | { 294 | WrapMode = WrapMode.Word 295 | }; 296 | _textView.ModifyBase(StateType.Normal, noteBackground); 297 | _textView.Buffer.Changed += _text_TextChanged; 298 | _textView.KeyDownEvent += _textView_KeyDownEvent; 299 | _textView.ModifyFont(textFont); 300 | 301 | ScrolledWindow scrolledTextView = new ScrolledWindow(); 302 | scrolledTextView.Add(_textView); 303 | 304 | _titleLabel = new Label(); 305 | _titleLabel.SetAlignment(0, 0); 306 | _titleLabel.Justify = Justification.Left; 307 | _titleLabel.SetPadding(0, 5); 308 | _titleLabel.ModifyFont(textFont); 309 | GLib.Timeout.Add((uint) TimeSpan.FromSeconds(3).TotalMilliseconds, 310 | () => { return SaveTimerTick(); }); 311 | 312 | EventBox titleContainer = new EventBox(); 313 | titleContainer.Add(_titleLabel); 314 | 315 | // hbox 316 | // vbox 317 | // dateLabel 318 | // versionLabel 319 | // pageLabel 320 | HBox locationInfo = new HBox(); 321 | VBox locationLeft = new VBox(); 322 | 323 | _dateLabel = new Label 324 | { 325 | Justify = Justification.Left 326 | }; 327 | _dateLabel.SetAlignment(0, 0); 328 | _dateLabel.SetPadding(5, 5); 329 | _dateLabel.ModifyFont(infoFont); 330 | locationLeft.PackStart(_dateLabel, false, false, 0); 331 | 332 | _versionLabel = new Label(); 333 | _versionLabel.SetAlignment(0, 0); 334 | _versionLabel.Justify = Justification.Left; 335 | _versionLabel.SetPadding(5, 5); 336 | _versionLabel.ModifyFont(infoFont); 337 | locationLeft.PackStart(_versionLabel, false, false, 0); 338 | 339 | locationInfo.PackStart(locationLeft, true, true, 0); 340 | 341 | _pageLabel = new Label 342 | { 343 | Markup = GetPageMarkup(1, 5), 344 | Justify = Justification.Right 345 | }; 346 | _pageLabel.SetAlignment(1, 0.5f); 347 | _pageLabel.SetPadding(5, 5); 348 | _pageLabel.ModifyFont(infoFont); 349 | locationInfo.PackEnd(_pageLabel, true, true, 0); 350 | Pango.Context ctx = _pageLabel.PangoContext; 351 | 352 | VBox outerVertical = new VBox(); 353 | outerVertical.PackStart(titleContainer, false, false, 0); 354 | outerVertical.PackStart(scrolledTextView, true, true, 0); 355 | outerVertical.PackEnd(locationInfo, false, false, 0); 356 | 357 | Add(outerVertical); 358 | 359 | BorderWidth = 5; 360 | 361 | } 362 | 363 | void Defer(System.Action action) 364 | { 365 | _deferred.Add(action); 366 | GLib.Idle.Add(DrainDeferred); 367 | } 368 | 369 | bool DrainDeferred() 370 | { 371 | if (_deferred.Count == 0) 372 | return false; 373 | System.Action defer = _deferred[_deferred.Count - 1]; 374 | _deferred.RemoveAt(_deferred.Count - 1); 375 | defer(); 376 | 377 | return true; 378 | } 379 | 380 | void _textView_KeyDownEvent(Gdk.EventKey evnt, ref bool handled) 381 | { 382 | // FIXME: this event actually doesn't grab that much. 383 | // E.g. normal Up, Down, Ctrl-Left, Ctrl-Right etc. are not seen here 384 | // It doesn't see typed characters. Effectively it only sees F-keys and keys pressed with Alt. 385 | bool ctrl = (evnt.State & Gdk.ModifierType.ControlMask) != 0; 386 | bool alt = (evnt.State & Gdk.ModifierType.Mod1Mask) != 0; 387 | bool shift = (evnt.State & Gdk.ModifierType.ShiftMask) != 0; 388 | 389 | if (GdkHelper.TryGetKeyName(evnt.Key, out string keyName)) 390 | { 391 | handled = Controller.InformKeyStroke(this, keyName, ctrl, alt, shift); 392 | } 393 | else 394 | { 395 | if (Controller.Scope.GetOrDefault("debug-keys", false)) 396 | Log.Out($"Not mapped: {evnt.Key}"); 397 | } 398 | 399 | var state = evnt.State & Gdk.ModifierType.Mod1Mask; 400 | switch (state) 401 | { 402 | case Gdk.ModifierType.Mod1Mask: 403 | switch (evnt.Key) 404 | { 405 | case Gdk.Key.Home: // M-Home 406 | case Gdk.Key.Up: // M-Up 407 | PreviousVersion(); 408 | break; 409 | 410 | case Gdk.Key.End: // M-End 411 | case Gdk.Key.Down: // M-Down 412 | NextVersion(); 413 | break; 414 | 415 | default: 416 | return; 417 | } 418 | break; 419 | } 420 | } 421 | 422 | private void _text_TextChanged(object sender, EventArgs e) 423 | { 424 | UpdateTitle(); 425 | if (_settingText) 426 | return; 427 | 428 | if (!_dirty) 429 | _lastSave = DateTime.UtcNow; 430 | _lastModification = DateTime.UtcNow; 431 | _currentIterator = null; 432 | _dirty = true; 433 | } 434 | 435 | private bool SaveTimerTick() 436 | { 437 | if (!_dirty) 438 | return true; 439 | 440 | TimeSpan span = DateTime.UtcNow - _lastModification; 441 | if (span > TimeSpan.FromSeconds(5)) 442 | { 443 | EnsureSaved(); 444 | return true; 445 | } 446 | 447 | span = DateTime.UtcNow - _lastSave; 448 | if (span > TimeSpan.FromSeconds(20)) 449 | EnsureSaved(); 450 | 451 | return true; 452 | } 453 | 454 | public bool Navigate(Func callback) 455 | { 456 | EnsureSaved(); 457 | if (_currentPage >= Book.Pages.Count) 458 | return false; 459 | if (_currentIterator == null) 460 | { 461 | _currentIterator = Book.Pages[_currentPage].GetIterator(); 462 | _currentIterator.MoveToEnd(); 463 | } 464 | if (callback(_currentIterator)) 465 | { 466 | UpdateTextBox(); 467 | SetSelection(_currentIterator.UpdatedFrom, _currentIterator.UpdatedTo); 468 | ScrollIntoView(_currentIterator.UpdatedFrom); 469 | UpdateViewLabels(); 470 | return true; 471 | } 472 | return false; 473 | } 474 | 475 | void PreviousVersion() 476 | { 477 | Navigate(iter => iter.MovePrevious()); 478 | } 479 | 480 | void NextVersion() 481 | { 482 | Navigate(iter => iter.MoveNext()); 483 | } 484 | 485 | public void JumpToPage(int pageIndex) 486 | { 487 | if (pageIndex < 0 || pageIndex >= Book.Pages.Count || pageIndex == _currentPage) 488 | return; 489 | 490 | ExitPage(); 491 | _currentIterator = null; 492 | _currentPage = Book.MoveToEnd(pageIndex); 493 | EnterPage(); 494 | } 495 | 496 | private void ExitPage() 497 | { 498 | EnsureSaved(); 499 | Controller.InvokeAction(this, "exit-page", ScratchValue.EmptyList); 500 | } 501 | 502 | private void EnterPage() 503 | { 504 | UpdateTextBox(); 505 | UpdateTitle(); 506 | UpdateViewLabels(); 507 | Controller.InvokeAction(this, "enter-page", ScratchValue.EmptyList); 508 | } 509 | 510 | public void InsertText(string text) 511 | { 512 | _textView.Buffer.InsertAtCursor(text); 513 | } 514 | 515 | public void ScrollIntoView(int pos) 516 | { 517 | Defer(() => 518 | { 519 | _textView.ScrollToIter(_textView.Buffer.GetIterAtOffset(pos), 0, false, 0, 0); 520 | }); 521 | } 522 | 523 | public int ScrollPos 524 | { 525 | get 526 | { 527 | // Returns the position in the text of location 0,0 at top left of the view 528 | _textView.WindowToBufferCoords(TextWindowType.Text, 0, 0, out int x, out int y); 529 | TextIter iter = _textView.GetIterAtLocation(x, y); 530 | return iter.Offset; 531 | } 532 | set 533 | { 534 | Defer(() => 535 | { 536 | _textView.ScrollToIter(_textView.Buffer.GetIterAtOffset(int.MaxValue), 0, false, 0, 0); 537 | _textView.ScrollToIter(_textView.Buffer.GetIterAtOffset(value), 0, false, 0, 0); 538 | }); 539 | } 540 | } 541 | 542 | public void SetSelection(int from, int to) 543 | { 544 | // hack: just highlight first few characters 545 | to = from + 4; 546 | if (from > to) 547 | { 548 | int t = from; 549 | from = to; 550 | to = t; 551 | } 552 | if (from == to) 553 | { 554 | to = from + 1; 555 | } 556 | _textView.Buffer.MoveMark("insert", _textView.Buffer.GetIterAtOffset(from)); 557 | _textView.Buffer.MoveMark("selection_bound", _textView.Buffer.GetIterAtOffset(to)); 558 | } 559 | 560 | public void AddRepeatingTimer(int millis, string actionName) 561 | { 562 | GLib.Timeout.Add((uint) millis, () => 563 | { 564 | Controller.InvokeAction(this, actionName, ScratchValue.EmptyList); 565 | return true; 566 | }); 567 | } 568 | 569 | public int CurrentPosition 570 | { 571 | get => _textView.Buffer.CursorPosition; 572 | set => _textView.Buffer.MoveMark("insert", _textView.Buffer.GetIterAtOffset(value)); 573 | } 574 | 575 | public int CurrentPageIndex 576 | { 577 | get => _currentPage; 578 | set 579 | { 580 | if (value == _currentPage || value >= Book.Pages.Count || value < 0) 581 | return; 582 | ExitPage(); 583 | _currentPage = value; 584 | EnterPage(); 585 | } 586 | } 587 | 588 | public ScratchBook Book 589 | { 590 | get; private set; 591 | } 592 | 593 | (int, int) IScratchBookView.Selection 594 | { 595 | get 596 | { 597 | _textView.Buffer.GetSelectionBounds(out var start, out var end); 598 | return (start.Offset, end.Offset); 599 | } 600 | set 601 | { 602 | var (from, to) = value; 603 | _textView.Buffer.MoveMark("insert", _textView.Buffer.GetIterAtOffset(from)); 604 | _textView.Buffer.MoveMark("selection_bound", _textView.Buffer.GetIterAtOffset(to)); 605 | } 606 | } 607 | 608 | string IScratchBookView.CurrentText 609 | { 610 | get => _textView.Buffer.Text; 611 | } 612 | 613 | string IScratchBookView.Clipboard 614 | { 615 | get 616 | { 617 | using (Clipboard clip = Clipboard.Get(GlobalClipboard)) 618 | return clip.WaitForText(); 619 | } 620 | } 621 | 622 | void IScratchBookView.DeleteTextBackwards(int count) 623 | { 624 | int pos = CurrentPosition; 625 | TextIter end = _textView.Buffer.GetIterAtOffset(pos); 626 | TextIter start = _textView.Buffer.GetIterAtOffset(pos - count); 627 | _textView.Buffer.Delete(ref start, ref end); 628 | } 629 | 630 | public bool RunSearch(ScratchPad.SearchFunc searchFunc, out T result) 631 | { 632 | return SearchWindow.RunSearch(AppWindow, searchFunc.Invoke, AppSettings, out result); 633 | } 634 | 635 | public bool GetInput(ScratchScope settings, out string value) 636 | { 637 | return InputModalWindow.GetInput(AppWindow, settings, out value); 638 | } 639 | 640 | string IScratchBookView.SelectedText 641 | { 642 | get 643 | { 644 | _textView.Buffer.GetSelectionBounds(out TextIter start, out TextIter end); 645 | return _textView.Buffer.GetText(start, end, true); 646 | } 647 | set 648 | { 649 | _textView.Buffer.GetSelectionBounds(out TextIter start, out TextIter end); 650 | _textView.Buffer.Delete(ref start, ref end); 651 | if (!string.IsNullOrEmpty(value)) 652 | _textView.Buffer.Insert(ref end, value); 653 | start = _textView.Buffer.GetIterAtOffset(end.Offset - value.Length); 654 | _textView.Buffer.SelectRange(start, end); 655 | } 656 | } 657 | 658 | public void LaunchSnippet(ScratchScope settings) 659 | { 660 | SnippetWindow window = new SnippetWindow(settings); 661 | window.ShowAll(); 662 | } 663 | } 664 | } 665 | -------------------------------------------------------------------------------- /gtk-ui/GtkScratch.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Debug 5 | AnyCPU 6 | 9.0.30729 7 | 2.0 8 | {DFB9B734-12C6-4014-997D-5B087B1FD53F} 9 | Exe 10 | Properties 11 | Barrkel.GtkScratchPad 12 | gtkscratch 13 | v4.7 14 | 512 15 | 16 | 17 | 18 | 19 | 3.5 20 | publish\ 21 | true 22 | Disk 23 | false 24 | Foreground 25 | 7 26 | Days 27 | false 28 | false 29 | true 30 | 0 31 | 1.0.0.%2a 32 | false 33 | false 34 | true 35 | 36 | 37 | 38 | 39 | 40 | true 41 | full 42 | false 43 | bin\Debug\ 44 | DEBUG;TRACE 45 | prompt 46 | 4 47 | x86 48 | false 49 | true 50 | false 51 | 52 | 53 | pdbonly 54 | true 55 | bin\Release\ 56 | TRACE 57 | prompt 58 | 4 59 | false 60 | 61 | 62 | 63 | 64 | 65 | 66 | False 67 | ..\..\..\other\bulk\mono-2.10.9\lib\mono\gtk-sharp-2.0\atk-sharp.dll 68 | 69 | 70 | 71 | False 72 | ..\..\..\other\bulk\mono-2.10.9\lib\mono\gtk-sharp-2.0\glib-sharp.dll 73 | 74 | 75 | False 76 | ..\..\..\other\bulk\mono-2.10.9\lib\mono\gtk-sharp-2.0\gtk-sharp.dll 77 | 78 | 79 | 80 | 81 | 3.5 82 | 83 | 84 | 3.5 85 | 86 | 87 | 3.5 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | True 97 | True 98 | Resources.resx 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | False 118 | .NET Framework 3.5 SP1 119 | true 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | PublicResXFileCodeGenerator 129 | Resources.Designer.cs 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 140 | 141 | 142 | 143 | 150 | -------------------------------------------------------------------------------- /gtk-ui/LogView.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Gtk; 6 | using Barrkel.ScratchPad; 7 | using System.IO; 8 | 9 | namespace Barrkel.GtkScratchPad 10 | { 11 | public class LogView : Frame 12 | { 13 | static readonly int MaxLength = 100_000; 14 | static readonly int TrimLength = 50_000; 15 | TextView _textView; 16 | int _length; 17 | 18 | public LogView(ScratchScope settings, Window appWindow) 19 | { 20 | AppSettings = settings; 21 | AppWindow = appWindow; 22 | InitComponent(); 23 | } 24 | 25 | public Window AppWindow { get; private set; } 26 | public ScratchScope AppSettings { get; private set; } 27 | 28 | private void InitComponent() 29 | { 30 | Gdk.Color grey = new Gdk.Color(0xF0, 0xF0, 0xF0); 31 | var textFontName = AppSettings.GetOrDefault("log-font", null); 32 | if (textFontName == null) 33 | textFontName = AppSettings.GetOrDefault("text-font", "Courier New"); 34 | var textFont = Pango.FontDescription.FromString(textFontName); 35 | 36 | _textView = new MyTextView 37 | { 38 | WrapMode = WrapMode.Word 39 | }; 40 | _textView.ModifyBase(StateType.Normal, grey); 41 | // _textView.Buffer.Changed += _text_TextChanged; 42 | // _textView.KeyDownEvent += _textView_KeyDownEvent; 43 | _textView.Editable = false; 44 | _textView.ModifyFont(textFont); 45 | 46 | ScrolledWindow scrolledTextView = new ScrolledWindow(); 47 | scrolledTextView.Add(_textView); 48 | 49 | VBox outerVertical = new VBox(); 50 | outerVertical.PackStart(scrolledTextView, true, true, 0); 51 | 52 | Add(outerVertical); 53 | 54 | BorderWidth = 5; 55 | } 56 | 57 | public void AppendLine(string text) 58 | { 59 | text += "\n"; 60 | TextIter end = _textView.Buffer.GetIterAtOffset(_length); 61 | _length += text.Length; 62 | _textView.Buffer.Insert(ref end, text); 63 | if (_length > MaxLength) 64 | Trim(); 65 | _textView.ScrollToIter( 66 | _textView.Buffer.GetIterAtOffset(_length), 0, false, 0, 0); 67 | } 68 | 69 | public void Trim() 70 | { 71 | string text = _textView.Buffer.Text; 72 | string[] lines = text.Split(new char[] { '\n' }, StringSplitOptions.None); 73 | 74 | int startLine = 0; 75 | int len = 0; 76 | for (int i = lines.Length - 1; i >= 0; --i) 77 | { 78 | string line = lines[i]; 79 | len += line.Length; 80 | if (len > TrimLength) 81 | { 82 | startLine = i + 1; 83 | break; 84 | } 85 | } 86 | StringBuilder result = new StringBuilder(); 87 | for (int i = startLine; i < lines.Length; ++i) 88 | result.AppendLine(lines[i]); 89 | _length = result.Length; 90 | _textView.Buffer.Text = result.ToString(); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /gtk-ui/MainWindow.cs: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Barrkel.ScratchPad; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Barrkel.GtkScratchPad 8 | { 9 | public class MainWindow : Window 10 | { 11 | Notebook _notebook; 12 | 13 | public MainWindow(ScratchRootController rootController) : base(WindowType.Toplevel) 14 | { 15 | RootController = rootController; 16 | rootController.ExitHandler += (sender, e) => Destroy(); 17 | InitComponent(); 18 | } 19 | 20 | public ScratchRootController RootController { get; } 21 | 22 | private void InitComponent() 23 | { 24 | Title = RootController.RootScope.GetOrDefault("app-title", "GTK ScratchPad"); 25 | Resize(600, 600); 26 | 27 | List views = new List(); 28 | _notebook = new Notebook(); 29 | 30 | foreach (var book in RootController.Root.Books) 31 | { 32 | ScratchBookController controller = RootController.GetControllerFor(book); 33 | BookView view = new BookView(book, controller, this); 34 | views.Add(view); 35 | _notebook.AppendPage(view, CreateTabLabel(book.Name)); 36 | } 37 | var logView = new LogView(RootController.RootScope, this); 38 | _notebook.AppendPage(logView, CreateTabLabel("log")); 39 | Log.Handler = logView.AppendLine; 40 | 41 | Add(_notebook); 42 | 43 | Destroyed += (o, e) => 44 | { 45 | // TODO: call EnsureSaved on the root controller instead 46 | foreach (var view in views) 47 | view.EnsureSaved(); 48 | Application.Quit(); 49 | }; 50 | } 51 | 52 | private static Label CreateTabLabel(string title) 53 | { 54 | Label viewLabel = new Label { Text = title }; 55 | viewLabel.SetPadding(10, 2); 56 | return viewLabel; 57 | } 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /gtk-ui/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Gtk; 3 | using Barrkel.ScratchPad; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.IO; 7 | 8 | namespace Barrkel.GtkScratchPad 9 | { 10 | static class Program 11 | { 12 | public static int Main(string[] args) 13 | { 14 | try 15 | { 16 | while (true) 17 | { 18 | int result = AppMain(args); 19 | if (result >= 0) 20 | return result; 21 | } 22 | } 23 | catch (Exception ex) 24 | { 25 | Console.Error.WriteLine("Error: {0}", ex.Message); 26 | Console.Error.WriteLine("Stack trace: {0}", ex.StackTrace); 27 | Console.ReadLine(); 28 | return 1; 29 | } 30 | } 31 | 32 | private static void NormalizeLineEndings(DirectoryInfo root) 33 | { 34 | Console.WriteLine("Processing notes in {0}", root.FullName); 35 | foreach (string baseName in root.GetFiles("*.txt") 36 | .Union(root.GetFiles("*.log")) 37 | .Select(f => Path.ChangeExtension(f.FullName, null)) 38 | .Distinct()) 39 | { 40 | Console.WriteLine("Normalizing {0}", baseName); 41 | ScratchPage.NormalizeLineEndings(baseName); 42 | } 43 | foreach (DirectoryInfo child in root.GetDirectories()) 44 | if (child.Name != "." && child.Name != "..") 45 | NormalizeLineEndings(child); 46 | } 47 | 48 | public static int AppMain(string[] argArray) 49 | { 50 | List args = new List(argArray); 51 | Options options = new Options(args); 52 | 53 | string[] stub = Array.Empty(); 54 | Application.Init("GtkScratchPad", ref stub); 55 | 56 | if (args.Count != 1) 57 | { 58 | Console.WriteLine("Expected argument: storage directory"); 59 | return 1; 60 | } 61 | 62 | if (options.NormalizeFiles) 63 | { 64 | NormalizeLineEndings(new DirectoryInfo(args[0])); 65 | } 66 | 67 | ScratchScope rootScope = ScratchScope.CreateRoot(); 68 | rootScope.Load(LegacyLibrary.Instance); 69 | rootScope.Load(ScratchLib.Instance); 70 | 71 | ScratchRoot root = new ScratchRoot(options, args[0], rootScope); 72 | ScratchLib.Instance.LoadConfig(root); 73 | 74 | ScratchRootController rootController = new ScratchRootController(root); 75 | 76 | MainWindow window = new MainWindow(rootController); 77 | window.ShowAll(); 78 | 79 | Application.Run(); 80 | 81 | return rootController.ExitIntent == ExitIntent.Exit ? 0 : -1; 82 | } 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /gtk-ui/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Barrkel.GtkScratchPad { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | public class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | public static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Barrkel.GtkScratchPad.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | public static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to .globalconfig Default Global Config 65 | /// 66 | ///# Do not modify this file since it may be overwritten without notice. 67 | /// 68 | ///# Reset this config with reset-config 69 | ///C-M-R = reset-config 70 | /// 71 | ///#################################################################################################### 72 | ///# UI config 73 | ///#################################################################################################### 74 | ///text-font = "Consolas, 9" 75 | ///info-font = "Verdana, 12" 76 | ///log-font = "Consolas, 8" 77 | /// 78 | ///get-input-number = { |current| 79 | /// result = nil 80 | /// whil [rest of string was truncated]";. 81 | /// 82 | public static string globalconfig { 83 | get { 84 | return ResourceManager.GetString("globalconfig", resourceCulture); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /gtk-ui/Resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | 122 | bin\Debug\dir\0-globalconfig.txt;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Windows-1252 123 | 124 | -------------------------------------------------------------------------------- /gtk-ui/Scratch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Linq; 5 | using System.Collections.ObjectModel; 6 | using System.IO; 7 | using System.Text.RegularExpressions; 8 | using System.Reflection; 9 | using System.Collections; 10 | 11 | namespace Barrkel.ScratchPad 12 | { 13 | public static class Log 14 | { 15 | public static Action Handler { get; set; } = Console.Error.WriteLine; 16 | 17 | public static void Out(string line) 18 | { 19 | Handler(line); 20 | } 21 | } 22 | 23 | public interface IScratchScope 24 | { 25 | IScratchScope CreateChild(string name); 26 | } 27 | 28 | public class NullScope : IScratchScope 29 | { 30 | public static readonly IScratchScope Instance = new NullScope(); 31 | 32 | private NullScope() 33 | { 34 | } 35 | 36 | public IScratchScope CreateChild(string name) 37 | { 38 | return this; 39 | } 40 | } 41 | 42 | public class Options 43 | { 44 | public Options(List args) 45 | { 46 | NormalizeFiles = ParseFlag(args, "normalize"); 47 | } 48 | 49 | static bool MatchFlag(string arg, string name) 50 | { 51 | return arg == "--" + name; 52 | } 53 | 54 | static bool ParseFlag(List args, string name) 55 | { 56 | return args.RemoveAll(arg => MatchFlag(arg, name)) > 0; 57 | } 58 | 59 | public bool NormalizeFiles { get; } 60 | } 61 | 62 | // The main root. Every directory in this directory is a tab on the main interface, like a separate 63 | // notebook. 64 | // Every file in this directory is in the main notebook. 65 | public class ScratchRoot 66 | { 67 | readonly List _books; 68 | 69 | public ScratchRoot(Options options, string rootDirectory, IScratchScope rootScope) 70 | { 71 | RootScope = rootScope; 72 | Options = options; 73 | _books = new List(); 74 | Books = new ReadOnlyCollection(_books); 75 | RootDirectory = rootDirectory; 76 | _books.Add(new ScratchBook(this, rootDirectory)); 77 | foreach (string dir in Directory.GetDirectories(rootDirectory)) 78 | _books.Add(new ScratchBook(this, dir)); 79 | } 80 | 81 | public Options Options { get; } 82 | 83 | public string RootDirectory { get; } 84 | 85 | public ReadOnlyCollection Books { get; } 86 | 87 | public IScratchScope RootScope { get; } 88 | 89 | public void SaveLatest() 90 | { 91 | foreach (var book in Books) 92 | book.SaveLatest(); 93 | } 94 | } 95 | 96 | static class DictionaryExtension 97 | { 98 | public static void Deconstruct(this KeyValuePair self, out TKey key, out TValue value) 99 | { 100 | key = self.Key; 101 | value = self.Value; 102 | } 103 | } 104 | 105 | // Simple text key to text value cache with timestamp-based invalidation. 106 | public class LineCache 107 | { 108 | string _storeFile; 109 | Dictionary _cache = new Dictionary(); 110 | bool _dirty; 111 | 112 | public LineCache(string storeFile) 113 | { 114 | _storeFile = storeFile; 115 | Load(); 116 | } 117 | 118 | private void Load() 119 | { 120 | _cache.Clear(); 121 | if (!File.Exists(_storeFile)) 122 | return; 123 | using (var r = new LineReader(_storeFile)) 124 | { 125 | int count = int.Parse(r.ReadLine()); 126 | for (int i = 0; i < count; ++i) 127 | { 128 | string name = StringUtil.Unescape(r.ReadLine()); 129 | // This is parsing UTC but it doesn't return UTC! 130 | DateTime timestamp = DateTime.Parse(r.ReadLine()).ToUniversalTime(); 131 | string line = StringUtil.Unescape(r.ReadLine()); 132 | _cache[name] = (timestamp, line); 133 | } 134 | } 135 | } 136 | 137 | public void Save() 138 | { 139 | if (!_dirty) 140 | return; 141 | using (var w = new LineWriter(_storeFile, FileMode.Create)) 142 | { 143 | w.WriteLine(_cache.Count.ToString()); 144 | foreach (var (name, (timestamp, line)) in _cache) 145 | { 146 | w.WriteLine(StringUtil.Escape(name)); 147 | w.WriteLine(timestamp.ToString("o")); 148 | w.WriteLine(StringUtil.Escape(line)); 149 | } 150 | } 151 | _dirty = false; 152 | } 153 | 154 | public string Get(string name, DateTime timestamp, Func fetch) 155 | { 156 | if (_cache.TryGetValue(name, out var entry)) 157 | { 158 | var (cacheTs, line) = entry; 159 | TimeSpan age = timestamp - cacheTs; 160 | // 1 second leeway because of imprecision in file system timestamps etc. 161 | if (age < TimeSpan.FromSeconds(1)) 162 | return line; 163 | } 164 | var update = fetch(); 165 | Put(name, timestamp, update); 166 | return update; 167 | } 168 | 169 | public void Put(string name, DateTime timestamp, string line) 170 | { 171 | _cache[name] = (timestamp, line); 172 | _dirty = true; 173 | } 174 | 175 | public void EnumerateValues(Action callback) 176 | { 177 | foreach (var (_, line) in _cache.Values) 178 | callback(line); 179 | } 180 | } 181 | 182 | public class ScratchBook 183 | { 184 | List _pages = new List(); 185 | string _rootDirectory; 186 | 187 | public ScratchBook(ScratchRoot root, string rootDirectory) 188 | { 189 | Root = root; 190 | _rootDirectory = rootDirectory; 191 | Scope = root.RootScope.CreateChild(Name); 192 | Pages = new ReadOnlyCollection(_pages); 193 | var rootDir = new DirectoryInfo(rootDirectory); 194 | TitleCache = new LineCache(Path.Combine(_rootDirectory, "title_cache.text")); 195 | _pages.AddRange(rootDir.GetFiles("*.txt") 196 | .Union(rootDir.GetFiles("*.log")) 197 | .OrderBy(f => f.LastWriteTimeUtc) 198 | .Select(f => Path.ChangeExtension(f.FullName, null)) 199 | .Distinct() 200 | .Select(name => new ScratchPage(TitleCache, name))); 201 | } 202 | 203 | public ScratchRoot Root { get; } 204 | 205 | public IScratchScope Scope { get; } 206 | 207 | internal LineCache TitleCache { get; } 208 | 209 | static bool DoesBaseNameExist(string baseName) 210 | { 211 | string logFile = Path.ChangeExtension(baseName, ".log"); 212 | string textFile = Path.ChangeExtension(baseName, ".txt"); 213 | return File.Exists(logFile) || File.Exists(textFile); 214 | } 215 | 216 | string FindNewPageBaseName() 217 | { 218 | string now = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm"); 219 | 220 | string stem = string.Format("page-{0}", now); 221 | string result = Path.Combine(_rootDirectory, stem); 222 | if (!DoesBaseNameExist(result)) 223 | return result; 224 | 225 | for (int i = 1; ; ++i) 226 | { 227 | result = Path.Combine(_rootDirectory, 228 | string.Format("{0}-{1:000}", stem, i)); 229 | if (!DoesBaseNameExist(result)) 230 | return result; 231 | } 232 | } 233 | 234 | public ReadOnlyCollection Pages { get; } 235 | 236 | public int MoveToEnd(int pageIndex) 237 | { 238 | var page = _pages[pageIndex]; 239 | _pages.RemoveAt(pageIndex); 240 | _pages.Add(page); 241 | return _pages.Count - 1; 242 | } 243 | 244 | public ScratchPage AddPage() 245 | { 246 | ScratchPage result = new ScratchPage(TitleCache, FindNewPageBaseName()); 247 | _pages.Add(result); 248 | return result; 249 | } 250 | 251 | // This is called periodically, and on every modification. 252 | public void EnsureSaved() 253 | { 254 | TitleCache.Save(); 255 | } 256 | 257 | // This is only called if content has been modified. 258 | public void SaveLatest() 259 | { 260 | foreach (var page in Pages) 261 | page.SaveLatest(); 262 | } 263 | 264 | static bool IsSearchMatch(string text, string[] parts) 265 | { 266 | foreach (string part in parts) 267 | { 268 | switch (part[0]) 269 | { 270 | case '-': 271 | { 272 | string neg = part.Substring(1); 273 | if (neg.Length == 0) 274 | continue; 275 | if (text.IndexOf(neg, StringComparison.InvariantCultureIgnoreCase) >= 0) 276 | return false; 277 | 278 | break; 279 | } 280 | 281 | default: 282 | if (text.IndexOf(part, StringComparison.InvariantCultureIgnoreCase) < 0) 283 | return false; 284 | break; 285 | } 286 | } 287 | 288 | return true; 289 | } 290 | 291 | public IEnumerable<(string, int)> SearchTitles(string text) 292 | { 293 | string[] parts = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); 294 | 295 | for (int i = Pages.Count - 1; i >= 0; --i) 296 | { 297 | var page = Pages[i]; 298 | 299 | if (IsSearchMatch(page.Title, parts)) 300 | yield return (page.Title, i); 301 | } 302 | } 303 | 304 | public IEnumerable<(string, int)> SearchText(Regex re) 305 | { 306 | for (int i = Pages.Count - 1; i >= 0; --i) 307 | { 308 | var page = Pages[i]; 309 | if (re.Match(page.Text).Success) 310 | yield return (page.Title, i); 311 | } 312 | } 313 | 314 | /// 315 | /// Returns (title, index) of each match 316 | /// 317 | public IEnumerable<(string, int)> SearchTitles(Regex re) 318 | { 319 | for (int i = Pages.Count - 1; i >= 0; --i) 320 | { 321 | var page = Pages[i]; 322 | if (re.Match(page.Title).Success) 323 | yield return (page.Title, i); 324 | } 325 | } 326 | 327 | public string Name => Path.GetFileName(_rootDirectory); 328 | 329 | public override string ToString() 330 | { 331 | return Name; 332 | } 333 | } 334 | 335 | interface IReadOnlyPage 336 | { 337 | DateTime ChangeStamp { get; } 338 | string Title { get; } 339 | string Text { get; } 340 | } 341 | 342 | public class ScratchPage 343 | { 344 | LiteScratchPage _liteImpl; 345 | RealScratchPage _realImpl; 346 | // These timestamps are for detecting out of process modifications to txt and log files 347 | DateTime? _logStamp; 348 | DateTime? _textStamp; 349 | string _baseName; 350 | string _shortName; 351 | LineCache _titleCache; 352 | Dictionary _viewState = new Dictionary(); 353 | 354 | public ScratchPage(LineCache titleCache, string baseName) 355 | { 356 | _baseName = baseName; 357 | _shortName = Path.GetFileNameWithoutExtension(_baseName); 358 | _titleCache = titleCache; 359 | TextFile = new FileInfo(Path.ChangeExtension(_baseName, ".txt")); 360 | LogFile = new FileInfo(Path.ChangeExtension(_baseName, ".log")); 361 | } 362 | 363 | public bool IsNew => _realImpl != null && _realImpl.IsEmpty; 364 | 365 | public static void NormalizeLineEndings(string baseName) 366 | { 367 | FileInfo textFile = new FileInfo(Path.ChangeExtension(baseName, ".txt")); 368 | FileInfo logFile = new FileInfo(Path.ChangeExtension(baseName, ".log")); 369 | 370 | string srcTextFinal = null; 371 | if (textFile.Exists) 372 | srcTextFinal = File.ReadAllText(textFile.FullName); 373 | var srcUpdates = new List(); 374 | if (logFile.Exists) 375 | { 376 | using (var reader = new LineReader(logFile.FullName)) 377 | while (true) 378 | try 379 | { 380 | srcUpdates.Add(ScratchUpdate.Load(reader.ReadLine)); 381 | } 382 | catch (EndOfStreamException) 383 | { 384 | break; 385 | } 386 | } 387 | 388 | // replay source and transcribe to destination 389 | string srcText = ""; 390 | string dstText = ""; 391 | var dstUpdates = new List(); 392 | 393 | bool diff = false; 394 | foreach (ScratchUpdate srcUp in srcUpdates) 395 | { 396 | srcText = srcUp.Apply(srcText, out _, out _); 397 | string newDest = srcText.Replace("\r\n", "\n"); 398 | ScratchUpdate dstUp = ScratchUpdate.CalcUpdate(dstText, newDest, srcUp.Stamp); 399 | dstText = newDest; 400 | dstUpdates.Add(dstUp); 401 | diff |= srcText != dstText; 402 | } 403 | if (srcTextFinal != null && srcTextFinal != srcText) 404 | { 405 | string newDest = srcTextFinal.Replace("\r\n", "\n"); 406 | ScratchUpdate dstUp = ScratchUpdate.CalcUpdate(dstText, newDest, textFile.LastWriteTimeUtc); 407 | dstText = newDest; 408 | dstUpdates.Add(dstUp); 409 | } 410 | 411 | // rewrite data only if necessary 412 | if (!diff) 413 | return; 414 | 415 | using (var writer = new LineWriter(logFile.FullName, FileMode.Create)) 416 | foreach (var up in dstUpdates) 417 | up.Save(writer.WriteLine); 418 | File.WriteAllText(textFile.FullName, dstText); 419 | } 420 | 421 | public string Title => _titleCache.Get(_shortName, ChangeStamp, () => GetReadOnlyPage().Title); 422 | 423 | public bool IsEmpty => _realImpl == null || _realImpl.IsEmpty; 424 | 425 | internal T GetViewState(object view, Func ctor) 426 | { 427 | if (!_viewState.TryGetValue(view, out var result)) 428 | { 429 | result = ctor(); 430 | _viewState.Add(view, result); 431 | } 432 | return (T) result; 433 | } 434 | 435 | internal FileInfo TextFile 436 | { 437 | get; set; 438 | } 439 | 440 | internal FileInfo LogFile 441 | { 442 | get; set; 443 | } 444 | 445 | // Any operations we can perform without loading the full page (mutation or history) should come through here. 446 | IReadOnlyPage GetReadOnlyPage() 447 | { 448 | if (_realImpl != null || !TextFile.Exists) 449 | return GetRealPage(); 450 | return LoadLiteImplIfNecessary(); 451 | } 452 | 453 | bool UnderlyingChanged() 454 | { 455 | return (TextFile.Exists && TextFile.LastWriteTimeUtc != _textStamp) 456 | || (LogFile.Exists && LogFile.LastWriteTimeUtc != _logStamp); 457 | } 458 | 459 | internal RealScratchPage GetRealPage() 460 | { 461 | if (_realImpl == null) 462 | { 463 | _realImpl = LoadRealImpl(); 464 | _liteImpl = null; 465 | } 466 | else if (UnderlyingChanged()) 467 | { 468 | RealScratchPage result = LoadRealImpl(); 469 | if (result.Text != _realImpl.Text) 470 | result.Text = _realImpl.Text; 471 | _realImpl = result; 472 | } 473 | return _realImpl; 474 | } 475 | 476 | LiteScratchPage LoadLiteImplIfNecessary() 477 | { 478 | if (_liteImpl != null && !UnderlyingChanged()) 479 | return _liteImpl; 480 | _liteImpl = new LiteScratchPage(this); 481 | _textStamp = TextFile.LastWriteTimeUtc; 482 | if (LogFile.Exists) 483 | _logStamp = LogFile.LastWriteTimeUtc; 484 | return _liteImpl; 485 | } 486 | 487 | RealScratchPage LoadRealImpl() 488 | { 489 | // Load up a new real implementation from disk. 490 | RealScratchPage result = null; 491 | string text = null; 492 | 493 | // Try getting it from the log. 494 | try 495 | { 496 | if (LogFile.Exists) 497 | { 498 | _logStamp = LogFile.LastWriteTimeUtc; 499 | using (var reader = new LineReader(LogFile.FullName)) 500 | result = new RealScratchPage(reader.ReadLine); 501 | } 502 | } 503 | catch 504 | { 505 | result = null; 506 | } 507 | 508 | if (result == null) 509 | result = new RealScratchPage(); 510 | 511 | // Try getting it from the text file. 512 | if (TextFile.Exists) 513 | { 514 | text = File.ReadAllText(TextFile.FullName); 515 | _textStamp = TextFile.LastWriteTimeUtc; 516 | } 517 | 518 | // If there's a conflict, the text file wins, but keep log history; and rewrite it. 519 | if (text != null && result.Text != text) 520 | { 521 | result.Text = text; 522 | using (var w = new LineWriter(LogFile.FullName, FileMode.Create)) 523 | result.SaveAll(w.WriteLine); 524 | _logStamp = LogFile.LastWriteTimeUtc; 525 | } 526 | 527 | return result; 528 | } 529 | 530 | public void SaveLatest() 531 | { 532 | if (_realImpl == null) 533 | return; 534 | RealScratchPage realImpl = GetRealPage(); 535 | using (var w = new LineWriter(LogFile.FullName, FileMode.Append)) 536 | if (!realImpl.SaveLatest(w.WriteLine)) 537 | { 538 | Console.WriteLine("Dodged a write!"); 539 | return; 540 | } 541 | File.WriteAllText(TextFile.FullName, _realImpl.Text); 542 | _logStamp = LogFile.LastWriteTimeUtc; 543 | _textStamp = TextFile.LastWriteTimeUtc; 544 | } 545 | 546 | public ScratchIterator GetIterator() => GetRealPage().GetIterator(); 547 | 548 | public DateTime ChangeStamp => GetReadOnlyPage().ChangeStamp; 549 | 550 | public string Text 551 | { 552 | get { return GetReadOnlyPage().Text; } 553 | set { GetRealPage().Text = value; } 554 | } 555 | } 556 | 557 | // Lightweight read-only current-version-only view of a page. 558 | // Should not be instantiated if we only have the log file, but it'll cope (it'll replay log). 559 | public class LiteScratchPage : IReadOnlyPage 560 | { 561 | ScratchPage _page; 562 | string _text; 563 | 564 | public LiteScratchPage(ScratchPage page) 565 | { 566 | _page = page; 567 | } 568 | 569 | public string Title 570 | { 571 | get 572 | { 573 | string text = Text; 574 | int newLine = text.IndexOfAny(new[] { '\r', '\n' }); 575 | if (newLine < 0) 576 | return text; 577 | return text.Substring(0, newLine); 578 | } 579 | } 580 | 581 | public DateTime ChangeStamp 582 | { 583 | get 584 | { 585 | if (_page.TextFile.Exists) 586 | return _page.TextFile.LastWriteTimeUtc; 587 | if (_page.LogFile.Exists) 588 | return _page.LogFile.LastWriteTimeUtc; 589 | return DateTime.UtcNow; 590 | } 591 | } 592 | 593 | public string Text 594 | { 595 | get 596 | { 597 | if (_text != null) 598 | return _text; 599 | if (_page.TextFile.Exists) 600 | { 601 | _text = File.ReadAllText(_page.TextFile.FullName); 602 | return _text; 603 | } 604 | if (_page.LogFile.Exists) 605 | using (var r = new LineReader(_page.LogFile.FullName)) 606 | { 607 | RealScratchPage log = new RealScratchPage(r.ReadLine); 608 | _text = log.Text; 609 | return _text; 610 | } 611 | return ""; 612 | } 613 | set { throw new NotImplementedException(); } 614 | } 615 | } 616 | 617 | // Full-fat page with navigable history, mutation and change tracking. 618 | public class RealScratchPage : IReadOnlyPage 619 | { 620 | string _text = string.Empty; 621 | List _updates = new List(); 622 | int _lastSave; 623 | 624 | public RealScratchPage() 625 | { 626 | } 627 | 628 | public bool IsEmpty => _updates.Count == 0 && _text == ""; 629 | 630 | public string Title 631 | { 632 | get 633 | { 634 | string text = Text; 635 | int newLine = text.IndexOfAny(new[] { '\r', '\n' }); 636 | if (newLine < 0) 637 | return text; 638 | return text.Substring(0, newLine); 639 | } 640 | } 641 | 642 | public RealScratchPage(Func source) 643 | { 644 | for (; ; ) 645 | { 646 | try 647 | { 648 | ScratchUpdate up = ScratchUpdate.Load(source); 649 | _text = up.Apply(_text, out _, out _); 650 | _updates.Add(up); 651 | } 652 | catch (EndOfStreamException) 653 | { 654 | break; 655 | } 656 | } 657 | _lastSave = _updates.Count; 658 | } 659 | 660 | public bool SaveLatest(Action sink) 661 | { 662 | for (int i = _lastSave; i < _updates.Count; ++i) 663 | _updates[i].Save(sink); 664 | _lastSave = _updates.Count; 665 | return _updates.Count > 0; 666 | } 667 | 668 | public bool SaveAll(Action sink) 669 | { 670 | for (int i = 0; i < _updates.Count; ++i) 671 | _updates[i].Save(sink); 672 | _lastSave = _updates.Count; 673 | return _updates.Count > 0; 674 | } 675 | 676 | public ScratchIterator GetIterator() 677 | { 678 | return new ScratchIterator(_updates, Text); 679 | } 680 | 681 | public string Text 682 | { 683 | get { return _text; } 684 | set 685 | { 686 | if (_text == value) 687 | return; 688 | _updates.Add(ScratchUpdate.CalcUpdate(_text, value)); 689 | _text = value; 690 | } 691 | } 692 | 693 | public DateTime ChangeStamp 694 | { 695 | get 696 | { 697 | if (_updates.Count > 0) 698 | return _updates[_updates.Count - 1].Stamp; 699 | return DateTime.UtcNow; 700 | } 701 | } 702 | } 703 | 704 | class LineWriter : IDisposable 705 | { 706 | Func _fileCtor; 707 | TextWriter _writer; 708 | FileStream _file; 709 | 710 | public LineWriter(string path, FileMode mode) 711 | { 712 | // Lazily construct file so we only write non-empty files. 713 | _fileCtor = () => new FileStream(path, mode); 714 | } 715 | 716 | public void WriteLine(string line) 717 | { 718 | if (_writer != null || line != "") 719 | GetWriter().WriteLine(StringUtil.Escape(line)); 720 | } 721 | 722 | private TextWriter GetWriter() 723 | { 724 | if (_writer == null) 725 | { 726 | _file = _fileCtor(); 727 | _writer = new StreamWriter(_file); 728 | } 729 | return _writer; 730 | } 731 | 732 | public void Dispose() 733 | { 734 | if (_writer != null) 735 | { 736 | _writer.Flush(); 737 | _writer.Dispose(); 738 | _file.Dispose(); 739 | } 740 | } 741 | } 742 | 743 | class StringUtil 744 | { 745 | public static string Escape(string text) 746 | { 747 | StringBuilder result = new StringBuilder(); 748 | for (int i = 0; i < text.Length; ++i) 749 | { 750 | char ch = text[i]; 751 | switch (ch) 752 | { 753 | case '\r': 754 | result.Append(@"\r"); 755 | break; 756 | 757 | case '\n': 758 | result.Append(@"\n"); 759 | break; 760 | 761 | case '\\': 762 | result.Append(@"\\"); 763 | break; 764 | 765 | default: 766 | result.Append(ch); 767 | break; 768 | } 769 | } 770 | return result.ToString(); 771 | } 772 | 773 | public static string Unescape(string text) 774 | { 775 | StringBuilder result = new StringBuilder(); 776 | for (int i = 0; i < text.Length; ) 777 | { 778 | char ch = text[i++]; 779 | if (ch == '\\' && i < text.Length) 780 | { 781 | ch = text[i++]; 782 | switch (ch) 783 | { 784 | case '\\': 785 | result.Append('\\'); 786 | break; 787 | 788 | case 'r': 789 | result.Append('\r'); 790 | break; 791 | 792 | case 'n': 793 | result.Append('\n'); 794 | break; 795 | 796 | default: 797 | result.Append('\\').Append(ch); 798 | break; 799 | } 800 | } 801 | else 802 | result.Append(ch); 803 | } 804 | return result.ToString(); 805 | } 806 | } 807 | 808 | class LineReader : IDisposable 809 | { 810 | TextReader _reader; 811 | 812 | public LineReader(string path) 813 | { 814 | _reader = File.OpenText(path); 815 | } 816 | 817 | public string ReadLine() 818 | { 819 | string line = _reader.ReadLine(); 820 | if (line == null) 821 | throw new EndOfStreamException(); 822 | return StringUtil.Unescape(line); 823 | } 824 | 825 | public void Dispose() 826 | { 827 | _reader.Dispose(); 828 | } 829 | } 830 | 831 | public class ScratchIterator 832 | { 833 | List _updates; 834 | // invariant of _position: is at the index in _updates of the next 835 | // update to be applied to move forward. If at _updates.Count, then 836 | // is at end. 837 | int _position; 838 | int _updatedFrom; 839 | int _updatedTo; 840 | 841 | internal ScratchIterator(List updates) 842 | { 843 | Text = ""; 844 | _updates = updates; 845 | } 846 | 847 | internal ScratchIterator(List updates, string endText) 848 | { 849 | Text = endText; 850 | _updates = updates; 851 | _position = updates.Count; 852 | } 853 | 854 | public bool Navigate(int offset) 855 | { 856 | while (offset > 0) 857 | { 858 | if (!MoveNext()) 859 | return false; 860 | --offset; 861 | } 862 | while (offset < 0) 863 | { 864 | if (!MovePrevious()) 865 | return false; 866 | ++offset; 867 | } 868 | return true; 869 | } 870 | 871 | public void MoveToStart() 872 | { 873 | _position = 0; 874 | Text = ""; 875 | } 876 | 877 | public void MoveToEnd() 878 | { 879 | Navigate(Count - Position); 880 | } 881 | 882 | public bool MoveNext() 883 | { 884 | if (_position >= _updates.Count) 885 | return false; 886 | Text = _updates[_position].Apply(Text, out _updatedFrom, out _updatedTo); 887 | ++_position; 888 | return true; 889 | } 890 | 891 | public bool MovePrevious() 892 | { 893 | if (_position <= 0) 894 | return false; 895 | Text = _updates[_position - 1].Revert(Text, out _updatedFrom, out _updatedTo); 896 | --_position; 897 | return true; 898 | } 899 | 900 | public int UpdatedFrom => _updatedFrom; 901 | public int UpdatedTo => _updatedTo; 902 | public int Count => _updates.Count; 903 | 904 | public int Position 905 | { 906 | get { return _position; } 907 | set { Navigate(value - _position); } 908 | } 909 | 910 | public DateTime Stamp 911 | { 912 | get 913 | { 914 | if (_position > 0) 915 | return _updates[_position - 1].Stamp; 916 | if (_updates.Count > 0) 917 | return _updates[0].Stamp; 918 | return DateTime.UtcNow; 919 | } 920 | } 921 | 922 | public string Text 923 | { 924 | get; private set; 925 | } 926 | } 927 | 928 | // Represents an edit to text: an insertion, a deletion, or a batch of insertions and deletions. 929 | abstract class ScratchUpdate 930 | { 931 | // Try to sync longer text before shorter to avoid spurious matches; also, don't go too short. 932 | static readonly int[] SyncLengths = new[] { 128, 64, 32 }; 933 | 934 | protected ScratchUpdate() 935 | { 936 | } 937 | 938 | ScratchUpdate(Func source) 939 | { 940 | } 941 | 942 | public abstract string Apply(string oldText, out int from, out int to); 943 | public abstract string Revert(string newText, out int from, out int to); 944 | 945 | public virtual void Save(Action sink) 946 | { 947 | } 948 | 949 | public virtual DateTime Stamp 950 | { 951 | get { return DateTime.MinValue; } 952 | } 953 | 954 | public static ScratchUpdate Load(Func source) 955 | { 956 | string kind = source(); 957 | switch (kind) 958 | { 959 | case "batch": 960 | return new ScratchBatch(source); 961 | case "insert": 962 | return new ScratchInsertion(source); 963 | case "delete": 964 | return new ScratchDeletion(source); 965 | default: 966 | throw new FormatException( 967 | string.Format("Unknown update kind '{0}'", kind)); 968 | } 969 | } 970 | 971 | public static ScratchUpdate CalcUpdate(string oldText, string newText) 972 | { 973 | return new ScratchBatch(CalcUpdates(oldText, newText)); 974 | } 975 | 976 | public static ScratchUpdate CalcUpdate(string oldText, string newText, DateTime stamp) 977 | { 978 | return new ScratchBatch(CalcUpdates(oldText, newText), stamp); 979 | } 980 | 981 | // Simplistic text diff algorithm creating insertions and deletions. 982 | // Doesn't try very hard to be optimal. 983 | static IEnumerable CalcUpdates(string oldText, string newText) 984 | { 985 | // invariant: oldIndex and newIndex are pointing at starts of suffix under consideration. 986 | int oldIndex = 0; 987 | int newIndex = 0; 988 | // resIndex is the position in the input string to be modified by the next update. 989 | // As updates are created, this position necessarily moves forward. 990 | int resIndex = 0; 991 | 992 | for (;;) 993 | { 994 | loop_top: 995 | // Skip common sequence 996 | while (oldIndex < oldText.Length && newIndex < newText.Length 997 | && oldText[oldIndex] == newText[newIndex]) 998 | { 999 | ++oldIndex; 1000 | ++newIndex; 1001 | ++resIndex; 1002 | } 1003 | 1004 | // Check for termination / truncation 1005 | if (oldIndex == oldText.Length) 1006 | { 1007 | if (newIndex < newText.Length) 1008 | yield return new ScratchInsertion(resIndex, newText.Substring(newIndex)); 1009 | break; 1010 | } 1011 | if (newIndex == newText.Length) 1012 | { 1013 | yield return new ScratchDeletion(resIndex, oldText.Substring(oldIndex)); 1014 | break; 1015 | } 1016 | 1017 | // Finally, resync to next common sequence. 1018 | // Three cases: 1019 | // 1) Insertion; start of oldText will be found later in newText 1020 | // 2) Deletion; start of newText will be found later in oldText 1021 | // 3) Change; neither insertion or deletion, so record change and skip forwards 1022 | 1023 | // the start of the change in the result text 1024 | int changeIndex = resIndex; 1025 | // the prefix of the change in the new text 1026 | int changeNew = newIndex; 1027 | int changeOld = oldIndex; 1028 | int changeLen = 0; 1029 | 1030 | for (;;) 1031 | { 1032 | for (int i = 0; i < SyncLengths.Length; ++i) 1033 | { 1034 | int syncLen = SyncLengths[i]; 1035 | 1036 | // try to sync old with new - the insertion case 1037 | if (syncLen <= (oldText.Length - oldIndex)) 1038 | { 1039 | string chunk = oldText.Substring(oldIndex, syncLen); 1040 | int found = newText.IndexOf(chunk, newIndex); 1041 | if (found >= 0) 1042 | { 1043 | // We found prefix chunk of oldText inside newText. 1044 | // That means the text at the start of newText is an insertion. 1045 | if (changeLen > 0) 1046 | { 1047 | // handle any change skipping we had to do 1048 | yield return new ScratchDeletion(changeIndex, 1049 | oldText.Substring(changeOld, changeLen)); 1050 | yield return new ScratchInsertion(changeIndex, 1051 | newText.Substring(changeNew, changeLen)); 1052 | } 1053 | int insertLen = found - newIndex; 1054 | yield return new ScratchInsertion(resIndex, 1055 | newText.Substring(newIndex, insertLen)); 1056 | resIndex += insertLen; 1057 | newIndex += insertLen; 1058 | // Now newIndex will be pointing at the prefix that oldIndex 1059 | // already points to, ready for another go around the loop. 1060 | goto loop_top; 1061 | } 1062 | } 1063 | 1064 | // sync new prefix with old - deletion 1065 | if (syncLen <= (newText.Length - newIndex)) 1066 | { 1067 | string chunk = newText.Substring(newIndex, syncLen); 1068 | int found = oldText.IndexOf(chunk, oldIndex); 1069 | if (found >= 0) 1070 | { 1071 | // Prefix chunk of newText inside oldText => deletion. 1072 | if (changeLen > 0) 1073 | { 1074 | yield return new ScratchDeletion(changeIndex, 1075 | oldText.Substring(changeOld, changeLen)); 1076 | yield return new ScratchInsertion(changeIndex, 1077 | newText.Substring(changeNew, changeLen)); 1078 | } 1079 | int deleteLen = found - oldIndex; 1080 | yield return new ScratchDeletion(resIndex, 1081 | oldText.Substring(oldIndex, deleteLen)); 1082 | oldIndex += deleteLen; 1083 | goto loop_top; 1084 | } 1085 | } 1086 | } 1087 | 1088 | // If we got here, then multiple sync prefixes failed. Take the longest 1089 | // prefix sync and skip it as a change, then try again. 1090 | int skipLen = SyncLengths[0]; 1091 | if (newIndex + skipLen > newText.Length) 1092 | skipLen = newText.Length - newIndex; 1093 | if (oldIndex + skipLen > oldText.Length) 1094 | skipLen = oldText.Length - oldIndex; 1095 | changeLen += skipLen; 1096 | oldIndex += skipLen; 1097 | newIndex += skipLen; 1098 | resIndex += skipLen; 1099 | if (skipLen != SyncLengths[0]) 1100 | { 1101 | // End of old or new has been found; don't try to resync. 1102 | yield return new ScratchDeletion(changeIndex, 1103 | oldText.Substring(changeOld, changeLen)); 1104 | yield return new ScratchInsertion(changeIndex, 1105 | newText.Substring(changeNew, changeLen)); 1106 | break; 1107 | } 1108 | } 1109 | } 1110 | } 1111 | 1112 | class ScratchBatch : ScratchUpdate 1113 | { 1114 | List _updates = new List(); 1115 | DateTime _stamp; 1116 | 1117 | public ScratchBatch(IEnumerable updates) 1118 | : this(updates, DateTime.UtcNow) 1119 | { 1120 | } 1121 | 1122 | public ScratchBatch(IEnumerable updates, DateTime stamp) 1123 | { 1124 | _stamp = stamp; 1125 | _updates.AddRange(updates); 1126 | } 1127 | 1128 | public ScratchBatch(Func source) 1129 | : base(source) 1130 | { 1131 | _stamp = DateTime.Parse(source()); 1132 | int count = int.Parse(source()); 1133 | for (int i = 0; i < count; ++i) 1134 | _updates.Add(ScratchUpdate.Load(source)); 1135 | } 1136 | 1137 | public override DateTime Stamp 1138 | { 1139 | get { return _stamp; } 1140 | } 1141 | 1142 | public override string Apply(string oldText, out int from, out int to) 1143 | { 1144 | from = int.MaxValue; 1145 | to = -1; 1146 | foreach (var up in _updates) 1147 | { 1148 | oldText = up.Apply(oldText, out int partFrom, out int partTo); 1149 | from = Math.Min(from, partFrom); 1150 | to = Math.Max(to, partTo); 1151 | } 1152 | return oldText; 1153 | } 1154 | 1155 | public override string Revert(string newText, out int from, out int to) 1156 | { 1157 | from = int.MaxValue; 1158 | to = -1; 1159 | for (int i = _updates.Count - 1; i >= 0; --i) 1160 | { 1161 | var up = _updates[i]; 1162 | newText = up.Revert(newText, out int partFrom, out int partTo); 1163 | from = Math.Min(from, partFrom); 1164 | to = Math.Max(to, partTo); 1165 | } 1166 | return newText; 1167 | } 1168 | 1169 | public override void Save(Action sink) 1170 | { 1171 | sink("batch"); 1172 | base.Save(sink); 1173 | sink(Stamp.ToString("o")); 1174 | sink(_updates.Count.ToString()); 1175 | foreach (var up in _updates) 1176 | up.Save(sink); 1177 | } 1178 | } 1179 | 1180 | class ScratchInsertion : ScratchUpdate 1181 | { 1182 | public ScratchInsertion(int offset, string value) 1183 | { 1184 | Offset = offset; 1185 | Value = value; 1186 | } 1187 | 1188 | public ScratchInsertion(Func source) 1189 | : base(source) 1190 | { 1191 | Offset = int.Parse(source()); 1192 | Value = source(); 1193 | } 1194 | 1195 | public int Offset { get; } 1196 | public string Value { get; } 1197 | 1198 | public override string Apply(string oldText, out int from, out int to) 1199 | { 1200 | from = Offset; 1201 | to = Offset + Value.Length; 1202 | return oldText.Insert(Offset, Value); 1203 | } 1204 | 1205 | public override string Revert(string newText, out int from, out int to) 1206 | { 1207 | from = Offset; 1208 | to = Offset; 1209 | return newText.Remove(Offset, Value.Length); 1210 | } 1211 | 1212 | public override void Save(Action sink) 1213 | { 1214 | sink("insert"); 1215 | base.Save(sink); 1216 | sink(Offset.ToString()); 1217 | sink(Value); 1218 | } 1219 | } 1220 | 1221 | class ScratchDeletion : ScratchUpdate 1222 | { 1223 | public ScratchDeletion(int offset, string value) 1224 | { 1225 | Offset = offset; 1226 | Value = value; 1227 | } 1228 | 1229 | public ScratchDeletion(Func source) 1230 | : base(source) 1231 | { 1232 | Offset = int.Parse(source()); 1233 | Value = source(); 1234 | } 1235 | 1236 | public int Offset { get; } 1237 | public string Value { get; } 1238 | 1239 | public override string Apply(string oldText, out int from, out int to) 1240 | { 1241 | from = Offset; 1242 | to = Offset; 1243 | return oldText.Remove(Offset, Value.Length); 1244 | } 1245 | 1246 | public override string Revert(string newText, out int from, out int to) 1247 | { 1248 | from = Offset; 1249 | to = Offset + Value.Length; 1250 | return newText.Insert(Offset, Value); 1251 | } 1252 | 1253 | public override void Save(Action sink) 1254 | { 1255 | sink("delete"); 1256 | base.Save(sink); 1257 | sink(Offset.ToString()); 1258 | sink(Value); 1259 | } 1260 | } 1261 | } 1262 | } 1263 | 1264 | -------------------------------------------------------------------------------- /gtk-ui/ScratchConf.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Linq; 5 | using System.Collections.ObjectModel; 6 | using System.IO; 7 | using System.Text.RegularExpressions; 8 | using System.Reflection; 9 | using System.Collections; 10 | 11 | namespace Barrkel.ScratchPad 12 | { 13 | public enum ScratchType 14 | { 15 | Null, 16 | String, // string 17 | Int32, // integer 18 | ScratchFunction, 19 | Action 20 | } 21 | 22 | public class ExecutionContext 23 | { 24 | public static readonly long MaxBackEdges = 50_000; 25 | public static readonly int MaxExecutionDepth = 1000; 26 | 27 | private class GlobalContext 28 | { 29 | public GlobalContext(ScratchBookController controller, IScratchBookView view) 30 | { 31 | Controller = controller; 32 | View = view; 33 | StartTime = DateTime.UtcNow; 34 | } 35 | 36 | public ScratchBookController Controller { get; } 37 | public IScratchBookView View { get; } 38 | public DateTime StartTime { get; } 39 | 40 | // Watchdog on runaway script execution is based on back edges. 41 | // Loops that reduce the value of the instruction pointer, and function returns 42 | // are considered back edges. It's not perfect but trying to calculate execution time 43 | // is awkward when we expect modal dialogs to be triggered from scripts. 44 | public long BackEdges; 45 | } 46 | 47 | public ExecutionContext(ScratchBookController controller, IScratchBookView view, ScratchScope scope) 48 | { 49 | Context = new GlobalContext(controller, view); 50 | Scope = scope; 51 | Depth = 0; 52 | } 53 | 54 | private ExecutionContext(ExecutionContext parent, ScratchScope childScope) 55 | { 56 | Context = parent.Context; 57 | Scope = childScope; 58 | Depth = parent.Depth + 1; 59 | if (Depth > MaxExecutionDepth) 60 | throw new InvalidOperationException("Execution stack too deep"); 61 | } 62 | 63 | public ExecutionContext CreateChild(string name) 64 | { 65 | return new ExecutionContext(this, Scope.CreateChild(name)); 66 | } 67 | 68 | private GlobalContext Context { get; } 69 | public ScratchBookController Controller => Context.Controller; 70 | public IScratchBookView View => Context.View; 71 | public ScratchScope Scope { get; } 72 | public int Depth { get; } 73 | 74 | public void AddBackEdge() 75 | { 76 | ++Context.BackEdges; 77 | if (Context.BackEdges > MaxBackEdges) 78 | throw new InvalidOperationException("Too much execution, too many back edges."); 79 | } 80 | } 81 | 82 | public delegate ScratchValue ScratchAction(ExecutionContext context, IList args); 83 | 84 | public class ScratchFunction 85 | { 86 | public ScratchFunction(ScratchProgram program, List parameters) 87 | { 88 | Program = program; 89 | Parameters = new ReadOnlyCollection(parameters); 90 | } 91 | 92 | public ReadOnlyCollection Parameters { get; } 93 | public ScratchProgram Program { get; } 94 | 95 | public ScratchValue Invoke(string name, ExecutionContext context, IList args) 96 | { 97 | ExecutionContext child = context.CreateChild(name); 98 | if (args.Count != Parameters.Count) 99 | throw new InvalidOperationException( 100 | $"Parameter count mismatch: expected {Parameters.Count}, got {args.Count}"); 101 | for (int i = 0; i < Parameters.Count; ++i) 102 | { 103 | child.Scope.AssignLocal(Parameters[i], args[i]); 104 | } 105 | return Program.Run(child); 106 | } 107 | 108 | public override string ToString() 109 | { 110 | StringBuilder result = new StringBuilder(); 111 | result.Append("{"); 112 | if (Parameters.Count > 0) 113 | result.Append("|").Append(string.Join(",", Parameters)).Append("| "); 114 | result.Append(Program); 115 | result.Append("}"); 116 | return result.ToString(); 117 | } 118 | } 119 | 120 | public class ScratchValue 121 | { 122 | public static readonly ScratchValue Null = new ScratchValue(null, ScratchType.Null); 123 | public static readonly ScratchValue True = new ScratchValue("true"); 124 | public static readonly ScratchValue False = Null; 125 | public static readonly IList EmptyList = new ScratchValue[0]; 126 | 127 | private Object _value; 128 | 129 | private ScratchValue(object value, ScratchType type) 130 | { 131 | _value = value; 132 | Type = type; 133 | } 134 | 135 | public ScratchValue(string value) 136 | { 137 | _value = value; 138 | Type = ScratchType.String; 139 | } 140 | 141 | public ScratchValue(int value) 142 | { 143 | _value = value; 144 | Type = ScratchType.Int32; 145 | } 146 | 147 | public ScratchValue(ScratchAction action) 148 | { 149 | _value = action; 150 | Type = ScratchType.Action; 151 | } 152 | 153 | public ScratchValue(ScratchFunction func) 154 | { 155 | _value = func; 156 | Type = ScratchType.ScratchFunction; 157 | } 158 | 159 | public static ScratchValue From(bool value) 160 | { 161 | return value ? True : False; 162 | } 163 | 164 | public static ScratchValue From(string value) 165 | { 166 | return new ScratchValue(value); 167 | } 168 | 169 | public static ScratchValue From(int value) 170 | { 171 | return new ScratchValue(value); 172 | } 173 | 174 | public static ScratchValue From(object value) 175 | { 176 | switch (value) 177 | { 178 | case null: 179 | return Null; 180 | 181 | case bool boolValue: 182 | return From(boolValue); 183 | 184 | case ScratchValue scratchValue: 185 | return scratchValue; 186 | 187 | case string stringValue: 188 | return new ScratchValue(stringValue); 189 | 190 | case int intValue: 191 | return new ScratchValue(intValue); 192 | 193 | case ScratchAction action: 194 | return new ScratchValue(action); 195 | 196 | case ScratchFunction func: 197 | return new ScratchValue(func); 198 | 199 | default: 200 | throw new ArgumentException("Invalid type: " + value); 201 | } 202 | } 203 | 204 | public ScratchType Type { get; } 205 | public String StringValue => (string)_value; 206 | public int Int32Value => (int)_value; 207 | public ScratchFunction FunctionValue => (ScratchFunction)_value; 208 | public bool IsTrue => Type != ScratchType.Null; 209 | public bool IsFalse => Type == ScratchType.Null; 210 | public object ObjectValue => _value; 211 | 212 | public bool IsInvokable => 213 | Type == ScratchType.Action || 214 | (Type == ScratchType.ScratchFunction && FunctionValue.Parameters.Count == 0); 215 | 216 | public ScratchValue Invoke(string name, ExecutionContext context, params ScratchValue[] args) 217 | { 218 | return Invoke(name, context, (IList)args); 219 | } 220 | 221 | public ScratchValue Invoke(string name, ExecutionContext context, IList args) 222 | { 223 | switch (Type) 224 | { 225 | case ScratchType.Action: 226 | return ScratchValue.From(((ScratchAction)_value)(context, args)); 227 | 228 | case ScratchType.ScratchFunction: 229 | return ((ScratchFunction)_value).Invoke(name, context, args); 230 | 231 | // invoking a string binding invokes whatever the string is itself bound to, recursively 232 | // this lets us use strings as function pointers, as long as we don't mind naming our functions 233 | case ScratchType.String: 234 | return context.Scope.Lookup(StringValue).Invoke($"{name}->{StringValue}", context, args); 235 | 236 | default: 237 | throw new InvalidOperationException("Tried to invoke non-function: " + this); 238 | } 239 | } 240 | 241 | public override string ToString() 242 | { 243 | return $"SV({_value})"; 244 | } 245 | 246 | public static IList List(params object[] values) 247 | { 248 | ScratchValue[] result = new ScratchValue[values.Length]; 249 | for (int i = 0; i < values.Length; ++i) 250 | result[i] = From(values[i]); 251 | return result; 252 | } 253 | } 254 | 255 | // Basic stack machine straight from parser. 256 | public class ScratchProgram 257 | { 258 | // Set by debug-stack(x) where x > 0 259 | public static bool DebugStack = false; 260 | 261 | public enum Operation 262 | { 263 | // arg is ScratchValue to push 264 | Push, 265 | Pop, 266 | // arg is name, stack is N followed by N arguments 267 | Call, 268 | // arg is name, value is peeked. 269 | // Existing binding is searched for and assigned in the scope it's found. 270 | Set, 271 | // Fetch value of existing binding 272 | Get, 273 | // arg is name, value is peeked. 274 | // Create binding in top scope and assign. 275 | SetLocal, 276 | // Early exit from this program, result is top of stack 277 | Ret, 278 | // Pop stack, jump if null 279 | JumpIfNull, 280 | // Pop stack, jump if not null 281 | JumpIfNotNull, 282 | // Unconditional jump 283 | Jump, 284 | // Boolean not 285 | Not, 286 | Dup, 287 | } 288 | 289 | public struct Op 290 | { 291 | public Op(Operation op) 292 | { 293 | Operation = op; 294 | Arg = null; 295 | } 296 | 297 | public Op(Operation op, ScratchValue arg) 298 | { 299 | Operation = op; 300 | Arg = arg; 301 | } 302 | 303 | public Operation Operation { get; } 304 | public ScratchValue Arg { get; } 305 | public string ArgAsString => Arg.StringValue; 306 | 307 | public override string ToString() 308 | { 309 | return $"{Operation} {Arg}"; 310 | } 311 | } 312 | 313 | private List _ops; 314 | 315 | public struct Label 316 | { 317 | public Label(string value) 318 | { 319 | Value = value; 320 | } 321 | 322 | public string Value { get; } 323 | } 324 | 325 | public class Writer 326 | { 327 | Dictionary _labels = new Dictionary(); 328 | List _ops = new List(); 329 | List _fixups = new List(); 330 | List _loops = new List(); 331 | 332 | public class Loop 333 | { 334 | public Label Break; 335 | public Label Continue; 336 | } 337 | 338 | public Label NewLabel(string prefix) 339 | { 340 | string result = $"{prefix}{_labels.Count}"; 341 | _labels[result] = -1; 342 | return new Label(result); 343 | } 344 | 345 | public void ResolveLabel(Label label) 346 | { 347 | _labels[label.Value] = _ops.Count; 348 | } 349 | 350 | public void EnterLoop(Label breakLabel, Label continueLabel) 351 | { 352 | _loops.Add(new Loop() { Break = breakLabel, Continue = continueLabel }); 353 | } 354 | 355 | public void ExitLoop() 356 | { 357 | _loops.RemoveAt(_loops.Count - 1); 358 | } 359 | 360 | public Loop CurrentLoop 361 | { 362 | get 363 | { 364 | if (_loops.Count == 0) 365 | return null; 366 | return _loops[_loops.Count - 1]; 367 | } 368 | } 369 | 370 | public bool LastOpIsRet 371 | { 372 | get 373 | { 374 | if (_ops.Count == 0) 375 | return false; 376 | return _ops[_ops.Count - 1].Operation == Operation.Ret; 377 | } 378 | } 379 | 380 | public void AddOp(Operation op) 381 | { 382 | _ops.Add(new Op(op)); 383 | } 384 | 385 | public void AddOpWithLabel(Operation op, Label label) 386 | { 387 | _fixups.Add(_ops.Count); 388 | _ops.Add(new Op(op, new ScratchValue(label.Value))); 389 | } 390 | 391 | public void AddOp(Operation op, ScratchValue value) 392 | { 393 | _ops.Add(new Op(op, value)); 394 | } 395 | 396 | public ScratchProgram ToProgram() 397 | { 398 | foreach (int fixup in _fixups) 399 | { 400 | string label = _ops[fixup].ArgAsString; 401 | int loc = _labels[label]; 402 | if (loc == -1) 403 | throw new Exception("Label not resolved: " + label); 404 | _ops[fixup] = new Op(_ops[fixup].Operation, new ScratchValue(loc)); 405 | } 406 | ScratchProgram result = new ScratchProgram(_ops); 407 | _ops = null; 408 | return result; 409 | } 410 | 411 | public override string ToString() 412 | { 413 | StringBuilder result = new StringBuilder(); 414 | Dictionary> labels = new Dictionary>(); 415 | foreach (var entry in _labels) 416 | if (labels.TryGetValue(entry.Value, out var names)) 417 | names.Add(entry.Key); 418 | else 419 | labels.Add(entry.Value, new List() { entry.Key }); 420 | for (int i = 0; i < _ops.Count; ++i) 421 | { 422 | if (labels.TryGetValue(i, out var names)) 423 | foreach (string name in names) 424 | result.AppendLine($"{name}:"); 425 | result.AppendLine($" {_ops[i]}"); 426 | } 427 | return result.ToString(); 428 | } 429 | } 430 | 431 | public static ScratchProgram WithWriter(Action w) 432 | { 433 | Writer writer = new Writer(); 434 | w(writer); 435 | return writer.ToProgram(); 436 | } 437 | 438 | private ScratchProgram(List ops) 439 | { 440 | _ops = ops; 441 | } 442 | 443 | private ScratchValue Pop(List stack) 444 | { 445 | ScratchValue result = stack[stack.Count - 1]; 446 | stack.RemoveAt(stack.Count - 1); 447 | return result; 448 | } 449 | 450 | private ScratchValue Peek(List stack) 451 | { 452 | return stack[stack.Count - 1]; 453 | } 454 | 455 | private List PopArgList(List stack) 456 | { 457 | int count = Pop(stack).Int32Value; 458 | var args = new List(); 459 | for (int i = 0; i < count; ++i) 460 | args.Add(Pop(stack)); 461 | // Args are pushed left to right so they pop off from right to left 462 | args.Reverse(); 463 | return args; 464 | } 465 | 466 | public ScratchValue Run(ExecutionContext context) 467 | { 468 | var stack = new List(); 469 | int ip = 0; 470 | 471 | while (ip < _ops.Count) 472 | { 473 | int cp = ip++; 474 | if (DebugStack) 475 | { 476 | Log.Out($" stack: {string.Join(", ", stack)}"); 477 | Log.Out(_ops[cp].ToString()); 478 | } 479 | switch (_ops[cp].Operation) 480 | { 481 | case Operation.Push: 482 | stack.Add(_ops[cp].Arg); 483 | break; 484 | 485 | case Operation.Pop: 486 | Pop(stack); 487 | break; 488 | 489 | case Operation.Get: 490 | stack.Add(context.Scope.Lookup(_ops[cp].ArgAsString)); 491 | break; 492 | 493 | case Operation.Ret: 494 | context.AddBackEdge(); 495 | return Pop(stack); 496 | 497 | case Operation.Jump: 498 | ip = _ops[cp].Arg.Int32Value; 499 | break; 500 | 501 | case Operation.JumpIfNull: 502 | if (Pop(stack).IsFalse) 503 | ip = _ops[cp].Arg.Int32Value; 504 | break; 505 | 506 | case Operation.JumpIfNotNull: 507 | if (Pop(stack).IsTrue) 508 | ip = _ops[cp].Arg.Int32Value; 509 | break; 510 | 511 | case Operation.Set: 512 | context.Scope.Assign(_ops[cp].ArgAsString, Peek(stack)); 513 | break; 514 | 515 | case Operation.SetLocal: 516 | context.Scope.AssignLocal(_ops[cp].ArgAsString, Peek(stack)); 517 | break; 518 | 519 | case Operation.Call: 520 | stack.Add(context.Scope.Lookup(_ops[cp].ArgAsString) 521 | .Invoke(_ops[cp].ArgAsString, context, PopArgList(stack))); 522 | break; 523 | 524 | case Operation.Not: 525 | if (Pop(stack).IsFalse) 526 | stack.Add(ScratchValue.True); 527 | else 528 | stack.Add(ScratchValue.False); 529 | break; 530 | 531 | case Operation.Dup: 532 | stack.Add(Peek(stack)); 533 | break; 534 | } 535 | if (ip <= cp) 536 | context.AddBackEdge(); 537 | } 538 | 539 | context.AddBackEdge(); 540 | return ScratchValue.Null; 541 | } 542 | 543 | public override string ToString() 544 | { 545 | return string.Join("; ", _ops); 546 | } 547 | } 548 | 549 | public class ConfigFileLibrary : ScratchLibraryBase 550 | { 551 | private ConfigFileLibrary(string name) : base(name) 552 | { 553 | } 554 | 555 | // Additively load bindings from text. 556 | public static ConfigFileLibrary Load(bool debugBinding, string name, string source) 557 | { 558 | // TODO: consider error handling 559 | // TODO: extend ScratchValue with basic syntax tree, mainly for easier introspection of bindings 560 | // TODO: accept identifier syntax in more contexts if not ambiguous 561 | 562 | /* 563 | Grammar: 564 | file ::= { setting } . 565 | setting ::= ( | ) '=' ( literal | ) ; 566 | literal ::= | | block | 'nil' ; 567 | block ::= '{' [paramList] exprList '}' ; 568 | paramList ::= '|' [ { ',' } ] '|' ; 569 | expr ::= if | orExpr | return | while | 'break' | 'continue' ; 570 | while ::= 'while' orExpr '{' exprList '}' ; 571 | return ::= 'return' [ '(' expr ')' ] ; 572 | orExpr ::= andExpr { '||' andExpr } ; 573 | andExpr ::= factor { '&&' factor } ; 574 | factor ::= [ 'not' ] callOrAssign | literal | '(' expr ')' ; 575 | callOrAssign ::= 576 | ( ['(' [ expr { ',' expr } ] ')'] 577 | | '=' expr 578 | | ':=' expr 579 | | 580 | ) ; 581 | exprList = { expr } ; 582 | if ::= 583 | 'if' orExpr '{' exprList '}' 584 | [ 'else' (if | '{' exprList '}') ] 585 | ; 586 | */ 587 | 588 | ConfigFileLibrary result = new ConfigFileLibrary(name); 589 | 590 | try 591 | { 592 | ScopeLexer lexer = new ScopeLexer(source); 593 | 594 | // skip first line (title) 595 | lexer.SkipPastEol(); 596 | lexer.NextToken(); 597 | 598 | while (lexer.CurrToken != ScopeToken.Eof) 599 | { 600 | // setting ::= ( | ) '=' ( literal | ) ; 601 | lexer.ExpectEither(ScopeToken.Ident, ScopeToken.String); 602 | string ident = lexer.StringValue; 603 | lexer.NextToken(); 604 | lexer.Eat(ScopeToken.Eq); 605 | var value = ParseSettingValue(lexer); 606 | result.Bindings.Add(ident, value); 607 | if (debugBinding) 608 | Log.Out($"Bound {ident} to {value}"); 609 | } 610 | 611 | return result; 612 | } 613 | catch (Exception ex) 614 | { 615 | throw new ArgumentException($"{name}: {ex.Message}", ex); 616 | } 617 | } 618 | 619 | private static ScratchValue ParseSettingValue(ScopeLexer lexer) 620 | { 621 | if (lexer.CurrToken == ScopeToken.Ident) 622 | { 623 | var result = new ScratchValue(lexer.StringValue); 624 | lexer.NextToken(); 625 | return result; 626 | } 627 | return ParseLiteral(lexer); 628 | } 629 | 630 | private static ScratchValue ParseLiteral(ScopeLexer lexer) 631 | { 632 | ScratchValue result; 633 | // literal ::= | | block | 'nil' ; 634 | switch (lexer.CurrToken) 635 | { 636 | case ScopeToken.String: 637 | result = new ScratchValue(lexer.StringValue); 638 | lexer.NextToken(); 639 | return result; 640 | 641 | case ScopeToken.Int32: 642 | result = new ScratchValue(lexer.Int32Value); 643 | lexer.NextToken(); 644 | return result; 645 | 646 | case ScopeToken.Nil: 647 | result = ScratchValue.Null; 648 | lexer.NextToken(); 649 | return result; 650 | 651 | case ScopeToken.LBrace: 652 | return new ScratchValue(CompileBlock(lexer)); 653 | } 654 | 655 | throw lexer.Error($"Expected: string, int or {{, got {lexer.CurrToken}"); 656 | } 657 | 658 | private static ScratchFunction CompileBlock(ScopeLexer lexer) 659 | { 660 | var w = new ScratchProgram.Writer(); 661 | // block ::= '{' [paramList] exprList '}' ; 662 | lexer.Eat(ScopeToken.LBrace); 663 | 664 | // paramList ::= '|' { } '|' ; 665 | var paramList = new List(); 666 | if (lexer.CurrToken == ScopeToken.Bar) 667 | { 668 | lexer.NextToken(); 669 | while (lexer.IsNot(ScopeToken.Eof, ScopeToken.Bar)) 670 | { 671 | lexer.Expect(ScopeToken.Ident); 672 | paramList.Add(lexer.StringValue); 673 | lexer.NextToken(); 674 | if (lexer.CurrToken == ScopeToken.Comma) 675 | lexer.NextToken(); 676 | else 677 | break; 678 | } 679 | lexer.Eat(ScopeToken.Bar); 680 | } 681 | 682 | CompileExprList(w, lexer); 683 | 684 | if (!w.LastOpIsRet) 685 | w.AddOp(ScratchProgram.Operation.Ret); 686 | return new ScratchFunction(w.ToProgram(), paramList); 687 | } 688 | 689 | private static void CompileExprList(ScratchProgram.Writer w, ScopeLexer lexer, 690 | ScopeToken stopToken = ScopeToken.RBrace) 691 | { 692 | // Invariant: we enter without a value on the stack, and we always leave with one. 693 | bool prevRetValOnStack = false; 694 | while (lexer.IsNot(ScopeToken.Eof, stopToken)) 695 | { 696 | if (prevRetValOnStack) 697 | w.AddOp(ScratchProgram.Operation.Pop); 698 | CompileExpr(w, lexer); 699 | prevRetValOnStack = true; 700 | } 701 | lexer.Eat(stopToken); 702 | if (!prevRetValOnStack) 703 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 704 | } 705 | 706 | private static void CompileExpr(ScratchProgram.Writer w, ScopeLexer lexer) 707 | { 708 | // expr ::= if | orExpr | return | while | 'break' | 'continue' ; 709 | switch (lexer.CurrToken) 710 | { 711 | case ScopeToken.Break: 712 | { 713 | lexer.NextToken(); 714 | var loop = w.CurrentLoop; 715 | if (loop == null) 716 | throw lexer.Error("break used outside loop"); 717 | // TODO: consider taking arg like return, result of broken loop 718 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 719 | w.AddOpWithLabel(ScratchProgram.Operation.Jump, loop.Break); 720 | break; 721 | } 722 | 723 | case ScopeToken.Continue: 724 | { 725 | lexer.NextToken(); 726 | var loop = w.CurrentLoop; 727 | if (loop == null) 728 | throw lexer.Error("continue used outside loop"); 729 | // TODO: consider taking arg like return, result of continued loop that fails predicate 730 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 731 | w.AddOpWithLabel(ScratchProgram.Operation.Jump, loop.Continue); 732 | break; 733 | } 734 | 735 | case ScopeToken.If: 736 | CompileIf(w, lexer); 737 | break; 738 | 739 | case ScopeToken.While: 740 | CompileWhile(w, lexer); 741 | break; 742 | 743 | case ScopeToken.Return: 744 | // return ::= 'return' [ '(' orExpr ')' ] ; 745 | int startLinum = lexer.LineNum; 746 | lexer.NextToken(); 747 | if (AcceptControlFlowArgument(lexer, startLinum)) 748 | CompileOrExpr(w, lexer); 749 | else 750 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 751 | w.AddOp(ScratchProgram.Operation.Ret); 752 | break; 753 | 754 | default: 755 | CompileOrExpr(w, lexer); 756 | break; 757 | } 758 | } 759 | 760 | private static bool AcceptControlFlowArgument(ScopeLexer lexer, int startLinum) 761 | { 762 | switch (lexer.CurrToken) 763 | { 764 | case ScopeToken.RBrace: 765 | return false; 766 | 767 | case ScopeToken.LParen: 768 | return true; 769 | 770 | default: 771 | return lexer.LineNum == startLinum; 772 | } 773 | } 774 | 775 | private static void CompileOrExpr(ScratchProgram.Writer w, ScopeLexer lexer) 776 | { 777 | // orExpr ::= andExpr { '||' andExpr } ; 778 | CompileAndExpr(w, lexer); 779 | var ifTrue = w.NewLabel("ifTrue"); 780 | bool needLabel = false; 781 | while (lexer.SkipIf(ScopeToken.Or)) 782 | { 783 | w.AddOp(ScratchProgram.Operation.Dup); 784 | // short circuit || 785 | w.AddOpWithLabel(ScratchProgram.Operation.JumpIfNotNull, ifTrue); 786 | needLabel = true; 787 | w.AddOp(ScratchProgram.Operation.Pop); 788 | CompileAndExpr(w, lexer); 789 | } 790 | if (needLabel) 791 | w.ResolveLabel(ifTrue); 792 | } 793 | 794 | private static void CompileAndExpr(ScratchProgram.Writer w, ScopeLexer lexer) 795 | { 796 | // andExpr ::= factor { '&&' factor } ; 797 | CompileFactor(w, lexer); 798 | // These labels can get really verbose when dumping opcodes 799 | var ifFalse = w.NewLabel("ifFalse"); 800 | bool needLabel = false; 801 | while (lexer.SkipIf(ScopeToken.And)) 802 | { 803 | w.AddOp(ScratchProgram.Operation.Dup); 804 | // short circuit && 805 | w.AddOpWithLabel(ScratchProgram.Operation.JumpIfNull, ifFalse); 806 | needLabel = true; 807 | w.AddOp(ScratchProgram.Operation.Pop); 808 | CompileFactor(w, lexer); 809 | } 810 | if (needLabel) 811 | w.ResolveLabel(ifFalse); 812 | } 813 | 814 | private static void CompileFactor(ScratchProgram.Writer w, ScopeLexer lexer) 815 | { 816 | // factor ::= [ '!' ] callOrAssign | literal | '(' expr ')' ; 817 | bool not = lexer.SkipIf(ScopeToken.Not); 818 | 819 | switch (lexer.CurrToken) 820 | { 821 | case ScopeToken.Ident: 822 | CompileCallOrAssign(w, lexer); 823 | break; 824 | 825 | case ScopeToken.LParen: 826 | lexer.NextToken(); 827 | CompileExpr(w, lexer); 828 | lexer.Eat(ScopeToken.RParen); 829 | break; 830 | 831 | default: 832 | w.AddOp(ScratchProgram.Operation.Push, ParseLiteral(lexer)); 833 | break; 834 | } 835 | 836 | if (not) 837 | w.AddOp(ScratchProgram.Operation.Not); 838 | } 839 | 840 | private static void CompileCallOrAssign(ScratchProgram.Writer w, ScopeLexer lexer) 841 | { 842 | // callOrAssign ::= 843 | // ( ['(' { expr } ')'] // invoke binding 844 | // | '=' expr // assign to binding, wherever found 845 | // | ':=' expr // assign to local binding 846 | // | // fetch value of binding 847 | // ) ; 848 | 849 | string name = lexer.StringValue; 850 | lexer.NextToken(); 851 | 852 | switch (lexer.CurrToken) 853 | { 854 | case ScopeToken.Eq: 855 | lexer.NextToken(); 856 | CompileExpr(w, lexer); 857 | w.AddOp(ScratchProgram.Operation.Set, new ScratchValue(name)); 858 | break; 859 | 860 | case ScopeToken.Assign: 861 | lexer.NextToken(); 862 | CompileExpr(w, lexer); 863 | w.AddOp(ScratchProgram.Operation.SetLocal, new ScratchValue(name)); 864 | break; 865 | 866 | case ScopeToken.LParen: 867 | lexer.NextToken(); 868 | int argCount = 0; 869 | while (lexer.IsNot(ScopeToken.Eof, ScopeToken.RParen)) 870 | { 871 | ++argCount; 872 | CompileExpr(w, lexer); 873 | if (lexer.CurrToken == ScopeToken.Comma) 874 | lexer.NextToken(); 875 | else 876 | break; 877 | } 878 | lexer.Eat(ScopeToken.RParen); 879 | w.AddOp(ScratchProgram.Operation.Push, new ScratchValue(argCount)); 880 | w.AddOp(ScratchProgram.Operation.Call, new ScratchValue(name)); 881 | break; 882 | 883 | default: 884 | w.AddOp(ScratchProgram.Operation.Get, new ScratchValue(name)); 885 | break; 886 | } 887 | } 888 | 889 | private static void CompileWhile(ScratchProgram.Writer w, ScopeLexer lexer) 890 | { 891 | // while ::= 'while' orExpr '{' exprList '}'; 892 | lexer.NextToken(); 893 | var topOfLoop = w.NewLabel("top"); 894 | var pastLoop = w.NewLabel("pastLoop"); 895 | // this is 'result' of loop if we never execute body 896 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 897 | // we have at least a null on the stack 898 | w.ResolveLabel(topOfLoop); 899 | CompileOrExpr(w, lexer); 900 | w.AddOpWithLabel(ScratchProgram.Operation.JumpIfNull, pastLoop); 901 | // pop off either the null above, or the result of previous iteration 902 | w.AddOp(ScratchProgram.Operation.Pop); 903 | lexer.Eat(ScopeToken.LBrace); 904 | w.EnterLoop(pastLoop, topOfLoop); 905 | // We have nothing on the stack 906 | CompileExprList(w, lexer); 907 | // we have at least a null on the stack 908 | w.ExitLoop(); 909 | w.AddOpWithLabel(ScratchProgram.Operation.Jump, topOfLoop); 910 | w.ResolveLabel(pastLoop); 911 | // top of stack is now either null or last expr evaluated in body 912 | } 913 | 914 | private static void CompileIf(ScratchProgram.Writer w, ScopeLexer lexer) 915 | { 916 | /* 917 | if ::= 918 | 'if' orExpr '{' exprList '}' 919 | [ 'else' (if | '{' exprList '}') ] 920 | ; 921 | */ 922 | var elseCase = w.NewLabel("else"); 923 | var afterIf = w.NewLabel("afterIf"); 924 | lexer.NextToken(); 925 | CompileOrExpr(w, lexer); 926 | lexer.Eat(ScopeToken.LBrace); 927 | w.AddOpWithLabel(ScratchProgram.Operation.JumpIfNull, elseCase); 928 | CompileExprList(w, lexer); 929 | w.AddOpWithLabel(ScratchProgram.Operation.Jump, afterIf); 930 | while (true) 931 | { 932 | w.ResolveLabel(elseCase); 933 | if (lexer.CurrToken == ScopeToken.Else) 934 | { 935 | lexer.NextToken(); 936 | 937 | if (lexer.CurrToken == ScopeToken.If) 938 | { 939 | elseCase = w.NewLabel("else"); 940 | lexer.NextToken(); 941 | CompileExpr(w, lexer); 942 | lexer.Eat(ScopeToken.LBrace); 943 | w.AddOpWithLabel(ScratchProgram.Operation.JumpIfNull, elseCase); 944 | CompileExprList(w, lexer); 945 | w.AddOpWithLabel(ScratchProgram.Operation.Jump, afterIf); 946 | } 947 | else 948 | { 949 | lexer.Eat(ScopeToken.LBrace); 950 | CompileExprList(w, lexer); 951 | break; 952 | } 953 | } 954 | else 955 | { 956 | // we need to have a value from the else branch even if it's missing 957 | w.AddOp(ScratchProgram.Operation.Push, ScratchValue.Null); 958 | break; 959 | } 960 | } 961 | w.ResolveLabel(afterIf); 962 | } 963 | } 964 | 965 | enum ScopeToken 966 | { 967 | Eof, 968 | String, 969 | Ident, 970 | Int32, 971 | Eq, 972 | Comma, 973 | LParen, 974 | RParen, 975 | LBrace, 976 | RBrace, 977 | Bar, 978 | Assign, 979 | If, 980 | Else, 981 | And, 982 | Or, 983 | Not, 984 | Nil, 985 | Return, 986 | While, 987 | Break, 988 | Continue, 989 | } 990 | 991 | class ScopeLexer 992 | { 993 | private static readonly Dictionary Keywords = CreateKeywordDictionary(); 994 | 995 | private int _currPos; 996 | private int _lineNum = 1; 997 | 998 | public string Source { get; } 999 | public ScopeToken CurrToken { get; private set; } 1000 | public string StringValue { get; private set; } 1001 | public int Int32Value { get; private set; } 1002 | public int LineNum => _lineNum; 1003 | 1004 | public ScopeLexer(string source) 1005 | { 1006 | Source = source; 1007 | // deliberately not NextToken() here 1008 | } 1009 | 1010 | private static Dictionary CreateKeywordDictionary() 1011 | { 1012 | var result = new Dictionary(); 1013 | result.Add("if", ScopeToken.If); 1014 | result.Add("while", ScopeToken.While); 1015 | result.Add("break", ScopeToken.Break); 1016 | result.Add("continue", ScopeToken.Continue); 1017 | result.Add("else", ScopeToken.Else); 1018 | result.Add("nil", ScopeToken.Nil); 1019 | result.Add("return", ScopeToken.Return); 1020 | return result; 1021 | } 1022 | 1023 | public bool SkipIf(ScopeToken token) 1024 | { 1025 | if (CurrToken == token) 1026 | { 1027 | NextToken(); 1028 | return true; 1029 | } 1030 | return false; 1031 | } 1032 | 1033 | public void SkipPastEol() 1034 | { 1035 | while (_currPos < Source.Length && !TrySkipEndOfLine(Source[_currPos++])) 1036 | /* loop */; 1037 | } 1038 | 1039 | private bool TrySkipEndOfLine(char ch) 1040 | { 1041 | switch (ch) 1042 | { 1043 | case '\r': 1044 | if (_currPos < Source.Length && Source[_currPos] == '\n') 1045 | ++_currPos; 1046 | ++_lineNum; 1047 | return true; 1048 | 1049 | case '\n': 1050 | if (_currPos < Source.Length && Source[_currPos] == '\r') 1051 | ++_currPos; 1052 | ++_lineNum; 1053 | return true; 1054 | } 1055 | return false; 1056 | } 1057 | 1058 | public void NextToken() 1059 | { 1060 | CurrToken = Scan(); 1061 | } 1062 | 1063 | public void Eat(ScopeToken token) 1064 | { 1065 | Expect(token); 1066 | NextToken(); 1067 | } 1068 | 1069 | public void Expect(ScopeToken token) 1070 | { 1071 | if (CurrToken != token) 1072 | throw Error($"Expected: {token}, got {CurrToken}"); 1073 | } 1074 | 1075 | public bool IsNot(params ScopeToken[] tokens) 1076 | { 1077 | return Array.IndexOf(tokens, CurrToken) < 0; 1078 | } 1079 | 1080 | public void ExpectEither(ScopeToken thisToken, ScopeToken thatToken) 1081 | { 1082 | if (CurrToken != thisToken && CurrToken != thatToken) 1083 | throw Error($"Expected: {thisToken} or {thatToken}, got {CurrToken}"); 1084 | } 1085 | 1086 | internal ArgumentException Error(string message) 1087 | { 1088 | return new ArgumentException($"Line {_lineNum}: {message}"); 1089 | } 1090 | 1091 | private string ScanString(char type) 1092 | { 1093 | int start = _currPos; 1094 | int startLine = _lineNum; 1095 | while (_currPos < Source.Length) 1096 | { 1097 | char ch = Source[_currPos++]; 1098 | 1099 | if (ch == type) 1100 | return Source.Substring(start, _currPos - start - 1); 1101 | 1102 | // we're gonna permit newlines in strings 1103 | // it'll make things easier for big blobs of text 1104 | // still need to detect them for the line numbers though 1105 | TrySkipEndOfLine(ch); 1106 | } 1107 | throw new ArgumentException($"End of file in string started on line {startLine}"); 1108 | } 1109 | 1110 | private int ScanInt32() 1111 | { 1112 | int start = _currPos - 1; 1113 | while (_currPos < Source.Length) 1114 | if (char.IsDigit(Source[_currPos])) 1115 | ++_currPos; 1116 | else 1117 | break; 1118 | return int.Parse(Source.Substring(start, _currPos - start)); 1119 | } 1120 | 1121 | private string ScanIdent() 1122 | { 1123 | // identifier syntax includes '-' 1124 | // [a-z_][A-Z0-9_-]* 1125 | int start = _currPos - 1; 1126 | while (_currPos < Source.Length) 1127 | { 1128 | char ch = Source[_currPos]; 1129 | if (char.IsLetterOrDigit(ch) || ch == '-' || ch == '_') 1130 | ++_currPos; 1131 | else 1132 | break; 1133 | } 1134 | return Source.Substring(start, _currPos - start); 1135 | } 1136 | 1137 | private bool SkipIfNextChar(char ch) 1138 | { 1139 | if (_currPos < Source.Length && Source[_currPos] == ch) 1140 | { 1141 | ++_currPos; 1142 | return true; 1143 | } 1144 | return false; 1145 | } 1146 | 1147 | private ScopeToken Scan() 1148 | { 1149 | // this value never actually used 1150 | // _currPos < Source.Length => it's a char from source 1151 | // otherwise we return early 1152 | char ch = '\0'; 1153 | 1154 | // skip whitespace 1155 | while (_currPos < Source.Length) 1156 | { 1157 | ch = Source[_currPos++]; 1158 | 1159 | if (TrySkipEndOfLine(ch) || char.IsWhiteSpace(ch)) 1160 | continue; 1161 | 1162 | switch (ch) 1163 | { 1164 | case '/': 1165 | if (!SkipIfNextChar('/')) 1166 | break; 1167 | SkipPastEol(); 1168 | continue; 1169 | 1170 | case '#': 1171 | SkipPastEol(); 1172 | continue; 1173 | } 1174 | 1175 | break; 1176 | } 1177 | 1178 | if (_currPos == Source.Length) 1179 | return ScopeToken.Eof; 1180 | 1181 | // determine token type 1182 | switch (ch) 1183 | { 1184 | case '(': 1185 | return ScopeToken.LParen; 1186 | case ')': 1187 | return ScopeToken.RParen; 1188 | case '{': 1189 | return ScopeToken.LBrace; 1190 | case '}': 1191 | return ScopeToken.RBrace; 1192 | case ',': 1193 | return ScopeToken.Comma; 1194 | case '=': 1195 | return ScopeToken.Eq; 1196 | 1197 | case '!': 1198 | return ScopeToken.Not; 1199 | 1200 | case '|': 1201 | if (SkipIfNextChar('|')) 1202 | return ScopeToken.Or; 1203 | return ScopeToken.Bar; 1204 | 1205 | case ':': 1206 | if (SkipIfNextChar('=')) 1207 | return ScopeToken.Assign; 1208 | throw Error("Unexpected ':', did you mean ':='"); 1209 | 1210 | case '&': 1211 | if (SkipIfNextChar('&')) 1212 | return ScopeToken.And; 1213 | throw Error("Unexpected '&', did you mean '&&'"); 1214 | 1215 | case '\'': 1216 | case '"': 1217 | StringValue = ScanString(ch); 1218 | return ScopeToken.String; 1219 | } 1220 | 1221 | if (char.IsDigit(ch)) 1222 | { 1223 | Int32Value = ScanInt32(); 1224 | return ScopeToken.Int32; 1225 | } 1226 | 1227 | if (char.IsLetter(ch) || ch == '_' || ch == '-') 1228 | { 1229 | StringValue = ScanIdent(); 1230 | if (Keywords.TryGetValue(StringValue, out var keyword)) 1231 | return keyword; 1232 | return ScopeToken.Ident; 1233 | } 1234 | 1235 | throw new ArgumentException($"Unexpected token character: '{ch}' on line {_lineNum}"); 1236 | } 1237 | } 1238 | } 1239 | -------------------------------------------------------------------------------- /gtk-ui/ScratchLegacy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace Barrkel.ScratchPad 9 | { 10 | public class LegacyLibrary : NativeLibrary 11 | { 12 | public static readonly LegacyLibrary Instance = new LegacyLibrary(); 13 | 14 | static readonly char[] SmartChars = { '{', '[', '(' }; 15 | static readonly char[] SmartInversion = { '}', ']', ')' }; 16 | 17 | private LegacyLibrary() : base("legacy") 18 | { 19 | // TODO: move these to config somehow 20 | // E.g. .globalconfig which can be exported from resource 21 | // "Invoking" a string looks up the binding and invokes that, recursively. 22 | // Keys are bound by binding their names. 23 | // Keys may be bound to an action / scratch function directly, 24 | // but because of the string invocation action, indirection works too. 25 | Bind("F4", new ScratchValue("insert-date")); 26 | Bind("S-F4", new ScratchValue("insert-datetime")); 27 | Bind("Return", new ScratchValue("autoindent-return")); 28 | Bind("Tab", new ScratchValue("indent-block")); 29 | Bind("S-Tab", new ScratchValue("unindent-block")); 30 | Bind("C-v", new ScratchValue("smart-paste")); 31 | Bind("M-/", new ScratchValue("complete")); 32 | Bind("C-a", new ScratchValue("goto-sol")); 33 | Bind("C-e", new ScratchValue("goto-eol")); 34 | Bind("F12", new ScratchValue("navigate-title")); 35 | Bind("F11", new ScratchValue("navigate-contents")); 36 | Bind("C-t", new ScratchValue("navigate-todo")); 37 | Bind("C-n", new ScratchValue("add-new-page")); 38 | 39 | Bind("F5", new ScratchValue("load-config")); 40 | Bind("S-F5", new ScratchValue("dump-bindings")); 41 | } 42 | 43 | [TypedAction("dump-bindings")] 44 | public void DumpBindings(ExecutionContext context, IList args) 45 | { 46 | var rootController = context.Controller.RootController; 47 | Log.Out("For root:"); 48 | foreach (var entry in rootController.RootScope) 49 | Log.Out($" {entry.Item1} => {entry.Item2}"); 50 | foreach (var book in rootController.Root.Books) 51 | { 52 | Log.Out($"For book: {book.Name}"); 53 | foreach (var entry in rootController.GetControllerFor(book).Scope) 54 | Log.Out($" {entry.Item1} => {entry.Item2}"); 55 | } 56 | } 57 | 58 | [TypedAction("get-cursor-text-re", ScratchType.String)] 59 | public ScratchValue DoGetCursorTextRe(ExecutionContext context, IList args) 60 | { 61 | // get-cursor-text-regex(regex) -> returns earliest text matching regex under cursor 62 | // Because regex won't scan backwards and we want to extract a word under cursor, 63 | // we use a hokey algorithm: 64 | // start := cursorPos 65 | // result := '' 66 | // begin loop 67 | // match regex at start 68 | // if match does not include cursor position, break 69 | // result := regex match 70 | // set start to start - 1 71 | // end loop 72 | // return result 73 | int currPos = context.View.CurrentPosition; 74 | // Regex caches compiled regexes, we expect reuse of the same extractions. 75 | Regex re = new Regex(args[0].StringValue, 76 | RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled, 77 | TimeSpan.FromSeconds(1)); 78 | string match = ""; 79 | string currentText = context.View.CurrentText; 80 | int startPos = currPos; 81 | while (startPos > 0) 82 | { 83 | Match m = re.Match(currentText, startPos); 84 | if (!m.Success) 85 | // but we allow one step back for cursor at end 86 | if (startPos == currPos) 87 | { 88 | --startPos; 89 | continue; 90 | } 91 | else 92 | break; 93 | 94 | // regex must match immediately 95 | if (m.Index != startPos) 96 | { 97 | // second chance, step back 98 | if (startPos == currPos) 99 | { 100 | --startPos; 101 | continue; 102 | } 103 | break; 104 | } 105 | // match must include cursor (cursor at end is ok though) 106 | if (m.Index + m.Length < currPos - 1) 107 | break; 108 | match = m.Value; 109 | --startPos; 110 | } 111 | return match == "" 112 | ? ScratchValue.Null 113 | : new ScratchValue(match); 114 | } 115 | 116 | [VariadicAction("dp")] 117 | public void DoDebugPrint(ExecutionContext context, IList args) 118 | { 119 | Log.Out(string.Join(" ", args)); 120 | } 121 | 122 | [VariadicAction("log")] 123 | public void DoLog(ExecutionContext context, IList args) 124 | { 125 | Log.Out(string.Join("", args.Select(x => "" + x.ObjectValue))); 126 | } 127 | 128 | [TypedAction("insert-date")] 129 | public void DoInsertDate(ExecutionContext context, IList args) 130 | { 131 | context.View.InsertText(DateTime.Today.ToString("yyyy-MM-dd")); 132 | } 133 | 134 | [TypedAction("insert-datetime")] 135 | public void DoInsertDateTime(ExecutionContext context, IList args) 136 | { 137 | context.View.InsertText(DateTime.Now.ToString("yyyy-MM-dd HH:mm")); 138 | } 139 | 140 | [TypedAction("autoindent-return")] 141 | public void DoAutoindentReturn(ExecutionContext context, IList args) 142 | { 143 | string text = context.View.CurrentText; 144 | int pos = context.View.CurrentPosition; 145 | string indent = GetCurrentIndent(text, pos); 146 | 147 | switch (IsSmartDelimiter(text, pos - 1, out char closer)) 148 | { 149 | case SmartDelimiter.No: 150 | context.View.InsertText(string.Format("\n{0}", indent)); 151 | break; 152 | 153 | case SmartDelimiter.Yes: 154 | // smart { etc. 155 | context.View.InsertText(string.Format("\n{0} \n{0}{1}", indent, closer)); 156 | context.View.CurrentPosition -= (1 + indent.Length + 1); 157 | context.View.Selection = (context.View.CurrentPosition, context.View.CurrentPosition); 158 | break; 159 | 160 | case SmartDelimiter.IndentOnly: 161 | context.View.InsertText(string.Format("\n{0} ", indent)); 162 | break; 163 | } 164 | context.View.ScrollIntoView(context.View.CurrentPosition); 165 | } 166 | 167 | [TypedAction("smart-paste")] 168 | public void DoSmartPaste(ExecutionContext context, IList args) 169 | { 170 | context.View.SelectedText = ""; 171 | string textToPaste = context.View.Clipboard; 172 | if (string.IsNullOrEmpty(textToPaste)) 173 | return; 174 | if (context.Scope.TryLookup("paste-filter", out var pasteFilter)) 175 | textToPaste = pasteFilter.Invoke("paste-filter", context, new ScratchValue(textToPaste)).StringValue; 176 | string indent = GetCurrentIndent(context.View.CurrentText, context.View.CurrentPosition); 177 | if (indent.Length > 0) 178 | // Remove existing indent if pasted to an indent 179 | context.View.InsertText(AddIndent(indent, ResetIndent(textToPaste), IndentOptions.SkipFirst)); 180 | else 181 | // Preserve existing indent if from col 0 182 | context.View.InsertText(AddIndent(indent, textToPaste, IndentOptions.SkipFirst)); 183 | context.View.ScrollIntoView(context.View.CurrentPosition); 184 | } 185 | 186 | private int GetIndent(string text) 187 | { 188 | int result = 0; 189 | foreach (char ch in text) 190 | { 191 | if (!char.IsWhiteSpace(ch)) 192 | return result; 193 | if (ch == '\t') 194 | result += (8 - result % 8); 195 | else 196 | ++result; 197 | } 198 | return result; 199 | } 200 | 201 | private const int DefaultContextLength = 40; 202 | 203 | // Given a line and a match, add prefix and postfix text as necessary, and mark up match with []. 204 | // This logic is sufficiently UI-specific that it should be factored out somehow. 205 | static string Contextualize(string line, SimpleMatch match, int contextLength = DefaultContextLength) 206 | { 207 | StringBuilder result = new StringBuilder(); 208 | // ... preamble [match] postamble ... 209 | if (match.Start > contextLength) 210 | { 211 | result.Append("..."); 212 | result.Append(line.Substring(match.Start - contextLength + 3, contextLength - 3)); 213 | } 214 | else 215 | { 216 | result.Append(line.Substring(0, match.Start)); 217 | } 218 | result.Append('[').Append(match.Value).Append(']'); 219 | if (match.End + contextLength < line.Length) 220 | { 221 | result.Append(line.Substring(match.End, contextLength - 3)); 222 | result.Append("..."); 223 | } 224 | else 225 | { 226 | result.Append(line.Substring(match.End)); 227 | } 228 | return result.ToString(); 229 | } 230 | 231 | struct SimpleMatch 232 | { 233 | public SimpleMatch(string text, Match match) 234 | { 235 | Text = text; 236 | if (!match.Success) 237 | { 238 | Start = 0; 239 | End = -1; 240 | } 241 | else 242 | { 243 | Start = match.Index; 244 | End = match.Index + match.Value.Length; 245 | } 246 | } 247 | 248 | private SimpleMatch(string text, int start, int end) 249 | { 250 | Start = start; 251 | End = end; 252 | Text = text; 253 | } 254 | 255 | public SimpleMatch Extend(SimpleMatch other) 256 | { 257 | if (!object.ReferenceEquals(Text, other.Text)) 258 | throw new ArgumentException("Extend may only be called with match over same text"); 259 | return new SimpleMatch(Text, Math.Min(Start, other.Start), Math.Max(End, other.End)); 260 | } 261 | 262 | public int Start { get; } 263 | public int End { get; } 264 | public int Length => End - Start; 265 | private string Text { get; } 266 | public string Value => Text.Substring(Start, Length); 267 | // We're not interested in 0-length matches 268 | public bool Success => Length > 0; 269 | } 270 | 271 | private static List MergeMatches(List matches) 272 | { 273 | matches.Sort((a, b) => a.Start.CompareTo(b.Start)); 274 | List result = new List(); 275 | foreach (SimpleMatch m in matches) 276 | { 277 | if (result.Count == 0 || result[result.Count - 1].End < m.Start) 278 | result.Add(m); 279 | else 280 | result[result.Count - 1] = result[result.Count - 1].Extend(m); 281 | } 282 | return result; 283 | } 284 | 285 | static List MatchRegexList(List regexes, string text) 286 | { 287 | var result = new List(); 288 | foreach (Regex regex in regexes) 289 | { 290 | var matches = regex.Matches(text); 291 | if (matches.Count == 0) 292 | return new List(); 293 | result.AddRange(matches.Cast().Select(x => new SimpleMatch(text, x))); 294 | } 295 | return MergeMatches(result); 296 | } 297 | 298 | // Given a list of (lineOffset, line) and a pattern, return UI-suitable strings with associated (offset, length) pairs. 299 | static IEnumerable<(string, (int, int))> FindMatchingLocations(List<(int, string)> lines, List regexes) 300 | { 301 | int count = 0; 302 | int linum = 0; 303 | // We cap at 1000 just in case caller doesn't limit us 304 | while (count < 1000 && linum < lines.Count) 305 | { 306 | var (lineOfs, line) = lines[linum]; 307 | ++linum; 308 | 309 | // Default case: empty pattern. Special case this one, we don't need to split every character. 310 | if (regexes.Count == 0 || regexes[0].IsMatch("")) 311 | { 312 | ++count; 313 | yield return (line, (lineOfs, 0)); 314 | continue; 315 | } 316 | 317 | var matches = MatchRegexList(regexes, line); 318 | if (matches.Count == 0) 319 | { 320 | continue; 321 | } 322 | count += matches.Count; 323 | // if multiple matches in a line, break them out 324 | // if a single match, keep as is 325 | string prefix = ""; 326 | if (matches.Count > 1) 327 | { 328 | prefix = " "; 329 | yield return (line, (lineOfs + matches[0].Start, matches[0].Length)); 330 | } 331 | foreach (SimpleMatch match in matches) 332 | { 333 | yield return (prefix + Contextualize(line, match), (lineOfs + match.Start, match.Length)); 334 | } 335 | } 336 | } 337 | 338 | // Returns (lineOffset, line) without trailing line separators. 339 | // lineOffset is the character offset of the start of the line in text. 340 | static IEnumerable<(int, string)> GetNonEmptyLines(string text) 341 | { 342 | int pos = 0; 343 | while (pos < text.Length) 344 | { 345 | char ch = text[pos]; 346 | int start = pos; 347 | ++pos; 348 | if (ch == '\n' || ch == '\r' || pos == text.Length) 349 | continue; 350 | 351 | while (pos < text.Length) 352 | { 353 | ch = text[pos]; 354 | ++pos; 355 | if (ch == '\n' || ch == '\r') 356 | break; 357 | } 358 | yield return (start, text.Substring(start, pos - start - 1)); 359 | } 360 | } 361 | 362 | private bool AnyCaps(string value) 363 | { 364 | foreach (char ch in value) 365 | if (char.IsUpper(ch)) 366 | return true; 367 | return false; 368 | } 369 | 370 | public List ParseRegexList(string pattern, RegexOptions options) 371 | { 372 | if (!AnyCaps(pattern)) 373 | options |= RegexOptions.IgnoreCase; 374 | 375 | Regex parsePart(string part) 376 | { 377 | if (part.StartsWith("*")) 378 | part = "\\" + part; 379 | try 380 | { 381 | return new Regex(part, options); 382 | } 383 | catch (ArgumentException) 384 | { 385 | return new Regex(Regex.Escape(part), options); 386 | } 387 | } 388 | 389 | return pattern.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) 390 | .Select(x => parsePart(x)) 391 | .ToList(); 392 | } 393 | 394 | [TypedAction("navigate-title")] 395 | public void NavigateTitle(ExecutionContext context, IList args) 396 | { 397 | context.View.EnsureSaved(); 398 | if (context.View.RunSearch(text => context.View.Book.SearchTitles(text).Take(100), out int found)) 399 | context.View.JumpToPage(found); 400 | } 401 | 402 | [TypedAction("navigate-contents")] 403 | public void NavigateContents(ExecutionContext context, IList args) 404 | { 405 | context.View.EnsureSaved(); 406 | if (context.View.RunSearch(text => TrySearch(context.View.Book, text).Take(50), out var triple)) 407 | { 408 | var (page, pos, len) = triple; 409 | context.View.JumpToPage(page); 410 | // these should probably be part of JumpToPage, to avoid the default action 411 | context.View.ScrollIntoView(pos); 412 | context.View.Selection = (pos, pos + len); 413 | } 414 | } 415 | 416 | [TypedAction("navigate-todo")] 417 | public void NavigateTodo(ExecutionContext context, IList args) 418 | { 419 | NavigateSigil(context, ScratchValue.List("=>")); 420 | } 421 | 422 | [TypedAction("add-new-page")] 423 | public void AddNewPage(ExecutionContext context, IList args) 424 | { 425 | context.View.AddNewPage(); 426 | } 427 | 428 | [TypedAction("on-text-changed")] 429 | public void OnTextChanged(ExecutionContext context, IList args) 430 | { 431 | // text has changed 432 | } 433 | 434 | [TypedAction("indent-block")] 435 | public void DoIndentBlock(ExecutionContext context, IList args) 436 | { 437 | string text = context.View.SelectedText; 438 | if (string.IsNullOrEmpty(text)) 439 | { 440 | context.View.InsertText(" "); 441 | return; 442 | } 443 | context.View.SelectedText = AddIndent(" ", text, IndentOptions.SkipTrailingEmpty); 444 | } 445 | 446 | [TypedAction("unindent-block")] 447 | public void DoUnindentBlock(ExecutionContext context, IList args) 448 | { 449 | // remove 2 spaces or a tab or one space from every line 450 | string text = context.View.SelectedText; 451 | if (string.IsNullOrEmpty(text)) 452 | { 453 | // We insert a literal tab, but we could consider unindenting line. 454 | context.View.InsertText("\t"); 455 | return; 456 | } 457 | string[] lines = text.Split('\r', '\n'); 458 | for (int i = 0; i < lines.Length; ++i) 459 | { 460 | string line = lines[i]; 461 | if (line.Length == 0) 462 | continue; 463 | if (line.StartsWith(" ")) 464 | lines[i] = line.Substring(2); 465 | else if (line.StartsWith("\t")) 466 | lines[i] = line.Substring(1); 467 | else if (line.StartsWith(" ")) 468 | lines[i] = line.Substring(1); 469 | } 470 | context.View.SelectedText = string.Join("\n", lines); 471 | } 472 | 473 | [TypedAction("exit-page")] 474 | public void ExitPage(ExecutionContext context, IList args) 475 | { 476 | ScratchPage page = GetPage(context.View.Book, context.View.CurrentPageIndex); 477 | if (page == null) 478 | return; 479 | PageViewState state = page.GetViewState(context.View, PageViewState.Create); 480 | state.CurrentSelection = context.View.Selection; 481 | state.CurrentScrollPos = context.View.ScrollPos; 482 | } 483 | 484 | [TypedAction("enter-page")] 485 | public void EnterPage(ExecutionContext context, IList args) 486 | { 487 | ScratchPage page = GetPage(context.View.Book, context.View.CurrentPageIndex); 488 | if (page == null) 489 | return; 490 | PageViewState state = page.GetViewState(context.View, PageViewState.Create); 491 | if (state.CurrentSelection.HasValue) 492 | context.View.Selection = state.CurrentSelection.Value; 493 | if (state.CurrentScrollPos.HasValue) 494 | context.View.ScrollPos = state.CurrentScrollPos.Value; 495 | } 496 | 497 | [Flags] 498 | enum SearchOptions 499 | { 500 | None = 0, 501 | TitleLinkToFirstResult = 1 502 | } 503 | 504 | // returns (UI line, (page, pos, len)) 505 | private IEnumerable<(string, (int, int, int))> TrySearch(ScratchBook book, string pattern, 506 | SearchOptions options = SearchOptions.None) 507 | { 508 | List re, fullRe; 509 | try 510 | { 511 | re = ParseRegexList(pattern, RegexOptions.Singleline); 512 | fullRe = ParseRegexList(pattern, RegexOptions.Multiline); 513 | } 514 | catch (ArgumentException) 515 | { 516 | yield break; 517 | } 518 | 519 | // Keep most recent pagesfirst 520 | for (int i = book.Pages.Count - 1; i >= 0; --i) 521 | { 522 | var page = book.Pages[i]; 523 | if (fullRe.Count > 0 && !fullRe[0].Match(page.Text).Success) 524 | continue; 525 | 526 | if (pattern.Length == 0) 527 | { 528 | yield return (page.Title, (i, 0, 0)); 529 | continue; 530 | } 531 | 532 | var lines = GetNonEmptyLines(page.Text).ToList(); 533 | bool isFirst = true; 534 | foreach (var match in FindMatchingLocations(lines, re)) 535 | { 536 | var (uiLine, (pos, len)) = match; 537 | if (isFirst) 538 | { 539 | if (options.HasFlag(SearchOptions.TitleLinkToFirstResult)) 540 | { 541 | yield return (page.Title, (i, pos, len)); 542 | } 543 | else 544 | { 545 | yield return (page.Title, (i, 0, 0)); 546 | } 547 | isFirst = false; 548 | } 549 | yield return (" " + uiLine, (i, pos, len)); 550 | } 551 | } 552 | } 553 | 554 | // starting at position-1, keep going backwards until test fails 555 | private string GetStringBackwards(string text, int position, Predicate test) 556 | { 557 | if (position == 0) 558 | return ""; 559 | if (position > text.Length) 560 | position = text.Length; 561 | int start = position - 1; 562 | while (start >= 0 && start < text.Length && test(text[start])) 563 | --start; 564 | if (position - start == 0) 565 | return ""; 566 | return text.Substring(start + 1, position - start - 1); 567 | } 568 | 569 | private void GetTitleCompletions(ScratchBook book, Predicate test, Action add) 570 | { 571 | book.TitleCache.EnumerateValues(value => GetTextCompletions(value, test, add)); 572 | } 573 | 574 | private void GetTextCompletions(string text, Predicate test, Action add) 575 | { 576 | int start = -1; 577 | for (int i = 0; i < text.Length; ++i) 578 | { 579 | if (test(text[i])) 580 | { 581 | if (start < 0) 582 | start = i; 583 | } 584 | else if (start >= 0) 585 | { 586 | add(text.Substring(start, i - start)); 587 | start = -1; 588 | } 589 | } 590 | if (start > 0) 591 | add(text.Substring(start, text.Length - start)); 592 | } 593 | 594 | // TODO: consider getting completions in a different order; e.g. working backwards from a position 595 | private List GetCompletions(ScratchBook book, string text, Predicate test) 596 | { 597 | var unique = new HashSet(); 598 | var result = new List(); 599 | void add(string candidate) 600 | { 601 | if (!unique.Contains(candidate)) 602 | { 603 | unique.Add(candidate); 604 | result.Add(candidate); 605 | } 606 | } 607 | 608 | GetTextCompletions(text, test, add); 609 | GetTitleCompletions(book, test, add); 610 | 611 | if (((ScratchScope)book.Scope).GetOrDefault("debug-completion", false)) 612 | result.ForEach(Log.Out); 613 | return result; 614 | } 615 | 616 | [TypedAction("check-for-save")] 617 | internal ScratchValue CheckForSave(ExecutionContext context, IList args) 618 | { 619 | // ... 620 | return ScratchValue.Null; 621 | } 622 | 623 | [TypedAction("complete")] 624 | public void CompleteAtPoint(ExecutionContext context, IList args) 625 | { 626 | // Emacs-style complete-at-point 627 | // foo| -> find symbols starting with foo and complete first found (e.g. 'bar') 628 | // foo[bar]| -> after completing [bar], find symbols starting with foo and complete first after 'bar' 629 | // If cursor isn't exactly at the end of a completion, we don't resume; we try from scratch. 630 | // Completion symbols come from all words ([A-Za-z0-9_-]+) in the document. 631 | var page = GetPage(context.View.Book, context.View.CurrentPageIndex); 632 | if (page == null) 633 | return; 634 | string text = context.View.CurrentText; 635 | var state = page.GetViewState(context.View, PageViewState.Create); 636 | var (currentStart, currentEnd) = state.CurrentCompletion.GetValueOrDefault(); 637 | var currentPos = context.View.CurrentPosition; 638 | string prefix, suffix; 639 | if (currentStart < currentEnd && currentPos == currentEnd) 640 | { 641 | prefix = GetStringBackwards(text, currentStart, char.IsLetterOrDigit); 642 | suffix = text.Substring(currentStart, currentEnd - currentStart); 643 | } 644 | else 645 | { 646 | prefix = GetStringBackwards(text, currentPos, char.IsLetterOrDigit); 647 | suffix = ""; 648 | currentStart = currentPos; 649 | } 650 | List completions = GetCompletions(context.View.Book, text, char.IsLetterOrDigit); 651 | 652 | int currentIndex = completions.IndexOf(prefix + suffix); 653 | if (currentIndex == -1) 654 | return; 655 | 656 | // find the next completion 657 | string nextSuffix = ""; 658 | for (int i = (currentIndex + 1) % completions.Count; i != currentIndex; i = (i + 1) % completions.Count) 659 | if (completions[i].StartsWith(prefix)) 660 | { 661 | nextSuffix = completions[i].Substring(prefix.Length); 662 | break; 663 | } 664 | if (suffix.Length > 0) 665 | context.View.DeleteTextBackwards(suffix.Length); 666 | context.View.InsertText(nextSuffix); 667 | state.CurrentCompletion = (currentStart, currentStart + nextSuffix.Length); 668 | } 669 | 670 | private ScratchPage GetPage(ScratchBook book, int index) 671 | { 672 | if (index < 0 || index >= book.Pages.Count) 673 | return null; 674 | return book.Pages[index]; 675 | } 676 | 677 | [TypedAction("navigate-sigil", ScratchType.String)] 678 | public void NavigateSigil(ExecutionContext context, IList args) 679 | { 680 | string sigil = args[0].StringValue; 681 | context.View.EnsureSaved(); 682 | if (context.View.RunSearch(text => TrySearch(context.View.Book, $"^\\s*{Regex.Escape(sigil)}.*{text}", 683 | SearchOptions.TitleLinkToFirstResult).Take(50), out var triple)) 684 | { 685 | var (page, pos, len) = triple; 686 | context.View.JumpToPage(page); 687 | // these should probably be part of JumpToPage, to avoid the default action 688 | context.View.ScrollIntoView(pos); 689 | context.View.Selection = (pos, pos + len); 690 | } 691 | } 692 | 693 | private string ResetIndent(string text) 694 | { 695 | string[] lines = text.Split('\r', '\n'); 696 | int minIndent = int.MaxValue; 697 | // TODO: make this tab-aware 698 | foreach (string line in lines) 699 | { 700 | int indent = GetWhitespace(line, 0, line.Length).Length; 701 | // don't count empty lines, or lines with only whitespace 702 | if (indent == line.Length) 703 | continue; 704 | minIndent = Math.Min(minIndent, indent); 705 | } 706 | if (minIndent == 0) 707 | return text; 708 | for (int i = 0; i < lines.Length; ++i) 709 | if (minIndent >= lines[i].Length) 710 | lines[i] = ""; 711 | else 712 | lines[i] = lines[i].Substring(minIndent); 713 | return string.Join("\n", lines); 714 | } 715 | 716 | enum IndentOptions 717 | { 718 | None, 719 | SkipFirst, 720 | SkipTrailingEmpty 721 | } 722 | 723 | private string AddIndent(string indent, string text, IndentOptions options = IndentOptions.None) 724 | { 725 | string[] lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None); 726 | int firstLine = options == IndentOptions.SkipFirst ? 1 : 0; 727 | int lastLine = lines.Length - 1; 728 | if (lastLine >= 0 && options == IndentOptions.SkipTrailingEmpty && string.IsNullOrEmpty(lines[lastLine])) 729 | --lastLine; 730 | for (int i = firstLine; i <= lastLine; ++i) 731 | { 732 | lines[i] = indent + lines[i]; 733 | } 734 | return string.Join("\n", lines); 735 | } 736 | 737 | // Gets the position of the character which ends the line. 738 | private int GetLineEnd(string text, int position) 739 | { 740 | while (position < text.Length) 741 | { 742 | switch (text[position]) 743 | { 744 | case '\r': 745 | case '\n': 746 | return position; 747 | 748 | default: 749 | ++position; 750 | break; 751 | } 752 | } 753 | return position; 754 | } 755 | 756 | // Extract all whitespace from text[position] up to least(non-whitespace, max). 757 | private string GetWhitespace(string text, int position, int max) 758 | { 759 | int start = position; 760 | while (position < text.Length && position < max) 761 | { 762 | char ch = text[position]; 763 | if (ch == '\r' || ch == '\n') 764 | break; 765 | if (char.IsWhiteSpace(ch)) 766 | ++position; 767 | else 768 | break; 769 | } 770 | return text.Substring(start, position - start); 771 | } 772 | 773 | private string GetCurrentIndent(string text, int position) 774 | { 775 | int lineStart = GetLineStart(text, position); 776 | return GetWhitespace(text, lineStart, position); 777 | } 778 | 779 | // Gets the position of the character which starts the line. 780 | private int GetLineStart(string text, int position) 781 | { 782 | if (position == 0) 783 | return 0; 784 | // If we are at the "end" of the line in the editor we are also at the start of the next line 785 | --position; 786 | while (position > 0) 787 | { 788 | switch (text[position]) 789 | { 790 | case '\r': 791 | case '\n': 792 | return position + 1; 793 | 794 | default: 795 | --position; 796 | break; 797 | } 798 | } 799 | return position; 800 | } 801 | 802 | private char GetNextNonWhite(string text, ref int pos) 803 | { 804 | while (pos < text.Length && char.IsWhiteSpace(text, pos)) 805 | ++pos; 806 | if (pos >= text.Length) 807 | return '\0'; 808 | return text[pos]; 809 | } 810 | 811 | enum SmartDelimiter 812 | { 813 | Yes, 814 | No, 815 | IndentOnly 816 | } 817 | 818 | private SmartDelimiter IsSmartDelimiter(string text, int pos, out char closer) 819 | { 820 | closer = ' '; 821 | if (pos < 0 || pos >= text.Length) 822 | return SmartDelimiter.No; 823 | int smartIndex = Array.IndexOf(SmartChars, text[pos]); 824 | if (smartIndex < 0) 825 | return SmartDelimiter.No; 826 | closer = SmartInversion[smartIndex]; 827 | int currIndent = GetIndent(GetCurrentIndent(text, pos)); 828 | ++pos; 829 | char nextCh = GetNextNonWhite(text, ref pos); 830 | int nextIndent = GetIndent(GetCurrentIndent(text, pos)); 831 | // foo(|<-- end of file; smart delimiter 832 | if (nextCh == '\0') 833 | return SmartDelimiter.Yes; 834 | // foo(|<-- next indent is equal indent but not a match; new delimeter 835 | // blah 836 | if (currIndent == nextIndent && nextCh != closer) 837 | return SmartDelimiter.Yes; 838 | // foo ( 839 | // ..blah (|<-- next indent is less indented; we want a new delimeter 840 | // ) 841 | // 842 | // foo (|<-- next indent is equal indent; no new delimiter but do indent 843 | // ) 844 | // 845 | // foo(|<-- next indent is more indented; no new delimeter but do indent 846 | // abc() 847 | // ) 848 | if (nextIndent < currIndent) 849 | return SmartDelimiter.Yes; 850 | return SmartDelimiter.IndentOnly; 851 | } 852 | 853 | 854 | } 855 | 856 | } -------------------------------------------------------------------------------- /gtk-ui/ScratchRootController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Linq; 5 | using System.Collections.ObjectModel; 6 | using System.IO; 7 | using System.Text.RegularExpressions; 8 | using System.Reflection; 9 | using System.Collections; 10 | 11 | namespace Barrkel.ScratchPad 12 | { 13 | public enum ExitIntent 14 | { 15 | Exit, 16 | Restart 17 | } 18 | 19 | // Controller for behaviour. UI should receive this and send keystrokes and events to it, along with view callbacks. 20 | // The view should be updated via the callbacks. 21 | // Much of it is stringly typed for a dynamically bound future. 22 | public class ScratchRootController 23 | { 24 | Dictionary _controllerMap = new Dictionary(); 25 | 26 | public ScratchRoot Root { get; } 27 | 28 | public Options Options => Root.Options; 29 | 30 | public ScratchScope RootScope { get; } 31 | 32 | public ExitIntent ExitIntent { get; private set; } = ExitIntent.Exit; 33 | 34 | public event EventHandler ExitHandler; 35 | 36 | public void Exit(ExitIntent intent) 37 | { 38 | ExitIntent = intent; 39 | ExitHandler(this, EventArgs.Empty); 40 | } 41 | 42 | public ScratchBookController GetControllerFor(ScratchBook book) 43 | { 44 | if (_controllerMap.TryGetValue(book, out var result)) 45 | { 46 | return result; 47 | } 48 | result = new ScratchBookController(this, book); 49 | _controllerMap.Add(book, result); 50 | return result; 51 | } 52 | 53 | public ScratchRootController(ScratchRoot root) 54 | { 55 | Root = root; 56 | RootScope = (ScratchScope)root.RootScope; 57 | } 58 | } 59 | 60 | public class ScratchBookController 61 | { 62 | // TODO: bullet mode, somewhat like auto indent; make tabs do similar things 63 | // TODO: keyword jump from hotkey, to create cross-page linking 64 | // TODO: read-only generated pages that e.g. collect sigil lines 65 | // Canonical example: TODO: lines, or discussion items for people; ought to link back 66 | // TODO: search over log history 67 | // TODO: load key -> action bindings from a note 68 | // TODO: move top-level logic (e.g. jumping) to controller 69 | // TODO: lightweight scripting for composing new actions 70 | 71 | public ScratchBook Book { get; } 72 | public ScratchRootController RootController { get; } 73 | public ScratchScope Scope { get; } 74 | 75 | public ScratchBookController(ScratchRootController rootController, ScratchBook book) 76 | { 77 | Book = book; 78 | RootController = rootController; 79 | Scope = (ScratchScope)book.Scope; 80 | } 81 | 82 | public void ConnectView(IScratchBookView view) 83 | { 84 | view.AddRepeatingTimer(3000, "check-for-save"); 85 | } 86 | 87 | public bool InformKeyStroke(IScratchBookView view, string keyName, bool ctrl, bool alt, bool shift) 88 | { 89 | string ctrlPrefix = ctrl ? "C-" : ""; 90 | string altPrefix = alt ? "M-" : ""; 91 | // Convention: self-printing keys have a single character. Exceptions are Return and Space. 92 | // Within this convention, don't use S- if shift was pressed to access the key. M-A is M-S-a, but M-A is emacs style. 93 | string shiftPrefix = (keyName.Length > 1 && shift) ? "S-" : ""; 94 | string key = string.Concat(ctrlPrefix, altPrefix, shiftPrefix, keyName); 95 | 96 | ExecutionContext context = new ExecutionContext(this, view, Scope); 97 | 98 | if (Scope.GetOrDefault("debug-keys", false)) 99 | Log.Out($"debug-keys: {key}"); 100 | 101 | if (Scope.TryLookup(key, out var action)) 102 | { 103 | try 104 | { 105 | action.Invoke(key, context, ScratchValue.EmptyList); 106 | } 107 | catch (Exception ex) 108 | { 109 | Log.Out(ex.Message); 110 | } 111 | return true; 112 | } 113 | 114 | return false; 115 | } 116 | 117 | public void InvokeAction(IScratchBookView view, string actionName, IList args) 118 | { 119 | ExecutionContext context = new ExecutionContext(this, view, Scope); 120 | if (Scope.TryLookup(actionName, out var action)) 121 | action.Invoke(actionName, context, args); 122 | } 123 | 124 | public void InformEvent(IScratchBookView view, string eventName, IList args) 125 | { 126 | ExecutionContext context = new ExecutionContext(this, view, Scope); 127 | string eventMethod = $"on-{eventName}"; 128 | if (Scope.TryLookup(eventMethod, out var action)) 129 | action.Invoke(eventMethod, context, args); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /gtk-ui/ScratchScopes.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Linq; 5 | using System.Collections.ObjectModel; 6 | using System.IO; 7 | using System.Text.RegularExpressions; 8 | using System.Reflection; 9 | using System.Collections; 10 | 11 | namespace Barrkel.ScratchPad 12 | { 13 | public interface IScratchLibrary : IEnumerable<(string, ScratchValue)> 14 | { 15 | bool TryLookup(string name, out ScratchValue result); 16 | string Name { get; } 17 | } 18 | 19 | public abstract class ScratchLibraryBase : IScratchLibrary 20 | { 21 | protected ScratchLibraryBase(string name) 22 | { 23 | Name = name; 24 | } 25 | 26 | protected Dictionary Bindings { get; } = new Dictionary(); 27 | 28 | public string Name { get; } 29 | 30 | public IEnumerator<(string, ScratchValue)> GetEnumerator() 31 | { 32 | foreach (var entry in Bindings) 33 | yield return (entry.Key, entry.Value); 34 | } 35 | 36 | public bool TryLookup(string name, out ScratchValue result) 37 | { 38 | return Bindings.TryGetValue(name, out result); 39 | } 40 | 41 | IEnumerator IEnumerable.GetEnumerator() 42 | { 43 | return GetEnumerator(); 44 | } 45 | } 46 | 47 | delegate void ScratchActionVoid(ExecutionContext context, IList args); 48 | 49 | public abstract class NativeLibrary : ScratchLibraryBase 50 | { 51 | protected NativeLibrary(string name) : base(name) 52 | { 53 | foreach (var member in GetType().GetMembers()) 54 | { 55 | if (member.MemberType != MemberTypes.Method) 56 | continue; 57 | MethodInfo method = (MethodInfo)member; 58 | foreach (VariadicActionAttribute attr in Attribute.GetCustomAttributes(member, typeof(VariadicActionAttribute))) 59 | { 60 | ScratchAction action; 61 | try 62 | { 63 | if (method.ReturnType == typeof(void)) 64 | { 65 | ScratchActionVoid voidAction = 66 | (ScratchActionVoid)Delegate.CreateDelegate(typeof(ScratchActionVoid), this, method, true); 67 | action = (context, args) => 68 | { 69 | voidAction(context, args); 70 | return ScratchValue.Null; 71 | }; 72 | } 73 | else 74 | { 75 | action = (ScratchAction)Delegate.CreateDelegate(typeof(ScratchAction), this, method, true); 76 | } 77 | Bindings.Add(attr.Name, new ScratchValue(action)); 78 | } 79 | catch (Exception ex) 80 | { 81 | Log.Out($"Error binding {method.Name}: {ex.Message}"); 82 | } 83 | } 84 | foreach (TypedActionAttribute attr in Attribute.GetCustomAttributes(member, typeof(TypedActionAttribute))) 85 | { 86 | ScratchAction action; 87 | try 88 | { 89 | if (method.ReturnType == typeof(void)) 90 | { 91 | ScratchActionVoid voidAction = 92 | (ScratchActionVoid)Delegate.CreateDelegate(typeof(ScratchActionVoid), this, method, true); 93 | action = (context, args) => 94 | { 95 | Validate(attr.Name, args, attr.ParamTypes); 96 | voidAction(context, args); 97 | return ScratchValue.Null; 98 | }; 99 | } 100 | else 101 | { 102 | var innerAction = (ScratchAction)Delegate.CreateDelegate(typeof(ScratchAction), this, method, true); 103 | action = (context, args) => 104 | { 105 | Validate(attr.Name, args, attr.ParamTypes); 106 | return innerAction(context, args); 107 | }; 108 | } 109 | Bindings.Add(attr.Name, new ScratchValue(action)); 110 | } 111 | catch (Exception ex) 112 | { 113 | Log.Out($"Error binding {method.Name}: {ex.Message}"); 114 | } 115 | } 116 | } 117 | } 118 | 119 | protected void Bind(string name, ScratchValue value) 120 | { 121 | Bindings[name] = value; 122 | } 123 | 124 | protected void ValidateLength(string method, IList args, int length) 125 | { 126 | if (args.Count != length) 127 | throw new ArgumentException($"Expected {length} arguments to {method} but got {args.Count}"); 128 | } 129 | 130 | protected void ValidateArgument(string method, IList args, int index, ScratchType paramType) 131 | { 132 | if (args[index].Type != paramType) 133 | throw new ArgumentException($"Expected arg {index + 1} to {method} to be {paramType} but got {args[index].Type}"); 134 | } 135 | 136 | protected void ValidateArgument(string method, IList args, int index, ScratchType paramType, 137 | ScratchType paramType2) 138 | { 139 | if (args[index].Type != paramType && args[index].Type != paramType2) 140 | throw new ArgumentException($"Expected arg {index + 1} to {method} to be {paramType} but got {args[index].Type}"); 141 | } 142 | 143 | protected void Validate(string method, IList args, IList paramTypes) 144 | { 145 | ValidateLength(method, args, paramTypes.Count); 146 | for (int i = 0; i < args.Count; ++i) 147 | ValidateArgument(method, args, i, paramTypes[i]); 148 | } 149 | } 150 | 151 | public class ScratchScope : IScratchLibrary, IScratchScope 152 | { 153 | Dictionary _bindings = new Dictionary(); 154 | 155 | private ScratchScope() 156 | { 157 | Name = "root"; 158 | } 159 | 160 | private ScratchScope(ScratchScope parent, string name) 161 | { 162 | Parent = parent; 163 | Name = name; 164 | } 165 | 166 | public static ScratchScope CreateRoot() 167 | { 168 | return new ScratchScope(); 169 | } 170 | 171 | public string Name { get; } 172 | 173 | // Additively load bindings from enumerable source. 174 | public void Load(IScratchLibrary library) 175 | { 176 | foreach (var (name, value) in library) 177 | _bindings[name] = value; 178 | } 179 | 180 | public ScratchScope Parent { get; } 181 | 182 | public bool TryLookup(string name, out ScratchValue result) 183 | { 184 | for (ScratchScope scope = this; scope != null; scope = scope.Parent) 185 | if (scope._bindings.TryGetValue(name, out result)) 186 | return true; 187 | result = null; 188 | return false; 189 | } 190 | 191 | public ScratchValue Lookup(string name) 192 | { 193 | if (TryLookup(name, out ScratchValue result)) 194 | return result; 195 | throw new ArgumentException("name not found in scope: " + name); 196 | } 197 | 198 | // We model assignment as updating a binding, rather than updating a box referenced by the binding. 199 | public void Assign(string name, ScratchValue value) 200 | { 201 | for (ScratchScope scope = this; scope != null; scope = scope.Parent) 202 | if (scope.TryAssign(name, value)) 203 | return; 204 | AssignLocal(name, value); 205 | } 206 | 207 | public void AssignLocal(string name, ScratchValue value) 208 | { 209 | _bindings[name] = value; 210 | } 211 | 212 | public bool TryAssign(string name, ScratchValue value) 213 | { 214 | if (_bindings.ContainsKey(name)) 215 | { 216 | _bindings[name] = value; 217 | return true; 218 | } 219 | return false; 220 | } 221 | 222 | public IEnumerator<(string, ScratchValue)> GetEnumerator() 223 | { 224 | foreach (var entry in _bindings) 225 | yield return (entry.Key, entry.Value); 226 | } 227 | 228 | // These GetOrDefault overloads are for pulling out settings 229 | 230 | public string GetOrDefault(string name, string defaultValue) 231 | { 232 | if (TryLookup(name, out var result) && result.Type == ScratchType.String) 233 | return result.StringValue; 234 | return defaultValue; 235 | } 236 | 237 | public bool GetOrDefault(string name, bool defaultValue) 238 | { 239 | if (TryLookup(name, out var result)) 240 | return result.Type != ScratchType.Null; 241 | return defaultValue; 242 | } 243 | 244 | public int GetOrDefault(string name, int defaultValue) 245 | { 246 | if (TryLookup(name, out var result) && result.Type == ScratchType.Int32) 247 | return result.Int32Value; 248 | return defaultValue; 249 | } 250 | 251 | IEnumerator IEnumerable.GetEnumerator() 252 | { 253 | return GetEnumerator(); 254 | } 255 | 256 | IScratchScope IScratchScope.CreateChild(string name) 257 | { 258 | return CreateChild(name); 259 | } 260 | 261 | public ScratchScope CreateChild(string name) 262 | { 263 | return new ScratchScope(this, name); 264 | } 265 | } 266 | 267 | [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] 268 | public class TypedActionAttribute : Attribute 269 | { 270 | public TypedActionAttribute(string name, params ScratchType[] paramTypes) 271 | { 272 | Name = name; 273 | ParamTypes = paramTypes; 274 | } 275 | 276 | public string Name { get; } 277 | public IList ParamTypes { get; } 278 | } 279 | 280 | [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)] 281 | public class VariadicActionAttribute : Attribute 282 | { 283 | public VariadicActionAttribute(string name) 284 | { 285 | Name = name; 286 | } 287 | 288 | public string Name { get; } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /gtk-ui/ScratchView.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace Barrkel.ScratchPad 8 | { 9 | internal class PageViewState 10 | { 11 | // Exists for nicer references to the constructor. 12 | internal static PageViewState Create() 13 | { 14 | return new PageViewState(); 15 | } 16 | 17 | public (int, int)? CurrentSelection { get; set; } 18 | public int? CurrentScrollPos { get; set; } 19 | // [start, end) delimits text inserted in a completion attempt 20 | public (int, int)? CurrentCompletion { get; set; } 21 | } 22 | 23 | public delegate IEnumerable<(string, T)> SearchFunc(string text); 24 | 25 | public interface IScratchBookView 26 | { 27 | // Get the book this view is for; the view only ever shows a single page from a book 28 | ScratchBook Book { get; } 29 | 30 | // Inserts text at CurrentPosition 31 | void InsertText(string text); 32 | // Delete text backwards from CurrentPosition 33 | void DeleteTextBackwards(int count); 34 | // Gets 0-based position in text 35 | int CurrentPosition { get; set; } 36 | // Get or set the bounds of selected text; first is cursor, second is bound. 37 | (int, int) Selection { get; set; } 38 | 39 | // 0-based position in text of first character on visible line at top of view. 40 | // Assigning will attempt to set the scroll position so that this character is at the top. 41 | int ScrollPos { get; set; } 42 | // Ensure 0-based position in text is visible by scrolling if necessary. 43 | void ScrollIntoView(int position); 44 | 45 | // 0-based index of current Page in Book; may be equal to Book.Pages.Count for new page. 46 | // Assigning does not move page to end, unlike JumpToPage. 47 | int CurrentPageIndex { get; set; } 48 | // Current text in editor, which may be ahead of model (lazy saves). 49 | string CurrentText { get; } 50 | 51 | string Clipboard { get; } 52 | string SelectedText { get; set; } 53 | 54 | // View should call InvokeAction with actionName every millis milliseconds 55 | void AddRepeatingTimer(int millis, string actionName); 56 | 57 | void AddNewPage(); 58 | 59 | // Show a search dialog with text box, list box and ok/cancel buttons. 60 | // List box is populated from result of searchFunc applied to contents of text box. 61 | // Returns true if OK clicked and item in list box selected, with associated T in result. 62 | // Returns false if Cancel clicked or search otherwise cancelled (Esc). 63 | bool RunSearch(SearchFunc searchFunc, out T result); 64 | 65 | // Show an input dialog. 66 | bool GetInput(ScratchScope settings, out string value); 67 | 68 | // Show a non-modal snippet window. 69 | void LaunchSnippet(ScratchScope settings); 70 | 71 | // Navigate version history. 72 | bool Navigate(Func callback); 73 | 74 | // Before invoking cross-page navigation, call this. 75 | void EnsureSaved(); 76 | // Jump to page by index in Book. 77 | void JumpToPage(int page); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /gtk-ui/SearchWindow.cs: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Barrkel.ScratchPad; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Barrkel.GtkScratchPad 8 | { 9 | public enum ModalResult 10 | { 11 | None, 12 | OK, 13 | Cancel 14 | } 15 | 16 | public delegate IEnumerable<(string, T)> SearchFunc(string text); 17 | 18 | public class ModalWindow : Window, IDisposable 19 | { 20 | public ModalWindow(string title) : base(title) 21 | { 22 | } 23 | 24 | protected override void OnHidden() 25 | { 26 | base.OnHidden(); 27 | if (ModalResult == ModalResult.None) 28 | ModalResult = ModalResult.Cancel; 29 | } 30 | 31 | public override void Dispose() 32 | { 33 | base.Destroy(); 34 | base.Dispose(); 35 | } 36 | 37 | public ModalResult ModalResult 38 | { 39 | get; protected set; 40 | } 41 | 42 | public ModalResult ShowModal(Window parent) 43 | { 44 | ModalResult = ModalResult.None; 45 | Modal = true; 46 | TransientFor = parent; 47 | ShowAll(); 48 | while (ModalResult == ModalResult.None) 49 | Application.RunIteration(true); 50 | return ModalResult; 51 | } 52 | 53 | protected override bool OnKeyPressEvent(Gdk.EventKey evnt) 54 | { 55 | switch (evnt.Key) 56 | { 57 | case Gdk.Key.Escape: 58 | ModalResult = ModalResult.Cancel; 59 | return false; 60 | 61 | case Gdk.Key.Return: 62 | ModalResult = ModalResult.OK; 63 | return false; 64 | 65 | default: 66 | return base.OnKeyPressEvent(evnt); 67 | } 68 | } 69 | } 70 | 71 | public class InputModalWindow : ModalWindow 72 | { 73 | TextView _textView; 74 | 75 | public InputModalWindow(ScratchScope settings) : base("Input") 76 | { 77 | Scope = settings; 78 | InitComponent(); 79 | } 80 | 81 | public ScratchScope Scope { get; } 82 | 83 | private void InitComponent() 84 | { 85 | Resize(500, 100); 86 | _textView = new TextView(); 87 | 88 | Gdk.Color lightBlue = new Gdk.Color(207, 207, 239); 89 | 90 | var textFont = Pango.FontDescription.FromString(Scope.GetOrDefault("text-font", "Courier New")); 91 | 92 | _textView.ModifyBase(StateType.Normal, lightBlue); 93 | // _textView.Buffer.Changed += (s, e) => { UpdateSearchBox(); }; 94 | // _textView.KeyPressEvent += _textView_KeyPressEvent; 95 | _textView.ModifyFont(textFont); 96 | string initText = Scope.GetOrDefault("init-text", ""); 97 | if (!string.IsNullOrEmpty(initText)) 98 | { 99 | _textView.Buffer.Text = initText; 100 | TextIter start = _textView.Buffer.GetIterAtOffset(0); 101 | TextIter end = _textView.Buffer.GetIterAtOffset(initText.Length); 102 | _textView.Buffer.SelectRange(start, end); 103 | } 104 | 105 | 106 | Add(_textView); 107 | BorderWidth = 5; 108 | } 109 | 110 | public static bool GetInput(Window parent, ScratchScope settings, out string result) 111 | { 112 | using (InputModalWindow window = new InputModalWindow(settings)) 113 | { 114 | switch (window.ShowModal(parent)) 115 | { 116 | case ModalResult.OK: 117 | result = window._textView.Buffer.Text; 118 | return true; 119 | 120 | default: 121 | result = null; 122 | return false; 123 | } 124 | } 125 | } 126 | 127 | private void _textView_KeyPressEvent(object o, KeyPressEventArgs args) 128 | { 129 | // throw new NotImplementedException(); 130 | } 131 | } 132 | 133 | delegate IEnumerable<(string, object)> SearchFunc(string text); 134 | 135 | public class SearchWindow : ModalWindow 136 | { 137 | TextView _searchTextView; 138 | TreeView _searchResultsView; 139 | ListStore _searchResultsStore; 140 | TreeViewColumn _valueColumn; 141 | int _searchResultsStoreCount; // asinine results store 142 | 143 | SearchWindow(SearchFunc searchFunc, ScratchScope settings) : base("ScratchPad") 144 | { 145 | SearchFunc = searchFunc; 146 | AppSettings = settings; 147 | InitComponent(); 148 | } 149 | 150 | static SearchFunc Polymorphize(SearchFunc generic) 151 | { 152 | return text => generic(text).Select(x => (x.Item1, (object)x.Item2)); 153 | } 154 | 155 | public static bool RunSearch(Window parent, SearchFunc searchFunc, ScratchScope settings, out T result) 156 | { 157 | using (SearchWindow window = new SearchWindow(Polymorphize(searchFunc), settings)) 158 | { 159 | switch (window.ShowModal(parent)) 160 | { 161 | case ModalResult.OK: 162 | if (window.SelectedItem == null) 163 | { 164 | result = default; 165 | return false; 166 | } 167 | result = (T) window.SelectedItem.Value; 168 | return true; 169 | 170 | default: 171 | result = default; 172 | return false; 173 | } 174 | } 175 | } 176 | 177 | public ScratchScope AppSettings { get; private set; } 178 | SearchFunc SearchFunc { get; set; } 179 | 180 | private void InitComponent() 181 | { 182 | Resize(500, 500); 183 | 184 | var vbox = new VBox(); 185 | 186 | Gdk.Color grey = new Gdk.Color(0xA0, 0xA0, 0xA0); 187 | Gdk.Color lightBlue = new Gdk.Color(207, 207, 239); 188 | var infoFont = Pango.FontDescription.FromString(AppSettings.GetOrDefault("info-font", "Verdana")); 189 | var textFont = Pango.FontDescription.FromString(AppSettings.GetOrDefault("text-font", "Courier New")); 190 | 191 | _searchTextView = new TextView(); 192 | _searchTextView.ModifyBase(StateType.Normal, lightBlue); 193 | _searchTextView.Buffer.Changed += (s, e) => { UpdateSearchBox(); }; 194 | _searchTextView.KeyPressEvent += _searchTextView_KeyPressEvent; 195 | _searchTextView.ModifyFont(textFont); 196 | _searchTextView.Buffer.Text = ""; 197 | 198 | 199 | _searchResultsStore = new ListStore(typeof(TitleSearchResult)); 200 | 201 | _searchResultsView = new TreeView(_searchResultsStore); 202 | var valueRenderer = new CellRendererText(); 203 | _valueColumn = new TreeViewColumn 204 | { 205 | Title = "Value" 206 | }; 207 | _valueColumn.PackStart(valueRenderer, true); 208 | _valueColumn.SetCellDataFunc(valueRenderer, (TreeViewColumn col, CellRenderer cell, TreeModel model, TreeIter iter) => 209 | { 210 | TitleSearchResult item = (TitleSearchResult) model.GetValue(iter, 0); 211 | ((CellRendererText)cell).Text = item.Title; 212 | }); 213 | _searchResultsView.AppendColumn(_valueColumn); 214 | 215 | _searchResultsView.ModifyBase(StateType.Normal, lightBlue); 216 | _searchResultsView.ButtonPressEvent += _searchResultsView_ButtonPressEvent; 217 | _searchResultsView.KeyPressEvent += _searchResultsView_KeyPressEvent; 218 | 219 | var scrolledResults = new ScrolledWindow(); 220 | scrolledResults.Add(_searchResultsView); 221 | 222 | vbox.PackStart(_searchTextView, false, false, 0); 223 | vbox.PackStart(scrolledResults, true, true, 0); 224 | 225 | Add(vbox); 226 | 227 | BorderWidth = 5; 228 | 229 | UpdateSearchBox(); 230 | } 231 | 232 | private void UpdateSearchBox() 233 | { 234 | _searchResultsStore.Clear(); 235 | _searchResultsStoreCount = 0; 236 | 237 | foreach (var (title, v) in SearchFunc(_searchTextView.Buffer.Text)) 238 | { 239 | _searchResultsStore.SetValue(_searchResultsStore.Append(), 0, 240 | new TitleSearchResult(title, _searchResultsStoreCount, v)); 241 | ++_searchResultsStoreCount; 242 | 243 | if (_searchResultsStoreCount >= 100) 244 | break; 245 | } 246 | if (_searchResultsStoreCount == 1) 247 | SelectedIndex = 0; 248 | else 249 | SelectedIndex = -1; 250 | } 251 | 252 | public int SelectedIndex 253 | { 254 | get 255 | { 256 | TreeIter iter; 257 | if (_searchResultsView.Selection.GetSelected(out iter)) 258 | return ((TitleSearchResult) _searchResultsStore.GetValue(iter, 0)).Index; 259 | return -1; 260 | } 261 | set 262 | { 263 | var selection = _searchResultsView.Selection; 264 | if (value < 0 || value >= _searchResultsStoreCount) 265 | { 266 | selection.UnselectAll(); 267 | return; 268 | } 269 | 270 | TreeIter iter; 271 | if (!_searchResultsStore.IterNthChild(out iter, value)) 272 | return; 273 | 274 | selection.SelectIter(iter); 275 | _searchResultsView.ScrollToCell(_searchResultsStore.GetPath(iter), null, false, 0, 0); 276 | } 277 | } 278 | 279 | internal TitleSearchResult SelectedItem 280 | { 281 | get 282 | { 283 | if (_searchResultsView.Selection.GetSelected(out TreeIter iter)) 284 | return (TitleSearchResult)_searchResultsStore.GetValue(iter, 0); 285 | return null; 286 | } 287 | } 288 | 289 | [GLib.ConnectBefore] 290 | void _searchTextView_KeyPressEvent(object o, KeyPressEventArgs args) 291 | { 292 | int selectedIndex = SelectedIndex; 293 | 294 | switch (args.Event.Key) 295 | { 296 | case Gdk.Key.Return: 297 | if (_searchResultsStoreCount > 0) 298 | { 299 | if (selectedIndex <= 0) 300 | selectedIndex = 0; 301 | SelectedIndex = selectedIndex; 302 | } 303 | ModalResult = ModalResult.OK; 304 | args.RetVal = true; 305 | break; 306 | 307 | case Gdk.Key.Up: 308 | // FIXME: make these things scroll the selection into view 309 | args.RetVal = true; 310 | if (_searchResultsStoreCount > 0) 311 | { 312 | --selectedIndex; 313 | if (selectedIndex <= 0) 314 | selectedIndex = 0; 315 | SelectedIndex = selectedIndex; 316 | } 317 | break; 318 | 319 | case Gdk.Key.Down: 320 | args.RetVal = true; 321 | if (_searchResultsStoreCount > 0) 322 | { 323 | ++selectedIndex; 324 | if (selectedIndex >= _searchResultsStoreCount) 325 | selectedIndex = _searchResultsStoreCount - 1; 326 | SelectedIndex = selectedIndex; 327 | } 328 | break; 329 | } 330 | } 331 | 332 | private void _searchResultsView_KeyPressEvent(object o, KeyPressEventArgs args) 333 | { 334 | int selectedIndex = SelectedIndex; 335 | switch (args.Event.Key) 336 | { 337 | case Gdk.Key.Return: 338 | if (_searchResultsStoreCount > 0) 339 | { 340 | if (selectedIndex <= 0) 341 | selectedIndex = 0; 342 | SelectedIndex = selectedIndex; 343 | } 344 | ModalResult = ModalResult.OK; 345 | args.RetVal = true; 346 | break; 347 | } 348 | } 349 | 350 | [GLib.ConnectBefore] 351 | void _searchResultsView_ButtonPressEvent(object o, ButtonPressEventArgs args) 352 | { 353 | // consider handling double-click 354 | } 355 | } 356 | 357 | class TitleSearchResult 358 | { 359 | public TitleSearchResult(string title, int index, object value) 360 | { 361 | Title = title; 362 | Index = index; 363 | Value = value; 364 | } 365 | 366 | public string Title { get; private set; } 367 | public int Index { get; private set; } 368 | public object Value { get; private set; } 369 | 370 | public override string ToString() 371 | { 372 | return Title; 373 | } 374 | } 375 | } 376 | 377 | -------------------------------------------------------------------------------- /gtk-ui/SnippetWindow.cs: -------------------------------------------------------------------------------- 1 | using Gtk; 2 | using Barrkel.ScratchPad; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Barrkel.GtkScratchPad 8 | { 9 | public class SnippetWindow : Window 10 | { 11 | TextView _textView; 12 | 13 | public SnippetWindow(ScratchScope settings) : base("Snippet") 14 | { 15 | Scope = settings; 16 | InitComponent(); 17 | } 18 | 19 | public ScratchScope Scope { get; } 20 | 21 | private void InitComponent() 22 | { 23 | Resize(500, 500); 24 | _textView = new TextView 25 | { 26 | WrapMode = WrapMode.Word 27 | }; 28 | 29 | Title= Scope.GetOrDefault("title", "Snippet"); 30 | 31 | Gdk.Color backgroundColor = new Gdk.Color(255, 255, 200); 32 | if (Scope.TryLookup("snippet-color", out var colorSetting)) 33 | Gdk.Color.Parse(colorSetting.StringValue, ref backgroundColor); 34 | 35 | var textFont = Pango.FontDescription.FromString(Scope.GetOrDefault("text-font", "Courier New")); 36 | 37 | _textView.ModifyBase(StateType.Normal, backgroundColor); 38 | _textView.ModifyFont(textFont); 39 | _textView.Editable = false; 40 | 41 | _textView.Buffer.Text = Scope.Lookup("text").StringValue; 42 | 43 | ScrolledWindow scrolledTextView = new ScrolledWindow(); 44 | scrolledTextView.Add(_textView); 45 | 46 | Add(scrolledTextView); 47 | 48 | BorderWidth = 0; 49 | } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /gtk-ui/app.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gtk-ui/bin/Debug/dir/0-globalconfig.txt: -------------------------------------------------------------------------------- 1 | .globalconfig Default Global Config 2 | 3 | #################################################################################################### 4 | # Do not modify this file since it may be overwritten without notice. 5 | #################################################################################################### 6 | 7 | #--------------------------------------------------------------------------------------------------- 8 | # Available Functions 9 | #--------------------------------------------------------------------------------------------------- 10 | 11 | // add 12 | // add-indent(text, indent: string): string 13 | // add-new-page 14 | // autoindent-return 15 | // call-action - invoke action via search UI 16 | // char-at(string, int): string 17 | // complete 18 | // concat(...): string 19 | // debug-stack 20 | // div 21 | // dp 22 | // dump-scopes - Output all scope values to the log. 23 | // ensure-saved 24 | // enter-page 25 | // eq 26 | // escape-re 27 | // exec 28 | // exit-app 29 | // exit-page 30 | // format(string, ...): string 31 | // ge 32 | // get-clipboard(): string 33 | // get-cursor-text-re 34 | // get-input([closure]): string - get input, scope args { init-text } 35 | // get-line-end(text: string, pos: int): int 36 | // get-line-ident(string, position) 37 | // get-line-start(text: string, pos: int): int 38 | // get-page-count(): int 39 | // get-page-index(): int - get index of current page 40 | // get-page-title(int): string 41 | // get-string-from-to(string, int, int): string 42 | // get-view-pos(): int 43 | // get-view-selected-text(): string 44 | // get-view-text(): string 45 | // get-whitespace(text: string; pos, max: int): string - Get all whitespace from text[pos] up to text[max] or non-whitespace 46 | // goto-next-major-version 47 | // goto-next-version 48 | // goto-previous-major-version 49 | // goto-previous-version 50 | // gsub 51 | // gsub(text, regex, replacement: string): string 52 | // gt 53 | // incremental-search(source, prefilter: (regex -> regex), transform: (string -> string)) 54 | // indent-block 55 | // index-of(haystack, needle[, start-index[, end-index]]): int 56 | // insert-date 57 | // insert-datetime 58 | // insert-text(...) 59 | // is-defined(symbol: string): bool 60 | // jump-to-page(int) - set page index and move page to end 61 | // launch-snippet 62 | // le 63 | // length(string): int 64 | // load-config 65 | // log 66 | // lt 67 | // match-re(text, regex: string): string 68 | // mod 69 | // mul 70 | // navigate-contents 71 | // navigate-sigil 72 | // navigate-title 73 | // navigate-todo 74 | // ne 75 | // occur 76 | // on-text-changed 77 | // open 78 | // reset-config 79 | // reset-indent(text: string): string 80 | // restart-app - Tear down UI, reload all books and recreate UI. 81 | // reverse-lines 82 | // scroll-pos-into-view(pos: int) 83 | // search-current-page(prefilter: string) - occur but on lines matching regex 84 | // set-page-index(int) - set current page 85 | // set-view-pos(int) 86 | // set-view-selected-text(text: string) 87 | // set-view-selection(from, to: int) 88 | // smart-paste 89 | // sort-lines 90 | // sub 91 | // substring(string; startIndex, length: int): string 92 | // to-int(string): int 93 | // to-str(int): string 94 | // transform-lines 95 | // unindent-block 96 | 97 | // Variable: set to non-zero to debug scope bindings. 98 | // debug-binding 99 | 100 | // Variable: set to non-null to debug key bindings. 101 | // debug-keys 102 | 103 | #------------------------------------------------------------------------------- 104 | # UI config 105 | #------------------------------------------------------------------------------- 106 | 107 | text-font = "Consolas, 9" 108 | info-font = "Verdana, 12" 109 | log-font = "Consolas, 8" 110 | app-title = "Barry's Scratchpad" 111 | 112 | 113 | #------------------------------------------------------------------------------- 114 | # Key bindings 115 | #------------------------------------------------------------------------------- 116 | 117 | # Reset this config with reset-config 118 | C-M-R = reset-config 119 | 120 | C-S-F4 = restart-app 121 | 122 | "C-M-?" = dump-scopes 123 | 124 | debug-keys = nil 125 | 126 | C-Up = goto-prev-para 127 | C-Down = goto-next-para 128 | "C-*" = repeat-selection 129 | 130 | F1 = create-snippet-from-selection 131 | 132 | M-S-Up = { goto-previous-major-version(300) } 133 | M-S-Down = { goto-next-major-version(300) } 134 | 135 | M-r = replace-command 136 | 137 | C-o = launch-url 138 | 139 | C-a = goto-sol 140 | C-e = goto-eol 141 | 142 | "C-!" = { insert-header('#', 100) } 143 | "C-@" = { insert-header('*', 90) } 144 | "C-#" = { insert-header('-', 80) } 145 | 146 | M-Return = jump-title-link 147 | 148 | M-Left = goto-previous-page 149 | M-PgUp = goto-previous-page 150 | M-Right = goto-next-page 151 | M-PgDn = goto-next-page 152 | 153 | M-x = call-action 154 | 155 | M-o = occur 156 | 157 | #------------------------------------------------------------------------------- 158 | # Commands 159 | #------------------------------------------------------------------------------- 160 | 161 | occur = { 162 | search-current-page('') 163 | } 164 | 165 | find-page-index = { |title| 166 | index := get-page-count() 167 | while ge(index, 0) { 168 | if eq(title, get-page-title(index)) { 169 | return index 170 | } 171 | index = sub(index, 1) 172 | } 173 | return nil 174 | } 175 | 176 | jump-to-title = { |title| 177 | index := find-page-index(title) 178 | if !index { return } 179 | jump-to-page(index) 180 | } 181 | 182 | jump-title-link = { 183 | line := get-current-line-text() 184 | sigil := index-of(line, '>>> ') 185 | if !sigil { return } 186 | title := get-string-from-to(line, add(sigil, 4), length(line)) 187 | jump-to-title(title) 188 | } 189 | 190 | goto-next-page = { 191 | target := add(get-page-index(), 1) 192 | if eq(target, get-page-count()) { 193 | add-new-page() 194 | } else { 195 | set-page-index(target) 196 | } 197 | } 198 | 199 | goto-previous-page = { 200 | set-page-index(sub(get-page-index(), 1)) 201 | } 202 | 203 | create-snippet-from-selection = { 204 | sel := get-view-selected-text() 205 | if sel && ne(sel, '') { 206 | title := get-input-text("Snippet") 207 | launch-snippet({ 208 | text := sel 209 | snippet-color := '#FFFFC0' 210 | }) 211 | } 212 | } 213 | 214 | launch-url = { 215 | value := get-cursor-text-re("\S+") 216 | if !value { return } 217 | if !is-url(value) { return } 218 | open(value) 219 | } 220 | 221 | repeat-selection = { 222 | sel := get-view-selected-text() 223 | if !sel || eq(sel, '') { return } 224 | count := get-input-number('repeat count') 225 | if !count || le(count, 0) { return } 226 | set-view-selected-text(str-n(sel, count)) 227 | } 228 | 229 | goto-eol = { 230 | set-view-pos( 231 | get-line-end( 232 | get-view-text(), 233 | get-view-pos())) 234 | } 235 | 236 | goto-sol = { 237 | set-view-pos( 238 | get-line-start( 239 | get-view-text(), 240 | get-view-pos())) 241 | } 242 | 243 | cut-current-line-text = { 244 | text := get-view-text() 245 | pos := get-view-pos() 246 | sol := get-line-start(text, pos) 247 | eol = get-line-end(text, pos) 248 | if le(eol, sol) { return } 249 | set-view-selection(sol, eol) 250 | result := get-view-selected-text() 251 | set-view-selected-text('') 252 | result 253 | } 254 | 255 | insert-header = { |char, count| 256 | header := cut-current-line-text() 257 | line := str-n(char, count) 258 | insert-line(line) 259 | insert-line(format("{0} ", char)) 260 | insert-line(line) 261 | goto-prev-line-end() 262 | goto-prev-line-end() 263 | if header { 264 | insert-text(header) 265 | } 266 | } 267 | 268 | goto-prev-para = { 269 | text := get-view-text() 270 | pos := get-view-pos() 271 | sol := get-line-start(text, pos) 272 | // go to previous line if we are at start of current line 273 | if eq(pos, sol) { 274 | pos := sub(pos, 1) 275 | } 276 | while gt(pos, 0) { 277 | pos := get-line-start(text, pos) 278 | eol := get-line-end(text, pos) 279 | if eq(pos, eol) { 280 | // blank line 281 | break 282 | } 283 | pos := sub(pos, 1) 284 | } 285 | if gt(pos, 0) { 286 | // set-view-pos(pos) 287 | set-view-selection(pos, add(pos, 1)) 288 | scroll-pos-into-view(pos) 289 | } 290 | } 291 | 292 | goto-next-para = { 293 | text := get-view-text() 294 | eof := length(text) 295 | pos := get-view-pos() 296 | eol := get-line-end(text, pos) 297 | // go to next line if we are at end of current line 298 | if eq(pos, eol) { 299 | pos := add(pos, 1) 300 | } 301 | while lt(pos, eof) { 302 | pos := get-line-start(text, pos) 303 | eol := get-line-end(text, pos) 304 | if eq(pos, eol) { 305 | // blank line 306 | break 307 | } 308 | pos := add(eol, 1) 309 | } 310 | // set-view-pos(pos) 311 | set-view-selection(pos, add(pos, 1)) 312 | scroll-pos-into-view(pos) 313 | } 314 | 315 | replace-command = { 316 | sel := get-view-selected-text() 317 | if !sel || eq(sel, '') { return } 318 | foo := get-input-text('Regex') 319 | if !foo { return } 320 | dst := get-input-text('Replacement') 321 | if !dst { return } 322 | ensure-saved() 323 | set-view-selected-text(gsub(sel, foo, dst)) 324 | } 325 | 326 | 327 | 328 | #------------------------------------------------------------------------------- 329 | # Utility functions 330 | #------------------------------------------------------------------------------- 331 | 332 | NEWLINE = ' 333 | ' 334 | 335 | get-line-indent = { |text, pos| 336 | get-whitespace(text, get-line-start(text, pos), pos) 337 | } 338 | 339 | is-url = { |text| 340 | match-re(text, "^https?://\S+$") 341 | } 342 | 343 | str-n = { |text, count| 344 | result := "" 345 | while gt(count, 0) { 346 | result = concat(result, text) 347 | count = sub(count, 1) 348 | } 349 | result 350 | } 351 | 352 | insert-n = { |text, count| 353 | insert-text(str-n(text, count)) 354 | } 355 | 356 | goto-prev-line-end = { 357 | text := get-view-text() 358 | line-start := get-line-start(text, get-view-pos()) 359 | prev-line-end := sub(line-start, 1) 360 | if ge(prev-line-end, 0) { 361 | set-view-pos(prev-line-end) 362 | } 363 | } 364 | 365 | insert-line = { |text| 366 | insert-text(concat(text, NEWLINE)) 367 | } 368 | 369 | get-current-line-text = { 370 | text := get-view-text() 371 | pos := get-view-pos() 372 | sol := get-line-start(text, pos) 373 | eol = get-line-end(text, pos) 374 | if le(eol, sol) { return } 375 | 376 | get-string-from-to(text, sol, eol) 377 | } 378 | 379 | get-input-number = { |current| 380 | result = nil 381 | while !result { 382 | current = get-input-text(current) 383 | if !current { 384 | return nil 385 | } 386 | result = to-int(current) 387 | } 388 | result 389 | } 390 | 391 | get-input-text = { |current| 392 | get-input({ 393 | init-text := current 394 | }) 395 | } 396 | 397 | -------------------------------------------------------------------------------- /gtk-ui/make: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | gmcs -r:/usr/lib/cli/gtk-sharp-2.0/gtk-sharp.dll \ 4 | -r:/usr/lib/cli/gdk-sharp-2.0/gdk-sharp.dll \ 5 | -r:/usr/lib/cli/glib-sharp-2.0/glib-sharp.dll \ 6 | -r:/usr/lib/cli/pango-sharp-2.0/pango-sharp.dll \ 7 | -o gtkscratch.exe \ 8 | *.cs || exit 9 | 10 | mono --runtime=v4.0 gtkscratch.exe . 11 | -------------------------------------------------------------------------------- /gtk-ui/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /normalize-line-endings: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname $0)" 4 | 5 | find | egrep '.*\.(cs|csproj|sln)$' | xargs dos2unix >/dev/null 2>/dev/null 6 | -------------------------------------------------------------------------------- /scratch.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 16 3 | VisualStudioVersion = 16.0.29709.97 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GtkScratch", "gtk-ui\GtkScratch.csproj", "{DFB9B734-12C6-4014-997D-5B087B1FD53F}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "scratchlog", "scratchlog\scratchlog.csproj", "{39151939-5820-4E36-8A14-01454735BF8B}" 8 | EndProject 9 | Global 10 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 11 | Debug|Any CPU = Debug|Any CPU 12 | Release|Any CPU = Release|Any CPU 13 | EndGlobalSection 14 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 15 | {DFB9B734-12C6-4014-997D-5B087B1FD53F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 16 | {DFB9B734-12C6-4014-997D-5B087B1FD53F}.Debug|Any CPU.Build.0 = Debug|Any CPU 17 | {DFB9B734-12C6-4014-997D-5B087B1FD53F}.Release|Any CPU.ActiveCfg = Release|Any CPU 18 | {DFB9B734-12C6-4014-997D-5B087B1FD53F}.Release|Any CPU.Build.0 = Release|Any CPU 19 | {39151939-5820-4E36-8A14-01454735BF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {39151939-5820-4E36-8A14-01454735BF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {39151939-5820-4E36-8A14-01454735BF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {39151939-5820-4E36-8A14-01454735BF8B}.Release|Any CPU.Build.0 = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(SolutionProperties) = preSolution 25 | HideSolutionNode = FALSE 26 | EndGlobalSection 27 | GlobalSection(ExtensibilityGlobals) = postSolution 28 | SolutionGuid = {FC0A6B91-5638-46B5-8115-744482FBF23F} 29 | EndGlobalSection 30 | EndGlobal 31 | -------------------------------------------------------------------------------- /scratchlog/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Barrkel.ScratchPad; 6 | using System.IO; 7 | 8 | namespace ScratchLog 9 | { 10 | class Program 11 | { 12 | static int Main(string[] args) 13 | { 14 | if (args.Length != 1) 15 | { 16 | Console.WriteLine("usage: {0} ", Path.GetFileNameWithoutExtension( 17 | Environment.GetCommandLineArgs()[0])); 18 | Console.WriteLine("Output date-stamped log of modifications in order with titles at time of modification"); 19 | return 1; 20 | } 21 | List argList = new List(args); 22 | Options options = new Options(argList); 23 | ScratchRoot root = new ScratchRoot(options, argList[0], NullScope.Instance); 24 | 25 | var updates = new List(); 26 | 27 | foreach (ScratchBook book in root.Books) 28 | { 29 | foreach (ScratchPage page in book.Pages) 30 | { 31 | ScratchIterator iter = page.GetIterator(); 32 | iter.MoveToEnd(); 33 | do { 34 | updates.Add(new Update { Title = new StringReader(iter.Text).ReadLine(), Stamp = iter.Stamp }); 35 | } while (iter.MovePrevious()); 36 | } 37 | } 38 | Console.WriteLine("Gathered {0} updates", updates.Count); 39 | 40 | Update previous = null; 41 | Update finish = null; 42 | int updateCount = 0; 43 | foreach (var update in updates.OrderByDescending(x => x.Stamp).Where(x => x.Title != null)) 44 | { 45 | if (previous == null) 46 | { 47 | previous = update; 48 | continue; 49 | } 50 | 51 | if (previous.Title == update.Title && (previous.Stamp - update.Stamp).TotalHours < 1) 52 | { 53 | // within the hour => probably the same task 54 | if (finish == null) 55 | { 56 | // this is the start of a range 57 | finish = previous; 58 | updateCount = 1; 59 | } 60 | else 61 | { 62 | ++updateCount; 63 | } 64 | } 65 | else 66 | { 67 | if (finish != null) 68 | { 69 | // we've come to the start of a range, and previous was the beginning 70 | TimeSpan duration = finish.Stamp - previous.Stamp; 71 | Console.WriteLine("{0} {1} ({2}) {3}", previous.Stamp, NiceDuration(duration), updateCount, previous.Title); 72 | } 73 | else 74 | { 75 | // different task that previous, and not part of range 76 | Console.WriteLine("{0} {1}", previous.Stamp, previous.Title); 77 | } 78 | 79 | updateCount = 0; 80 | finish = null; 81 | } 82 | 83 | previous = update; 84 | } 85 | if (finish != null) 86 | { 87 | // we've come to the start of a range, and previous was the beginning 88 | TimeSpan duration = finish.Stamp - previous.Stamp; 89 | Console.WriteLine("{0} {1} ({2}) {3}", previous.Stamp, NiceDuration(duration), updates, previous.Title); 90 | } 91 | else 92 | { 93 | // different task that previous, and not part of range 94 | Console.WriteLine("{0} {1}", previous.Stamp, previous.Title); 95 | } 96 | 97 | 98 | return 0; 99 | } 100 | 101 | static string NiceDuration(TimeSpan span) 102 | { 103 | if (span.TotalDays > 1) 104 | return string.Format("{0} days {1} hours", (int) span.TotalDays, span.Hours); 105 | if (span.TotalHours > 1) 106 | return string.Format("{0} hours {1} mins", (int) span.TotalHours, span.Minutes); 107 | return string.Format("{0} minutes", (int) span.TotalMinutes); 108 | } 109 | 110 | class Update 111 | { 112 | public DateTime Stamp { get; set; } 113 | public string Title { get; set; } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /scratchlog/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("scratchlog")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("scratchlog")] 13 | [assembly: AssemblyCopyright("Copyright © 2013")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("277beb24-8ca3-4714-9dc6-c54e8496c57d")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.0.0.0")] 36 | [assembly: AssemblyFileVersion("1.0.0.0")] 37 | -------------------------------------------------------------------------------- /scratchlog/app.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /scratchlog/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /scratchlog/scratchlog.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | 9.0.30729 8 | 2.0 9 | {39151939-5820-4E36-8A14-01454735BF8B} 10 | Exe 11 | Properties 12 | ScratchLog 13 | scratchlog 14 | v4.7 15 | 512 16 | 17 | 18 | 19 | 20 | 3.5 21 | 22 | 23 | 24 | 25 | 26 | true 27 | full 28 | false 29 | bin\Debug\ 30 | DEBUG;TRACE 31 | prompt 32 | 4 33 | false 34 | 35 | 36 | pdbonly 37 | true 38 | bin\Release\ 39 | TRACE 40 | prompt 41 | 4 42 | false 43 | 44 | 45 | 46 | ..\packages\Humanizer.Core.2.2.0\lib\netstandard1.0\Humanizer.dll 47 | 48 | 49 | ..\packages\Microsoft.Bcl.AsyncInterfaces.5.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll 50 | 51 | 52 | ..\packages\Microsoft.CodeAnalysis.Common.3.10.0\lib\netstandard2.0\Microsoft.CodeAnalysis.dll 53 | 54 | 55 | ..\packages\Microsoft.CodeAnalysis.CSharp.3.10.0\lib\netstandard2.0\Microsoft.CodeAnalysis.CSharp.dll 56 | 57 | 58 | ..\packages\Microsoft.CodeAnalysis.CSharp.Workspaces.3.10.0\lib\netstandard2.0\Microsoft.CodeAnalysis.CSharp.Workspaces.dll 59 | 60 | 61 | ..\packages\Microsoft.CodeAnalysis.Workspaces.Common.3.10.0\lib\netstandard2.0\Microsoft.CodeAnalysis.Workspaces.dll 62 | 63 | 64 | 65 | ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll 66 | 67 | 68 | ..\packages\System.Collections.Immutable.5.0.0\lib\net461\System.Collections.Immutable.dll 69 | 70 | 71 | ..\packages\System.Composition.AttributedModel.1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.AttributedModel.dll 72 | 73 | 74 | ..\packages\System.Composition.Convention.1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Convention.dll 75 | 76 | 77 | ..\packages\System.Composition.Hosting.1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Hosting.dll 78 | 79 | 80 | ..\packages\System.Composition.Runtime.1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.Runtime.dll 81 | 82 | 83 | ..\packages\System.Composition.TypedParts.1.0.31\lib\portable-net45+win8+wp8+wpa81\System.Composition.TypedParts.dll 84 | 85 | 86 | 3.5 87 | 88 | 89 | ..\packages\System.IO.Pipelines.5.0.1\lib\net461\System.IO.Pipelines.dll 90 | 91 | 92 | ..\packages\System.Memory.4.5.4\lib\net461\System.Memory.dll 93 | 94 | 95 | 96 | ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll 97 | 98 | 99 | ..\packages\System.Reflection.Metadata.5.0.0\lib\net461\System.Reflection.Metadata.dll 100 | 101 | 102 | ..\packages\System.Runtime.CompilerServices.Unsafe.5.0.0\lib\net45\System.Runtime.CompilerServices.Unsafe.dll 103 | 104 | 105 | ..\packages\System.Text.Encoding.CodePages.4.5.1\lib\net461\System.Text.Encoding.CodePages.dll 106 | 107 | 108 | ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll 109 | 110 | 111 | 3.5 112 | 113 | 114 | 3.5 115 | 116 | 117 | 118 | 119 | 120 | 121 | Scratch.cs 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 138 | 139 | 140 | 141 | 142 | 143 | 150 | --------------------------------------------------------------------------------