Skip to content
elcritchPublic

About

Full featured cross platform GUI in pure Nim

Topics

Resources

Stars

40 stars

Watchers

0 watching

Forks

Repository files navigation

Merenda

Merenda banner

Merenda is a desktop GUI toolkit written in Nim, inspired by Cocoa and OpenStep. It gives you buttons, text editors, tables, menus, and layouts for building desktop apps, with themes you can change to suit your app. Its public module is called NimKit: import merenda/nimkit.

The project is under active development, targeting macOS, Linux, FreeBSD, and Windows. Kosmo, a code editor built with Merenda, is a way to try it without writing any code.

How it looks

Merenda draws its own controls, so you can use the same theme across platforms. Choose a familiar macOS look, glossy Aqua buttons, or something more colorful. DarkBSD is the default.

Merenda with the modern macOS theme Merenda with the Aqua theme Merenda with the Synthwave83 theme Merenda with the Peachy theme Merenda with the DarkBSD theme

Install and run Merenda

You'll need Nim 2.2.6 or newer, a C compiler, Git, and Atlas to install the Nim dependencies. Use a current Atlas release: the Atlas 0.14 bundled with Nim 2.2.12 cannot read merenda.nimble or the -u flag used below; Atlas 0.16.3 works. On Linux and FreeBSD, you'll also need the system libraries for your windowing and graphics backend; Merenda uses Siwin for windows and FigDraw for rendering.

To try the examples, clone the repository and run the controls showcase:

git clone https://github.com/elcritch/merenda.git
cd merenda
atlas install -tuk
nim r examples/controls_showcase.nim

The showcase lets you try the controls together in one window. To see another theme, run it with NIMKIT_THEME set (in a POSIX shell):

NIMKIT_THEME=macos nim r examples/controls_showcase.nim
NIMKIT_THEME=aqua nim r examples/controls_showcase.nim
NIMKIT_THEME=punch nim r examples/controls_showcase.nim

The built-in themes are embedded CSS stylesheets with shared widget defaults. Application CSS can extend them, and typed Nim styling APIs remain available for programmatic overrides.

The Punch theme ports RUI2’s Punch (fat) theme: warm cream, orange accents, thick ink outlines, bold uppercase buttons and hard shadows.

Use CSS for control colors, fonts, gradients, state styles, edge pinning and native layout constraints. Style StackView/GridView spacing and alignment and control-specific metrics through -nimkit-* properties. The CSS preview lays out its toolbar, sidebar and editor from a stylesheet and reloads it when you click Reload CSS:

nim r examples/css_theme_demo.nim

Read the CSS theming guide for selectors, variables, supported properties and appearance scope.

You can also describe native GUIs in lightweight markup, using HTML5 syntax and data-* attributes for native widget options:

<nk-main>
  <nk-window id="window" title="Hello" data-frame="100 100 400 240">
    <nk-stack-view id="root" data-axis="vertical" data-padding="24" data-spacing="12">
      <nk-label data-label-style="title">Hello from Merenda</nk-label>
      <nk-text-field id="name" placeholder="Your name"/>
      <button id="save" data-action="saveDocument">Save</button>
    </nk-stack-view>
  </nk-window>
</nk-main>

loadGuiResourceBundle reads the interface and instantiateResources constructs its native controls. One document can define independent windows. Built-in elements use nk-; application widgets can use names such as some-app-widget. Use IDs to look up controls and connect behavior in Nim.

Try the GUI resource example with nim ic -r examples/html_ui_demo.nim. It includes compact preferences controls, Preview and Reset actions, and a separate About window. The Appearance dropdown switches both windows between the custom stylesheet and the built-in CSS themes.

The NIF version duplicates this demo with NIF GUI markup and a NIF theme. The theme uses tagged selectors, hex colors, numbers with units, bare keywords, and shared token references. Enable its optional dependency and run it with:

atlas install -tk --feature=nifDemo
nim ic -r examples/nif_ui_demo.nim

It uses OpenParser 0.4.1 or newer with Nim 2.2.10 or newer. The demo-local adapter feeds the existing GUI and CSS loaders; see the NIF demo format for its vocabulary. The shorthand version uses native widget property nodes and line-based style rules for the same preferences window.

To use Merenda in your own project, add this dependency to your .nimble file and run atlas install -tuk from that project:

requires "merenda"

Build your app with threads enabled and ARC or ORC, for example nim r --threads:on --mm:arc main.nim. The examples in this repository already have those settings.

FigDraw is compiled into the application. Merenda's experimental useNativeDynlib mode and build_dynlib task have been removed; omit -d:useNativeDynlib from existing build commands.

Running with Dev Control Mode

Compile Merenda apps with -d:merenda.devControl for optional local screenshots and input automation. Setting MERENDA_DEV_CONTROL=1 then starts and control socket. MERENDA_BACKGROUND=1 supports automated runs without activating the app. See development control for the PID/socket CLI and protocol.

MERENDA_DEV_CONTROL=1 MERENDA_BACKGROUND=1 ./examples/controls_showcase &
app_pid=$!
./deps/merenda-dev --pid "$app_pid" windows
./deps/merenda-dev --pid "$app_pid" screenshot --window w1 --output /tmp/showcase.png

A few small apps

Hello, Merenda

A window, a label, and an application loop:

import merenda/nimkit

let
  app = sharedApplication()
  window = newWindow("Hello", frame = rect(100, 100, 360, 180))
  root = newView()
  greeting = newTitleLabel("Hello, Merenda!")

root.addSubview(greeting)
greeting.pinEdges(
  toGuide = root.contentLayoutGuide(insets(24.0)),
  edges = {leLeft, leTop, leRight},
)

app.runWindow(window, root)

Save this as examples/greeting.nim in your checkout and run nim r examples/greeting.nim.

A button that does something

Here's a counter. A stack view arranges the controls, and the button's action updates the label.

import merenda/nimkit

import sigils/selectors

let
  app = sharedApplication()
  window = newWindow("Counter", frame = rect(100, 100, 320, 220))
  root = newView()
  layout = newStackView(laVertical)
  label = newStatusLabel("Clicked 0 times")
  button = newButton("Click")
  clickAction = actionSelector("counterClicked")

var clicks = 0

proc onClick(sender: DynamicAgent) =
  if not sender.isNil:
    inc clicks
    label.text = "Clicked " & $clicks & " times"

button.target = newActionTarget(clickAction, onClick)
button.action = clickAction

layout.spacing = 12.0
layout.alignment = svaFill
layout.addArrangedSubview(label, button)

root.addSubview(layout)
layout.pinEdges(
  toGuide = root.contentLayoutGuide(insets(44.0, 44.0, 0.0, 44.0)),
  edges = {leLeft, leTop, leRight},
)

app.runWindow(window, root)

This is examples/quick_start.nim. Run it with:

nim r examples/quick_start.nim

A Markdown reader in a handful of lines

NimKit's larger controls handle more of the work for you. This app opens a Markdown file with selectable text, links, code blocks, tables, and images. The view handles scrolling and layout as you resize the window. Up/Down arrows scroll the reader, j/k select headings and paragraphs in reading order, and Enter expands or collapses the selected block's section.

import std/os
import merenda/nimkit

let
  path = absolutePath(paramStr(1))
  app = sharedApplication()
  window = newWindow(path.extractFilename(), frame = rect(120, 80, 820, 700))
  root = newView()
  viewer = newMarkdownView(readFile(path), imageBasePath = path.parentDir)

root.addSubview(viewer)
viewer.pinEdges(toGuide = root.contentLayoutGuide(insets(20.0)))

app.runWindow(window, root, viewer)

Save it as examples/reader.nim, then run nim r examples/reader.nim README.md. It expects a readable file path. For a version with a built-in sample document, run:

nim r examples/markdown_viewer_demo.nim README.md

That is the kind of efficiency NimKit aims for: you write the app's behavior, while the controls take care of text selection, focus, drawing, and layout. For an app with more interaction, try the to-do list or its table-based version, which adds row selection and drag reordering.

A table with model–view–presenter

For a small app, MVP doesn't need a class hierarchy. Here, tasks holds the model data in an ArrayController, the table and button are the view, and markDone acts as the presenter. Select a row and click Mark done: the presenter updates the model and refreshes the table.

import merenda/nimkit
import sigils/selectors

let
  app = sharedApplication()
  window = newWindow("Tasks", frame = rect(100, 100, 460, 320))
  root = newView()
  table = newTableView(frame = rect(24, 24, 412, 200))
  doneButton = newButton("Mark done", frame = rect(24, 244, 140, 32))
  tasks = newArrayController(columns = [
    modelColumn("task", "Task", "task", 260.0),
    modelColumn("state", "State", "state", 100.0),
  ])

for index, title in ["Write release notes", "Try the demo"]:
  tasks.addItem(modelItem($index, fields = [
    modelField("task", toObj(title)),
    modelField("state", toObj("To do")),
  ]))

table.bindTableView(tasks)
table.selectionMode = tsmSingle

# The presenter turns a user action into a model update.
proc markDone(sender: DynamicAgent) =
  discard sender
  let selected = tasks.selectionController().selectedIdentifier()
  if selected.len > 0:
    tasks.setValue(selected, "state", toObj("Done"))
    table.reloadData()

let doneAction = actionSelector("markTaskDone")
doneButton.target = newActionTarget(doneAction, markDone)
doneButton.action = doneAction

root.addSubview(table)
root.addSubview(doneButton)
app.runWindow(window, root, table)

Save this as examples/tasks_mvp.nim and run nim r examples/tasks_mvp.nim. The table binding supplies the columns and row values, so you only write the action specific to your app. For a larger version, see the table-based to-do app or the model controller examples.

Change a view's behavior with a protocol

Sigils protocols let you attach methods to an individual object, even when its type comes from a library. Here, an ordinary View gets a custom drawing method. Click Inspect layout to replace that method with one that displays the view's dimensions; click again to restore the preview.

import merenda/nimkit
import sigils/selectors

protocol PreviewDrawing of ViewDrawingProtocol:
  method draw(view: View, context: DrawContext) =
    context.addRectangle(view.bounds, fill(color(0.18, 0.32, 0.55)))
    context.addText(view.bounds, "Design preview", color(1, 1, 1), taCenter)

protocol LayoutDrawing of ViewDrawingProtocol:
  method draw(view: View, context: DrawContext) =
    context.addRectangle(view.bounds, fill(color(0.12, 0.22, 0.24)))
    let size = view.bounds.size
    context.addText(
      view.bounds, $size.width & " x " & $size.height, color(1, 1, 1), taCenter
    )

let
  app = sharedApplication()
  window = newWindow("Dynamic drawing", frame = rect(100, 100, 420, 260))
  root = newView()
  preview = newView(frame = rect(24, 24, 372, 140))
  button = newButton("Inspect layout", frame = rect(24, 188, 160, 32))
  inspectAction = actionSelector("toggleLayoutDrawing")

preview.withProtocol(PreviewDrawing)
var inspecting = false

proc toggleLayout(sender: DynamicAgent) =
  discard sender
  inspecting = not inspecting
  if inspecting:
    preview.withProtocol(LayoutDrawing)
  else:
    preview.withProtocol(PreviewDrawing)
  preview.needsDisplay = true

button.target = newActionTarget(inspectAction, toggleLayout)
button.action = inspectAction
root.addSubview(preview)
root.addSubview(button)
app.runWindow(window, root)

Save this as examples/protocol_drawing.nim and run nim r examples/protocol_drawing.nim.

Both implementations are compiled Nim code with typed View and DrawContext arguments. NimKit calls the drawing protocol, and Sigils dispatches to the method currently installed on preview. Replacing it leaves other views alone and keeps this view's identity, layout, and place in the window intact.

This is useful for adding diagnostics, swapping rendering strategies, or customizing a library object without introducing a subclass for every variation. The same pattern works for view controller loading and table delegates.

Slide between panels

An animationGroup turns ordinary property assignments into a coordinated animation. These two panels slide together over 800 ms with linear motion. Next slides to the details; Back reverses the trip.

import merenda/nimkit
import sigils/selectors

let
  app = sharedApplication()
  window = newWindow("Carousel", frame = rect(100, 100, 420, 260))
  root = newView()
  viewport = newView(frame = rect(24, 24, 372, 140))
  first = newGroupBox("Welcome", frame = rect(0, 0, 372, 140))
  second = newGroupBox("Details", frame = rect(372, 0, 372, 140))
  button = newButton("Next", frame = rect(24, 188, 160, 32))
  slideAction = actionSelector("slidePanels")

first.contentView = newLabel("Your first panel.")
second.contentView = newLabel("A little more information.")
viewport.clipsToBounds = true
var showingDetails = false

proc finishSlide(button: Button) {.slot.} =
  button.enabled = true

proc slidePanels(sender: DynamicAgent) =
  discard sender
  if button.enabled:
    button.enabled = false
    showingDetails = not showingDetails
    button.title = if showingDetails: "Back" else: "Next"
    let size = viewport.bounds.size
    let offset =
      if showingDetails:
        -size.width
      else:
        0.0'f32
    let slide = animationGroup(duration = 800.ms, curve = acLinear):
      first.frame = rect(offset, 0, size.width, size.height)
      second.frame = rect(offset + size.width, 0, size.width, size.height)
    slide.connect(finished, button, finishSlide)
    discard app.startAnimation(slide)

button.target = newActionTarget(slideAction, slidePanels)
button.action = slideAction
viewport.addSubview(first)
viewport.addSubview(second)
root.addSubview(viewport)
root.addSubview(button)
app.runWindow(window, root)

Run the carousel example with nim r examples/carousel_demo.nim. The viewport clips the panels as they move, and the animation's finished signal enables the button for the next transition. For more, see property animations and sequences.

Blur an overlay's background

A standard Box can blur the content behind its rounded bounds through FigDraw. Its child controls stay sharp, and its fill follows the active theme:

let overlay = newBox(frame = rect(24, 24, 320, 96))
overlay.addStyleClass(PopoverBoxStyleClass)
overlay.backdropBlurRadius = 20
overlay.backdropTintOpacity = 0.78
overlay.addContentSubview(newTextField("Search"))
root.addSubview(overlay)

Set backdropBlurRadius to zero to restore the ordinary box fill. backdropTintOpacity controls the themed tint over the blurred content, from zero to one. This effect is rendered inside the application, independently of native window backdrop effects.

Reusable search controls

NimKit apps can reuse the same controls through SearchBar or attach a TextSearchController to any host view:

let preview = newTextView("λcat and dog")
let search = newTextSearchController(
  preview, "document", textViewSearchAdapter(preview)
)
# Attach preview to a window before opening the controls.
discard search.showSearch()

Keep the controller alive while its controls are attached, and call search.close() before releasing it if the host remains alive. Custom TextSearchAdapter callbacks provide text snapshots, reveal matches, and clear selections for terminals, multiple documents, or other data sources. An optional replace callback adds collapsed replacement controls and receives a validated Reni pattern and capture template. The adapter owns edits, undo, and checking that source content is current. Hosts can route find shortcuts through search.handleSearchKey(event) and call search.refreshSearch() when their source changes.

SearchBar is a regular Box containing standard text fields and buttons, arranged with StackView. For embedded controls, add newSearchBar(frame = ...) to a container (or use layout constraints) and set hidden = false. The host chooses whether it floats; query, error, and replacement changes do not override an embedded frame. TextSearchController anchors the floating controls with ordinary layout constraints, allowing their height to follow their content.

Kosmo

Kosmo is a code editor built with Merenda and Moe's Vim-style editing engine. It brings together a file browser, split panes, terminal tabs, Markdown previews, Git diffs, and GitHub issues and PRs. You can use it on its own or explore its source to see how a larger Merenda app fits together.

In terminal tabs, Command-click a file path on macOS, or Control-click on Linux and Windows, to open it in Kosmo. Links such as src/main.nim:12:5 jump to the given line and column (both start at 1). Relative paths use the terminal's reported current directory, or its starting directory when the shell does not report one. The terminal link setting controls both file links and web links. Nim diagnostics such as src/main.nim(12, 5) and src/main.nim(12) are clickable too.

Show Git Diff keeps a separate viewer for each repository. Detach their tabs to compare projects in separate windows; invoking the shortcut again returns to that project's viewer.

In the GitHub view, expand a pull request and choose Open diff in Kosmo to compare its base branch with its head. Kosmo fetches dedicated PR refs in the background, including for fork PRs, and leaves your working branch and files intact. Fetch failures appear in the diff viewer; Refresh retries the comparison.

Install and open a project

You don't need Nim to use a prebuilt Kosmo release. Run the installer from a shell (Git Bash on Windows):

curl -fsSL https://raw.githubusercontent.com/elcritch/merenda/HEAD/install.sh | bash

On macOS, it installs Kosmo.app in ~/Applications and a kosmo command in ~/.local/bin. On Linux, FreeBSD, and Windows, the command goes in ~/.local/bin. Make sure that directory is on your PATH.

Open the current folder or a file:

kosmo .
kosmo README.md
kosmo src/main.nim:12:5
kosmo 'src/main.nim(12, 5)'

These commands reuse a running Kosmo instance. Use kosmo --bg ./folder/ to start a new instance detached from your shell. Start a new instance with kosmo --new ./folder/. kosmo -v and kosmo --version print the version and exit. On macOS, you can also open Kosmo.app from Finder.

kosmo --add ../shared-library
kosmo --bg ../shared-library

Run kosmo --help for command-line options. See the keyboard shortcut guide for navigation and Vim bindings, or release and installer details for supported builds, custom install locations, and the static Linux build.

Build from source

FigDraw 0.46.3 discovers HarfBuzz 7 or newer and FriBidi at runtime. Kosmo uses them for complex-script shaping, ligatures, and automatic system-font fallback; if either library is unavailable, it uses Pixie for text layout and rasterization. The native text libraries are optional for building and running ordinary source builds. Install harfbuzz and fribidi with Homebrew on macOS, or libharfbuzz0b and libfribidi0 on Debian/Ubuntu, to enable shaping.

Use -d:figdrawTextBackend=pixie to build with Pixie only, or -d:figdrawTextBackend=harfbuzzy to require native shaping. Bundled static builds (-d:harfbuzzyStatic) retain the Harfbuzzy backend by default.

From your Merenda checkout, install the extra Kosmo dependencies, then build and launch it:

atlas install -tuk --features:kosmo
nim c -o:kosmo src/merenda/kosmo/kosmo.nim
./kosmo .

On Windows, use MinGW-w64 GCC. CI and releases use MSYS2's UCRT64 environment:

pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-harfbuzz mingw-w64-ucrt-x86_64-fribidi
nim c --cc:gcc -o:kosmo.exe src/merenda/kosmo/kosmo.nim
./kosmo.exe .

Keep the UCRT64 bin directory on PATH for the compiler and optional text DLLs. Release archives bundle the text DLLs and their dependencies, and retain native debug information inside kosmo.exe.

On macOS, install the current checkout as a complete Kosmo.app with its icon, bundled notices, debug symbols, and local code signature:

brew install meson ninja pkg-config
nim install_kosmo

This uses Atlas to resolve dependencies, replaces ~/Applications/Kosmo.app, and updates the ~/.local/bin/kosmo link. Restart Kosmo if it was already running. Set KOSMO_INSTALL_DIR or KOSMO_BIN_DIR to choose other locations. Like CI, this builds shared HarfBuzz and FriBidi libraries for macOS 13, bundles them in Contents/Frameworks, and loads them at runtime. The installed app does not require Homebrew at runtime.

Explore more

The examples directory has complete apps you can run and change. These are good places to go once you've tried the basics:

Tekton interface builder

Build a resource document with Tekton's widget palette, property inspector, and live preview. Add layout guides and constraints, pin views to their parent, undo changes, and save the result for your Merenda app. Use Interact to try controls in the preview.

nim r src/merenda/tekton.nim
# Or open an existing interface:
nim r src/merenda/tekton.nim path/to/interface.cbor

See Tekton's authoring workflow for supported resources and how to load the saved interface in your app.

About

Full featured cross platform GUI in pure Nim

Topics

Resources

Stars

40 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages