diff --git a/.github/workflows/blank.yml b/.github/workflows/blank.yml new file mode 100644 index 0000000..c42b410 --- /dev/null +++ b/.github/workflows/blank.yml @@ -0,0 +1,49 @@ +name: Compile Fenstim Examples + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Set up Nim + uses: jiro4989/setup-nim-action@v1 + with: + nim-version: 'stable' + + - name: Install dependencies on Ubuntu + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y libasound2-dev + shell: bash + + - name: Create build directory + run: mkdir build + shell: bash + + - name: Compile Examples + run: | + for file in examples/*.nim; do + filename=$(basename "$file" .nim) + echo "Compiling $filename" + nim c -f -d:danger -d:lto -d:strip --mm:arc --passC:"-march=native" -o:"build/${filename}_${{ runner.os }}" "$file" + done + shell: bash + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: fenstim-examples-${{ runner.os }} + path: build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2290267 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/fensterb"] + path = src/fensterb + url = https://github.com/CardealRusso/fensterb/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d99c81b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Cardeal Russo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae28968 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Fenstim +Fenstim is a Nim wrapper for [Fenster](https://github.com/zserge/fenster), the most minimal cross-platform GUI with 2D canvas library. It provides a simple and efficient way to create graphical applications in Nim. + +# Implementation status +- [x] Minimal 24-bit RGB framebuffer. +- [x] Application lifecycle and system events are all handled automatically. +- [x] Simple polling API without a need for callbacks or multithreading (like Arduino/Processing). +- [x] Cross-platform keyboard events (keycodes). +- [x] Cross-platform mouse events (X/Y + mouse click). +- [x] Cross-platform timers to have a stable FPS rate. (builtin) +- [x] Cross-platform audio playback (WinMM, CoreAudio, ALSA). + +# Credits +Project done in collaboration with @ElegantBeef and @morturo at https://forum.nim-lang.org/t/12504 + +# Examples +Basic usage +```nim +import fenstim, colors + +var app = init(Fenster, "My Window", 800, 600, 60) + +if app.loop: + echo "Window target FPS: ", app.targetFps, ", Resolution: ", app.width, "x", app.height + +while app.loop: + # Set pixel color + app.pixel(400, 300) = 16711680 # Decimal red + app.pixel(420, 300) = 0x0000FF # Hex blue + pixel(app, 410, 300) = 0x00ff00 # Hex green + + # With colors module + app.pixel(390, 300) = rgb(255, 0, 0).uint32 # Red + app.pixel(380, 300) = parseColor("#00FF00").uint32 # Green + app.pixel(370, 300) = colAliceBlue.uint32 # Alice Blue + app.pixel(360, 300) = parseColor("silver").uint32 # Silver + + # Get pixel color + let color = app.pixel(420, 300) # Decimal + + # Check key press if A key is pressed + if app.keys[ord('A')] == 1: + echo "A key is pressed" + + # Check if scape key is pressed + if keys(app)[27] == 1: + app.close + break + + # Get mouse position and click state + if app.mouse.mclick[0] == 1: + echo "Clicked at: ", app.mouse.pos.x, "x", app.mouse.pos.y + + # Adjust FPS + app.targetFps = 30 +``` + +Opens a 60fps 800x600 window, draws a red square and exits when pressing the Escape key: + +```nim +# examples/red_square.nim +import fenstim + +var app = init(Fenster, "Red square with fenstim", 800, 600, 60) + +while app.loop and app.keys[27] == 0: + for x in 350 .. 450: + for y in 250 .. 350: + app.pixel(x, y) = 0xFF0000 +``` + +# API usage +### Initialization +```nim +init*(_: type Fenster, title: string, width, height: int, fps: int = 60): Fenster +``` +Creates a new Fenster window with the specified title, dimensions, and target FPS. + +### Main Loop +```nim +loop*(self: var Fenster): bool +``` +Handles events and updates the display. Returns false when the window should close. + +### Pixel Manipulation +```nim +pixel*(self: Fenster, x, y: int): uint32 +``` +Get or set a uint32 pixel color at (x, y). + +### Window Properties +```nim +width*(self: Fenster): int +height*(self: Fenster): int +targetFps*(self: Fenster): int +close*(self: var Fenster) +clear*(self: Fenster) +``` + +### Input Handling +```nim +keys*(self: Fenster): array[256, cint] +mouse*(self: Fenster): tuple[pos: tuple[x, y: int], mclick: array[5, cint], mhold: array[3, cint]] +modkey*(self: Fenster): int +``` +keys = Array of key states. Index corresponds to ASCII value (0-255), but arrows are 17..20. +mouse = Get mouse position (x, y), clicked (left, right, middle, scroll up, scroll down) and holding buttons (left, right, middle) +modkey = 4 bits mask, ctrl=1, shift=2, alt=4, meta=8 + +# Examples +### Galery +![lca1](https://github.com/user-attachments/assets/4da9fa7f-e201-4f18-a262-d53fcbdb0380) +![lca2](https://github.com/user-attachments/assets/9a8654af-e5fc-4b1e-9c3f-2eeaf2bf2016) +![lca3](https://github.com/user-attachments/assets/c2fe1c8f-b138-491f-be7b-0baa1f553259) +![lca5](https://github.com/user-attachments/assets/73e1280f-a988-4190-bf6e-1f94fee1d8d9) +![lca4](https://github.com/user-attachments/assets/93b30453-de4e-4952-8729-87f149b02a13) +[interactive_julia_set.nim](examples/interactive_julia_set.nim) +![ezgif-2-1a773886a7](https://github.com/user-attachments/assets/95116b60-cdf0-4308-9f90-593e97bd60d0) +[mandelbrot.nim](examples/mandelbrot.nim) +[threaded_mandelbrot.nim](examples/threaded_mandelbrot.nim) diff --git a/examples/audio.nim b/examples/audio.nim new file mode 100644 index 0000000..8b31671 --- /dev/null +++ b/examples/audio.nim @@ -0,0 +1,25 @@ +import fenstim, fenstim_audio + +var + app = init(Fenster, "Audio Example", 320, 240, 60) + app_audio = init(FensterAudio) + t, u = 0 + +proc generateAudio(n: int): seq[float32] = + result = newSeq[float32](n) + for i in 0.. 0: + let audio = generateAudio(n) + app_audio.write(audio) + + for i in 0.. 0: + app_audio.write(generateAudio(app_audio.available)) + + for i in 0.. 4: return i + + zy = 2*zx*zy + cy + zx = zx2 - zy2 + cx + + return 0 + +while app.loop and app.keys[27] == 0: + let (mouseX, mouseY) = app.mouse.pos + if (mouseX, mouseY) != oldpos: + oldpos = (mouseX, mouseY) + cx = mouseX.float32 / app.width.float32 * 4 - 2 + cy = mouseY.float32 / app.height.float32 * 4 - 2 + + for px in 0.. 4.0: return i + zy = 2.0 * zx * zy + y + zx = zx2 - zy2 + x + zx2 = zx * zx + zy2 = zy * zy + + return maxIter + +proc findInterestingArea(currX, currY, currZoom: float64): tuple[x, y: float64] = + const searchRadius = 2.0 + const samples = 10 + var bestX: float64 = currX + var bestY: float64 = currY + var bestScore = 0 + + for _ in 0.. 10 and iterations < maxIterations-10: + let score = maxIterations - abs(iterations - maxIterations div 2) + if score > bestScore: + bestScore = score + bestX = x + bestY = y + + if bestScore == 0: + # If no interesting point found, slightly move in a random direction + let angle = rand(0.0..2*PI) + bestX = currX + cos(angle) * (0.1 / currZoom) + bestY = currY + sin(angle) * (0.1 / currZoom) + + return (bestX, bestY) + +proc draw() = + for px in 0.. 4.0: return i + zy = 2.0 * zx * zy + y + zx = zx2 - zy2 + x + zx2 = zx * zx + zy2 = zy * zy + + return maxIter + +proc findInterestingArea(currX, currY, currZoom: float64): tuple[x, y: float64] = + const searchRadius = 2.0 + const samples = 10 + var bestX: float64 = currX + var bestY: float64 = currY + var bestScore = 0 + + for _ in 0.. 10 and iterations < maxIterations.load-10: + let score = maxIterations.load - abs(iterations - maxIterations.load div 2) + if score > bestScore: + bestScore = score + bestX = x + bestY = y + + if bestScore == 0: + let angle = rand(0.0..2*PI) + bestX = currX + cos(angle) * (0.1 / currZoom) + bestY = currY + sin(angle) * (0.1 / currZoom) + + return (bestX, bestY) + +proc drawSection(arg: ThreadArg) {.thread.} = + let (startY, endY) = arg + let maxIter = maxIterations.load + + {.cast(gcsafe).}: + for py in startY..= 1.0.0" + +srcDir = "src" diff --git a/src/fensterb b/src/fensterb new file mode 160000 index 0000000..efc299b --- /dev/null +++ b/src/fensterb @@ -0,0 +1 @@ +Subproject commit efc299b346b4b8cc4c9bc00873e6b7ea1b755164 diff --git a/src/fenstim.nim b/src/fenstim.nim new file mode 100644 index 0000000..94674c3 --- /dev/null +++ b/src/fenstim.nim @@ -0,0 +1,123 @@ +import os + +const fensterHeader = currentSourcePath().parentDir() / "fensterb/src/fenster/fenster.h" + +when defined(linux): {.passl: "-lX11".} +elif defined(windows): {.passl: "-lgdi32".} +elif defined(macosx): {.passl: "-framework Cocoa".} + +{.passC: "-Ivendor".} + +type + FensterStruct = object + title*: cstring + width*: cint + height*: cint + buf*: ptr UncheckedArray[uint32] + keys*: array[256, cint] + modkey*: cint + x*: cint + y*: cint + mclick: array[5, cint] + mhold: array[3, cint] + + Fenster* = object + raw: ptr FensterStruct + targetFps*: int + lastFrameTime: int64 + fps*: float + +{.push importc, header: fensterHeader.} +proc fenster_open(fenster: ptr FensterStruct): cint +proc fenster_loop(fenster: ptr FensterStruct): cint +proc fenster_close(fenster: ptr FensterStruct) +proc fenster_sleep(ms: cint) +proc fenster_time(): int64 +{.pop.} + +proc close*(self: var Fenster) = + fenster_close(self.raw) + dealloc(self.raw.buf) + dealloc(self.raw) + self.raw = nil + +proc `=destroy`(self: Fenster) = + if self.raw != nil: + fenster_close(self.raw) + dealloc(self.raw.buf) + dealloc(self.raw) + +proc init*(_: type Fenster, title: string, width, height: int, fps: int = 60): Fenster = + result = Fenster() + + result.raw = cast[ptr FensterStruct](alloc0(sizeof(FensterStruct))) + result.raw.title = cstring(title) + result.raw.width = cint(width) + result.raw.height = cint(height) + result.raw.buf = + cast[ptr UncheckedArray[uint32]](alloc(width * height * sizeof(uint32))) + + result.targetFps = fps + result.lastFrameTime = fenster_time() + + discard fenster_open(result.raw) + +proc loop*(self: var Fenster): bool = + let frameTime = 1000 div self.targetFps + let currentTime = fenster_time() + let elapsedTime = currentTime - self.lastFrameTime + + if elapsedTime < frameTime: + fenster_sleep((frameTime - elapsedTime).cint) + + if elapsedTime > 0: + self.fps = 1000.0 / elapsedTime.float + + self.lastFrameTime = fenster_time() + result = fenster_loop(self.raw) == 0 + +template pixel*(self: Fenster, x, y: int): uint32 = self.raw.buf[y * self.raw.width + x] +template width*(self: Fenster): int = self.raw.width.int +template height*(self: Fenster): int = self.raw.height.int +template keys*(self: Fenster): array[256, cint] = self.raw.keys +template modkey*(self: Fenster): int = self.raw.modkey.int +template mouse*(self: Fenster): tuple[pos: tuple[x, y: int], mclick: array[5, cint], mhold: array[3, cint]] = + ( + pos: (x: self.raw.x.int, y: self.raw.y.int), + mclick: self.raw.mclick, + mhold: self.raw.mhold + ) +proc sleep*(self: Fenster, ms: int) = fenster_sleep(ms.cint) +proc time*(self: Fenster): int64 = fenster_time() + +#Below are functions that are not part of Fenster +template clear*(self: Fenster) = zeroMem(self.raw.buf, self.raw.width.int * self.raw.height.int * sizeof(uint32)) +proc getFonts*(self: Fenster): seq[string] = + let searchPatterns = when defined(linux): + @[ + expandTilde("~/.local/share/fonts/**/*.ttf"), + expandTilde("~/.fonts/**/*.ttf"), + "/usr/*/fonts/**/*.ttf", + "/usr/*/*/fonts/**/*.ttf", + "/usr/*/*/*/fonts/**/*.ttf", + "/usr/*/*/*/*/fonts/**/*.ttf" + ] + elif defined(macosx): + @[ + expandTilde("~/Library/Fonts/**/*.ttf"), + "/Library/Fonts/**/*.ttf", + "/System/Library/Fonts/**/*.ttf", + "/Network/Library/Fonts/**/*.ttf" + ] + elif defined(windows): + @[ + getEnv("SYSTEMROOT") & r"\Fonts\*.ttf", + getEnv("LOCALAPPDATA") & r"\Microsoft\Windows\Fonts\*.ttf" + ] + else: + @[] + + result = newSeq[string]() + for pattern in searchPatterns: + for entry in walkPattern(pattern): + result.add(entry) diff --git a/src/fenstim_audio.nim b/src/fenstim_audio.nim new file mode 100644 index 0000000..7310eb9 --- /dev/null +++ b/src/fenstim_audio.nim @@ -0,0 +1,46 @@ +import os + +const fensterAudioHeader = currentSourcePath().parentDir() / "fensterb/src/fenster_audio/fenster_audio.h" + +when defined(linux): {.passL: "-lasound".} +elif defined(windows): {.passL: "-lwinmm".} +elif defined(macosx): {.passL: "-framework AudioToolbox".} + +{.passC: "-Ivendor".} + +const FENSTER_AUDIO_BUFSZ = 8192 + +type + FensterAudioStruct = object + audio_data: pointer + buf: array[FENSTER_AUDIO_BUFSZ, float32] + pos: csize_t + + FensterAudio* = object + raw: ptr FensterAudioStruct + +{.push importc, header: fensterAudioHeader.} +proc fenster_audio_open(fa: ptr FensterAudioStruct): cint +proc fenster_audio_available(fa: ptr FensterAudioStruct): cint +proc fenster_audio_write(fa: ptr FensterAudioStruct, buf: ptr float32, n: csize_t) +proc fenster_audio_close(fa: ptr FensterAudioStruct) +{.pop.} + +proc close*(self: var FensterAudio) = + fenster_audio_close(self.raw) + dealloc(self.raw) + self.raw = nil + +proc `=destroy`(self: FensterAudio) = + if self.raw != nil: + fenster_audio_close(self.raw) + dealloc(self.raw) + +proc init*(_: type FensterAudio): FensterAudio = + result = FensterAudio() + result.raw = cast[ptr FensterAudioStruct](alloc0(sizeof(FensterAudioStruct))) + discard fenster_audio_open(result.raw) + +proc available*(self: FensterAudio): int = fenster_audio_available(self.raw).int + +proc write*(self: FensterAudio, buf: openArray[float32]) = fenster_audio_write(self.raw, unsafeAddr buf[0], buf.len.csize_t)