├── .gitignore ├── .gitmodules ├── LICENSE.md ├── Makefile ├── README.md ├── editor.lua ├── main.m ├── readme-example.gif ├── test ├── Makefile └── testwatch.c ├── watch.c └── watch.h /.gitignore: -------------------------------------------------------------------------------- 1 | editor 2 | test/testwatch 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lua"] 2 | path = lua 3 | url = git@github.com:LuaDist/lua.git 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Omar Rizwan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: editor 2 | 3 | .PHONY: lua 4 | lua: 5 | cd lua && cp src/luaconf.h.orig src/luaconf.h && make macosx 6 | 7 | editor: watch.c main.m 8 | cc -fobjc-arc -framework Cocoa -x objective-c -Ilua/src -Llua/src -llua -o editor watch.c main.m 9 | 10 | clean: 11 | rm -f editor 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # little-editor 2 | 3 | What is the **minimal (small code size, low-dependency) text editor** 4 | for macOS that is extensible? I want a really simple platform that I 5 | understand for experimenting with editor decorations / live 6 | assistance. 7 | 8 | I like CodeMirror and Monaco, but I don't want to spin up a Web 9 | browser or Electron instance every time I play with editing. And I 10 | don't want to pull in (and learn details of) giant libraries and a 11 | complicated build system. 12 | 13 | And I like having easy access to the underlying operating system and 14 | file system and to different programming languages, external 15 | utilities, and concurrency primitives. And I use macOS, so I don't 16 | need cross-platform abstractions. And I'm experimenting with small 17 | files and small syntax, so it doesn't need to be super-efficient. 18 | 19 | ## Design 20 | 21 | ![](readme-example.gif) 22 | 23 | The idea is that [`main.m`](main.m) makes a Cocoa window and draws an 24 | NSTextView, and then as much other text editor behavior as possible 25 | should be scriptable from Lua ([`editor.lua`](editor.lua)) and 26 | instantly live-reload when you save `editor.lua`. 27 | 28 | There should be **no configuration** outside `editor.lua`. Instead of 29 | a configuration system to set font size or syntax highlighting or 30 | whatever, you should... reprogram `editor.lua`. 31 | 32 | The only external dependency (beyond macOS frameworks) is the Lua 33 | interpreter. 34 | 35 | ## Install 36 | 37 | ``` 38 | $ git clone https://github.com/osnr/little-editor.git 39 | $ cd little-editor 40 | $ git submodule update --init --recursive 41 | $ make lua 42 | $ make 43 | $ ./editor 44 | ``` 45 | 46 | ## Status 47 | 48 | Still very early. Need to support drawing arbitrary decorations and 49 | taking click/hover input. 50 | -------------------------------------------------------------------------------- /editor.lua: -------------------------------------------------------------------------------- 1 | print("Running editor.lua!") 2 | 3 | setdefaultfont("Menlo", 15) 4 | 5 | function hook(s) 6 | removeattribute("NSColor", makerange(1, #s)) 7 | 8 | local i, j = 0, 0 9 | while true do 10 | i, j = s:find('hi!', i + 1) 11 | if i == nil then break end 12 | addattribute("NSColor", makecolor("orangeColor"), 13 | makerange(i, j - i + 1)) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /main.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include "watch.h" 8 | 9 | static const char *EDITOR_LUA = "editor.lua"; 10 | 11 | static NSTextView *textView; 12 | 13 | static lua_State *L; 14 | static void interpreterInit() { 15 | L = luaL_newstate(); 16 | luaL_openlibs(L); 17 | } 18 | 19 | static int l_setdefaultfont(lua_State *L) { 20 | NSString *fontName = [NSString stringWithUTF8String:luaL_checkstring(L, 1)]; 21 | int fontSize = luaL_checknumber(L, 2); 22 | [textView setFont:[NSFont fontWithName:fontName size:fontSize]]; 23 | return 0; 24 | } 25 | 26 | static int l_makerange(lua_State *L) { 27 | NSRange *range = lua_newuserdata(L, sizeof(NSRange)); 28 | *range = NSMakeRange(luaL_checknumber(L, 1) - 1, luaL_checknumber(L, 2)); 29 | return 1; 30 | } 31 | static int l_makecolor(lua_State *L) { 32 | NSString *colorName = [NSString stringWithUTF8String:luaL_checkstring(L, 1)]; 33 | NSColor *color = [NSColor valueForKey:colorName]; 34 | lua_pushlightuserdata(L, (__bridge void *) color); 35 | return 1; 36 | } 37 | static int l_addattribute(lua_State *L) { 38 | NSAttributedStringKey name = [NSString stringWithUTF8String:lua_tostring(L, 1)]; 39 | id value = (__bridge id) lua_touserdata(L, 2); 40 | NSRange *range = lua_touserdata(L, 3); 41 | @try { 42 | // FIXME: propagate errors better. 43 | [[textView textStorage] addAttribute:name 44 | value:value 45 | range:*range]; 46 | } @catch (NSException *e) { 47 | NSLog(@"%@", e); 48 | } 49 | return 0; 50 | } 51 | static int l_removeattribute(lua_State *L) { 52 | NSAttributedStringKey name = [NSString stringWithUTF8String:lua_tostring(L, 1)]; 53 | NSRange *range = lua_touserdata(L, 2); 54 | @try { 55 | // FIXME: propagate errors better. 56 | [[textView textStorage] removeAttribute:name 57 | range:*range]; 58 | } @catch (NSException *e) { 59 | NSLog(@"%@", e); 60 | } 61 | return 0; 62 | } 63 | 64 | static void interpreterRun() { 65 | lua_pushcfunction(L, l_setdefaultfont); 66 | lua_setglobal(L, "setdefaultfont"); 67 | 68 | lua_pushcfunction(L, l_makerange); 69 | lua_setglobal(L, "makerange"); 70 | lua_pushcfunction(L, l_makecolor); 71 | lua_setglobal(L, "makecolor"); 72 | lua_pushcfunction(L, l_removeattribute); 73 | lua_setglobal(L, "removeattribute"); 74 | lua_pushcfunction(L, l_addattribute); 75 | lua_setglobal(L, "addattribute"); 76 | 77 | luaL_dofile(L, EDITOR_LUA); 78 | } 79 | 80 | static void interpreterHook(const char *s) { 81 | lua_getglobal(L, "hook"); 82 | if (lua_isfunction(L, -1)) { 83 | lua_pushstring(L, s); 84 | lua_pcall(L, 1, 0, 0); 85 | } 86 | } 87 | 88 | static void interpreterWatchCallback() { 89 | interpreterRun(); 90 | interpreterHook([[[textView textStorage] string] UTF8String]); 91 | } 92 | 93 | @interface IdeTextStorageDelegate: NSObject 94 | @end 95 | 96 | @implementation IdeTextStorageDelegate 97 | - (void)textStorage:(NSTextStorage *)textStorage 98 | didProcessEditing:(NSTextStorageEditActions)editedMask 99 | range:(NSRange)editedRange 100 | changeInLength:(NSInteger)delta { 101 | id string = [textStorage string]; 102 | interpreterHook([string UTF8String]); 103 | } 104 | @end 105 | 106 | @interface IdeApplication: NSApplication 107 | @end 108 | 109 | @implementation IdeApplication 110 | /* from https://stackoverflow.com/questions/970707/cocoa-keyboard-shortcuts-in-dialog-without-an-edit-menu 111 | hack around not having a real Edit menu => not having edit keyboard shortcuts */ 112 | - (void) sendEvent:(NSEvent *)event { 113 | if ([event type] == NSEventTypeKeyDown) { 114 | if (([event modifierFlags] & NSEventModifierFlagDeviceIndependentFlagsMask) == NSEventModifierFlagCommand) { 115 | if ([[event charactersIgnoringModifiers] isEqualToString:@"x"]) { 116 | if ([self sendAction:@selector(cut:) to:nil from:self]) 117 | return; 118 | } 119 | else if ([[event charactersIgnoringModifiers] isEqualToString:@"c"]) { 120 | if ([self sendAction:@selector(copy:) to:nil from:self]) 121 | return; 122 | } 123 | else if ([[event charactersIgnoringModifiers] isEqualToString:@"v"]) { 124 | if ([self sendAction:@selector(paste:) to:nil from:self]) 125 | return; 126 | } 127 | else if ([[event charactersIgnoringModifiers] isEqualToString:@"z"]) { 128 | if ([self sendAction:@selector(undo:) to:nil from:self]) 129 | return; 130 | } 131 | else if ([[event charactersIgnoringModifiers] isEqualToString:@"a"]) { 132 | if ([self sendAction:@selector(selectAll:) to:nil from:self]) 133 | return; 134 | } 135 | } 136 | else if (([event modifierFlags] & NSEventModifierFlagDeviceIndependentFlagsMask) == (NSEventModifierFlagCommand | NSEventModifierFlagShift)) { 137 | if ([[event charactersIgnoringModifiers] isEqualToString:@"Z"]) { 138 | if ([self sendAction:@selector(redo:) to:nil from:self]) 139 | return; 140 | } 141 | } 142 | } 143 | [super sendEvent:event]; 144 | } 145 | 146 | // Blank selectors to silence Xcode warnings: 'Undeclared selector undo:/redo:' 147 | - (IBAction)undo:(id)sender {} 148 | - (IBAction)redo:(id)sender {} 149 | @end 150 | 151 | int main() { 152 | @autoreleasepool { 153 | [IdeApplication sharedApplication]; 154 | [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; 155 | id applicationName = [[NSProcessInfo processInfo] processName]; 156 | 157 | NSRect contentFrame = NSMakeRect(0, 0, 600, 600); 158 | id window = [[NSWindow alloc] 159 | initWithContentRect:contentFrame 160 | styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable 161 | backing:NSBackingStoreBuffered 162 | defer:NO]; 163 | 164 | // from https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/TextUILayer/Tasks/TextInScrollView.html#//apple_ref/doc/uid/20000938-164652 165 | NSScrollView *scrollView = [[NSScrollView alloc] 166 | initWithFrame:[[window contentView] frame]]; 167 | NSSize contentSize = [scrollView contentSize]; 168 | [scrollView setBorderType:NSNoBorder]; 169 | [scrollView setHasVerticalScroller:YES]; 170 | [scrollView setHasHorizontalScroller:YES]; 171 | [scrollView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; 172 | 173 | textView = [[NSTextView alloc] initWithFrame:NSMakeRect(0, 0, contentSize.width, contentSize.height)]; 174 | [textView setMinSize:NSMakeSize(0.0, contentSize.height)]; 175 | [textView setMaxSize:NSMakeSize(FLT_MAX, FLT_MAX)]; 176 | [textView setVerticallyResizable:YES]; 177 | [textView setHorizontallyResizable:YES]; 178 | [textView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; 179 | // The order of the following 2 lines matters! WTF? 180 | [[textView textContainer] setWidthTracksTextView:NO]; 181 | [[textView textContainer] setContainerSize:NSMakeSize(FLT_MAX, FLT_MAX)]; 182 | 183 | id textStorageDelegate = [[IdeTextStorageDelegate alloc] init]; 184 | [[textView textStorage] setDelegate:textStorageDelegate]; 185 | 186 | [scrollView setDocumentView:textView]; 187 | [window setContentView:scrollView]; 188 | [window makeFirstResponder:textView]; 189 | 190 | [window cascadeTopLeftFromPoint:NSMakePoint(20,20)]; 191 | [window setTitle: applicationName]; 192 | [window makeKeyAndOrderFront:nil]; 193 | 194 | // Now that we have all the editor UI, 195 | // initialize and first-run the Lua script. 196 | interpreterInit(); 197 | interpreterRun(); 198 | 199 | // Set up a file-watcher that will rerun when the Lua script 200 | // changes. (It runs on the single main CF run loop on the 201 | // NSApp, I think, so no threading issues!) 202 | watch(EDITOR_LUA, &interpreterWatchCallback); 203 | 204 | [NSApp activateIgnoringOtherApps:YES]; 205 | [NSApp run]; 206 | } 207 | return 0; 208 | } 209 | 210 | -------------------------------------------------------------------------------- /readme-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osnr/little-editor/f96f8cb53b04685de102e6ac7cc055e81e9ad25d/readme-example.gif -------------------------------------------------------------------------------- /test/Makefile: -------------------------------------------------------------------------------- 1 | testwatch: testwatch.c 2 | cc -o testwatch -framework CoreServices -I.. ../watch.c testwatch.c 3 | -------------------------------------------------------------------------------- /test/testwatch.c: -------------------------------------------------------------------------------- 1 | #include "watch.h" 2 | 3 | void callback() { 4 | printf("change\n"); 5 | } 6 | 7 | int main() { 8 | watch("../editor.lua", &callback); 9 | 10 | CFRunLoopRun(); 11 | } 12 | -------------------------------------------------------------------------------- /watch.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "watch.h" 3 | 4 | static void callbackWrapper( 5 | ConstFSEventStreamRef streamRef, 6 | void *clientCallbackInfo, 7 | size_t numEvents, 8 | void *eventPaths, 9 | const FSEventStreamEventFlags eventFlags[], 10 | const FSEventStreamEventId eventIds[]) { 11 | void (*callback)() = clientCallbackInfo; 12 | callback(); 13 | } 14 | 15 | void watch(const char *path, void (*callback)()) { 16 | /* Define variables and create a CFArray object containing 17 | CFString objects containing paths to watch. */ 18 | CFStringRef pathToWatch = CFStringCreateWithCString(kCFAllocatorDefault, 19 | path, 20 | kCFStringEncodingUTF8); 21 | CFArrayRef pathsToWatch = CFArrayCreate(NULL, (const void **)&pathToWatch, 1, NULL); 22 | 23 | FSEventStreamContext context = {0}; 24 | context.info = callback; 25 | 26 | FSEventStreamRef stream; 27 | CFAbsoluteTime latency = 0.05; /* Latency in seconds */ 28 | 29 | /* Create the stream, passing in a callback */ 30 | stream = FSEventStreamCreate(NULL, 31 | &callbackWrapper, 32 | &context, 33 | pathsToWatch, 34 | kFSEventStreamEventIdSinceNow, /* Or a previous event ID */ 35 | latency, 36 | kFSEventStreamCreateFlagFileEvents // special flag for tracking file events 37 | ); 38 | 39 | FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode); 40 | FSEventStreamStart(stream); 41 | } 42 | -------------------------------------------------------------------------------- /watch.h: -------------------------------------------------------------------------------- 1 | #ifndef WATCH_H 2 | #define WATCH_H 3 | 4 | #include 5 | 6 | void watch(const char *path, void (*callback)()); 7 | 8 | #endif 9 | --------------------------------------------------------------------------------