australorp.dev

Cursed MacOS GUI Programming with Odin

Written

Warning: I have little experience with Objective-C, Swift, or any of Apple’s various SDKs and APIs, so don’t take anything you are about to see too seriously…


I really enjoy programming with Odin. And one neat feature of Odin is that it has some builtin support for integrating with Objective-C APIs. Odin has a vendor library with bindings for most of the classes you would need for writing a basic Darwin application, so I figured why not give it a try?

With only 214 lines of code, I already have the start of a small text editor! Obviously there is a reason large frameworks like SwiftUI exist, and I’m not saying this way is better, but it is refreshing to dip down to the lowest level (that I know of, there may be another set of lower-level Darwin graphics APIs below the Objective-C runtime). This was also a fun experience for me because I don’t have much experience using native platform APIs. Most of the time, I am working at a very high-level of abstraction with frameworks or libraries that handle platform details for me.

Anyway, here is all the code for the demo app.

package main

import "core:os"
import "core:fmt"
import "base:runtime"
import "base:intrinsics"
import "core:path/filepath"
import NS "core:sys/darwin/Foundation"

State :: struct {
    app: ^NS.Application,
    window: ^NS.Window,
    scrollable_text_view: ^ScrollView,
    text_view: ^TextView,
}

main :: proc() {
    s := new(State)
    s.app = NS.Application_sharedApplication()
    s.app->setActivationPolicy(.Regular)

    context.user_ptr = rawptr(s)

    app_delegate := NS.application_delegate_register_and_alloc(
        NS.ApplicationDelegateTemplate{
            applicationDidFinishLaunching = proc(_: ^NS.Notification) {
                s := (^State)(context.user_ptr)
                setup_ui(s)
            },
            applicationShouldTerminate = proc(app: ^NS.Application) -> NS.ApplicationTerminateReply {
                return .TerminateNow
            },
        },
        "DemoApplicationDelegate",
        context
    )
    s.app->setDelegate(app_delegate)

    rect := NS.Rect {
        x = 0,
        y = 0,
        width = 500,
        height = 500,
    }
    s.window = NS.Window_alloc()
    style := NS.WindowStyleMask{.Titled,.Closable,.Miniaturizable,.Resizable}
    s.window = s.window->initWithContentRect(rect, style, .Buffered, false)
    bg_color := NS.Color_colorWithCalibratedRed(251,254,255,255)
    s.window->setBackgroundColor(bg_color)
    s.window->center()
    s.window->makeKeyAndOrderFront(nil)

    win_delegate := NS.window_delegate_register_and_alloc(
        {
            windowWillResizeToSize = proc(_: ^NS.Window, size: NS.Size) -> NS.Size {
                s := cast(^State)context.user_ptr
                window_resizing(s, size)
                return size
            },
            windowShouldClose = proc(_: ^NS.Window) -> bool {
                s := cast(^State)context.user_ptr
                s.app->terminate(nil)
                return true
            },
        },
        "DemoWindowDelegate",
        context
    )
    s.window->setDelegate(win_delegate)

    s.app->activateIgnoringOtherApps(true)
    s.app->finishLaunching()

    s.app->run()
}

msgSend :: intrinsics.objc_send

window_resizing :: proc(s: ^State, size: NS.Size) {
    content_rect := s.window->contentLayoutRect()
    if s.scrollable_text_view != nil {
        rect := NS.Rect {
            x = 0,
            y = 0,
            width = content_rect.width,
            height = content_rect.height,
        }
        msgSend(nil, s.scrollable_text_view, "setFrame:", content_rect)
    }
}

load_file :: proc(s: ^State, window: ^NS.Window, path: string) {
    assert(s.text_view != nil, "Cannot load file without initializing UI first")
    data, err := os.read_entire_file(path, context.allocator)
    assert(err == nil)
    str := NS.String_alloc()
    str = str->initWithOdinString(string(data))
    (cast(^Text)s.text_view)->setString(str)
    fmt.println("loaded file")

    filename := filepath.base(path)
    title := NS.String_alloc()
    title = title->initWithOdinString(filename)
    s.window->setTitle(title)
}

setup_ui :: proc(s: ^State) {
    fmt.println("setting up UI")

    view := s.window->contentView()

    content_rect := s.window->contentLayoutRect()

    fmt.println("creating TextView")

    s.scrollable_text_view = TextView_scrollableTextView()
    s.text_view = cast(^TextView)s.scrollable_text_view->documentView()

    msgSend(nil, s.scrollable_text_view, "setFrame:", content_rect)

    s.text_view->setEditable(true)
    fmt.println("done creating TextView", s.text_view)
    text_bg := NS.Color_colorWithCalibratedRed(0xfb,0xfe,0xff,0xff)
    s.text_view->setBackgroundColor(text_bg)
    text_fg := NS.Color_blackColor()
    s.text_view->setTextColor(text_fg)

    font_name := NS.MakeConstantString("Berkeley Mono Variable")
    font := Font_fontWithName(font_name, 24)
    if font == nil {
        panic(fmt.tprintf("failed to load font: %s", font_name))
    }
    as_text := cast(^Text)s.text_view
    as_text->setFont(font)
    as_text->setRichText(false)
    s.text_view->setAutomaticQuoteSubstitutionEnabled(false)

    load_file(s, s.window, "main.odin")

    view->addSubview(cast(^NS.View)s.scrollable_text_view)
    fmt.println("added TextView to window View")
}

@(objc_class="NSFont")
Font :: struct{using _: NS.Object}

@(objc_type=Font, objc_name="fontWithName", objc_is_class_method=true)
Font_fontWithName :: proc "c" (name: ^NS.String, size: NS.Float) -> ^Font {
    return msgSend(^Font, Font, "fontWithName:size:", name, size)
}

@(objc_class="NSScrollView")
ScrollView :: struct {using _: NS.Object}

@(objc_type=ScrollView, objc_name="documentView")
ScrollView_documentView :: proc(self: ^ScrollView) -> ^NS.Object {
    return msgSend(^NS.Object, self, "documentView")
}

@(objc_class="NSText")
Text :: struct{using _: NS.Object}

@(objc_type=Text, objc_name="setFont")
Text_setFont :: proc "c" (self: ^Text, font: ^Font) {
    msgSend(nil, self, "setFont:", font)
}
@(objc_type=Text, objc_name="setRichText")
Text_setRichText :: proc "c" (self: ^Text, enabled: bool) {
    msgSend(nil, self, "setRichText:", enabled)
}
@(objc_type=Text, objc_name="string")
Text_string :: proc "c" (self: ^Text) -> ^NS.String {
    return msgSend(^NS.String, self, "string")
}
@(objc_type=Text, objc_name="setString")
Text_setString :: proc "c" (self: ^Text, str: ^NS.String) {
    msgSend(nil, self, "setString:", str)
}

@(objc_class="NSTextView")
TextView :: struct {using _: NS.Object}

@(objc_type=TextView, objc_name="alloc", objc_is_class_method=true)
TextView_alloc :: proc "c" () -> ^TextView {
    return msgSend(^TextView, TextView, "alloc")
}
@(objc_type=TextView, objc_name="initWithFrame")
TextView_initWithFrame :: proc "c" (self: ^TextView, rect: NS.Rect) -> ^TextView {
    return msgSend(^TextView, self, "initWithFrame:", rect)
}
@(objc_type=TextView, objc_name="setBackgroundColor")
TextView_setBackgroundColor :: proc "c" (self: ^TextView, color: ^NS.Color) {
    msgSend(nil, self, "setBackgroundColor:", color)
}
@(objc_type=TextView, objc_name="backgroundColor")
TextView_backgroundColor :: proc "c" (self: ^TextView) -> ^NS.Color {
    return msgSend(^NS.Color, self, "backgroundColor")
}
@(objc_type=TextView, objc_name="setEditable")
TextView_setEditable :: proc "c" (self: ^TextView, editable: bool) {
    msgSend(nil, self, "setEditable:", editable)
}
@(objc_type=TextView, objc_name="setTextColor")
TextView_setTextColor :: proc "c" (self: ^TextView, color: ^NS.Color) {
    msgSend(nil, self, "setTextColor:", color)
}
@(objc_type=TextView, objc_name="setAutomaticQuoteSubstitutionEnabled")
TextView_setAutomaticQuoteSubstitutionEnabled :: proc "c" (self: ^TextView, enabled: bool) {
    msgSend(nil, self, "setAutomaticQuoteSubstitutionEnabled:", enabled)
}
@(objc_type=TextView, objc_name="scrollableTextView", objc_is_class_method=true)
TextView_scrollableTextView :: proc "c" () -> ^ScrollView {
    return msgSend(^ScrollView, TextView, "scrollableTextView")
}