diff --git a/flake.nix b/flake.nix index 5ded085..8917af7 100644 --- a/flake.nix +++ b/flake.nix @@ -18,7 +18,7 @@ let pkgs = import nixpkgs { inherit system; - config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave"; + config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "radiantwave"; }; buildInputs = with pkgs; [ @@ -27,8 +27,8 @@ SDL2_image SDL2_mixer sqlite - dbus ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + dbus libGL wayland wayland-protocols @@ -36,15 +36,7 @@ libdecor libx11 libxext - ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk.frameworks; [ - OpenGL - CoreAudio - CoreFoundation - CoreServices - ForceFeedback - IOKit - AppKit - ]); + ]; # Darwin: system SDK provides OpenGL, AppKit, etc. automatically nativeBuildInputs = with pkgs; [ go diff --git a/internal/page/AudioOptions.go b/internal/page/AudioOptions.go index ecb9a08..aa48357 100644 --- a/internal/page/AudioOptions.go +++ b/internal/page/AudioOptions.go @@ -15,11 +15,8 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -// focusToggle is the sentinel focusState value meaning the audio on/off toggle -// row has keyboard focus. Audio-file indices (0..n-1) are used for everything below. const focusToggle = -1 -// AudioFileList is kept as the canonical seed data for the audio_files table. var AudioFileList = []db.AudioFile{ {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1}, {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2}, @@ -40,28 +37,22 @@ var AudioFileList = []db.AudioFile{ const triangleSize = float32(16) type AudioOptions struct { - // Page Attributes Base font *fontManager.FontEntry textShader *shaderManager.Shader solidShader *shaderManager.Shader - // Page Textures title StringItem prompt StringItem - // Layout verticalMargin int32 ItemScale float32 - // Audio toggle AudioStatusToggle StringItem - // Audio file list — index 0 = "Dynamic Audio", index 1+ = AudioFileList entries audioFileTextures []StringItem - selectedFileIndex int // which item is currently selected (highlights green) + selectedFileIndex int - // Volume row VolumeLabel StringItem volumePercent StringItem volumeBarX int32 @@ -69,25 +60,46 @@ type AudioOptions struct { volumeBarW int32 volumeBarH int32 - // Triangle cursor triVAO uint32 triVBO uint32 triX float32 triCenterY float32 spinElapsed float32 - // Focus: focusToggle (-1) = toggle row, 0..n-1 = audio file list item focusState int -} -// ── Init ────────────────────────────────────────────────────────────────────── + // Section header + sectionHeader StringItem + + // Scrolling + scrollOffset int + maxVisible int + listItemMargin int32 + listAreaTop int32 + listAreaBottom int32 + listItemHeight int32 + scrollTrackWidth int32 + scrollTrackX int32 + scrollTrackY int32 + scrollTrackH int32 + + // Selection dot + dotSize float32 + + // List left-alignment X (same for all items) + listLeftX int32 +} func (p *AudioOptions) Init(ctx *PageCtx) error { var err error p.verticalMargin = int32(20) + p.listItemMargin = 6 p.focusState = focusToggle + p.scrollOffset = 0 + p.dotSize = 8.0 + p.scrollTrackWidth = 30 + p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth - // Font fm := fontManager.Get() font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") if !ok { @@ -95,7 +107,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { } p.font = font - // Shaders sm := shaderManager.Get() p.textShader, ok = sm.Get("text") if !ok { @@ -119,14 +130,12 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { titleScale := float32(64) / float32(baseFontSize) p.ItemScale = float32(32) / float32(baseFontSize) - // Title p.title, err = NewStringItem("Audio Options", p.font, colors.White) if err != nil { return fmt.Errorf("creating title: %w", err) } p.title.Scale(titleScale) - // Prompt p.prompt, err = NewStringItem( "Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back", p.font, colors.White, @@ -136,7 +145,6 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { } p.prompt.Scale(p.ItemScale) - // Volume row p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) if err != nil { return fmt.Errorf("creating volume label: %w", err) @@ -146,20 +154,16 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") } - // Audio toggle if err = p.CreateAudioStatusItem(); err != nil { logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") } - // Make sure the audio_files table is populated. if err = p.seedAudioFiles(); err != nil { logger.Error("failed to seed audio files: %v", err) } - // Build the audio file list textures: "Dynamic Audio" + AudioFileList p.buildAudioFileList() - // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords triVerts := []float32{ 0.5, 0.0, -0.5, 0.5, @@ -175,20 +179,22 @@ func (p *AudioOptions) Init(ctx *PageCtx) error { gl.BindBuffer(gl.ARRAY_BUFFER, 0) gl.BindVertexArray(0) + p.sectionHeader, err = NewStringItem("Audio Track", p.font, colors.Silver) + if err != nil { + return fmt.Errorf("creating section header: %w", err) + } + p.sectionHeader.Scale(p.ItemScale * 1.2) + p.layout() return nil } -// buildAudioFileList creates textures for the audio file list. -// Index 0 = "Dynamic Audio", index 1+ = AudioFileList entries. func (p *AudioOptions) buildAudioFileList() { - // Clear old textures for i := range p.audioFileTextures { gl.DeleteTextures(1, &p.audioFileTextures[i].ID) } p.audioFileTextures = nil - // "Dynamic Audio" entry at index 0 dynamicItem, err := NewStringItem("Dynamic Audio", p.font, colors.White) if err != nil { logger.Error("failed to create Dynamic Audio string item: %v", err) @@ -197,7 +203,6 @@ func (p *AudioOptions) buildAudioFileList() { p.audioFileTextures = append(p.audioFileTextures, dynamicItem) } - // AudioFileList entries at index 1+ for _, af := range AudioFileList { item, err := NewStringItem(af.Name, p.font, colors.White) if err != nil { @@ -208,15 +213,13 @@ func (p *AudioOptions) buildAudioFileList() { p.audioFileTextures = append(p.audioFileTextures, item) } - // Determine which item is currently selected p.updateSelectedIndex() } -// updateSelectedIndex sets selectedFileIndex based on the DB state. func (p *AudioOptions) updateSelectedIndex() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { - p.selectedFileIndex = 0 // default to Dynamic Audio + p.selectedFileIndex = 0 return } @@ -225,27 +228,22 @@ func (p *AudioOptions) updateSelectedIndex() { return } - // Find the audio file with Selected = true var selected db.AudioFile if err := p.Context.DbPtr.Where("selected = ?", true).First(&selected).Error; err != nil { - // No selection found — default to Dynamic Audio p.selectedFileIndex = 0 return } - // Map the selected AudioFile ID to its index in the list for i, af := range AudioFileList { if af.ID == selected.ID { - p.selectedFileIndex = i + 1 // +1 because index 0 is "Dynamic Audio" + p.selectedFileIndex = i + 1 return } } - // Fallback — file not in list p.selectedFileIndex = 0 } -// seedAudioFiles ensures the audio_files table has its default rows. func (p *AudioOptions) seedAudioFiles() error { var existing []db.AudioFile if err := p.Context.DbPtr.Find(&existing).Error; err != nil { @@ -257,8 +255,6 @@ func (p *AudioOptions) seedAudioFiles() error { return nil } -// ── Audio status toggle ─────────────────────────────────────────────────────── - func (p *AudioOptions) CreateAudioStatusItem() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -280,9 +276,6 @@ func (p *AudioOptions) CreateAudioStatusItem() error { return nil } -// ── Audio file selection ───────────────────────────────────────────────────── - -// selectAudioFile handles the user pressing Enter on an audio file list item. func (p *AudioOptions) selectAudioFile(index int) { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -290,18 +283,13 @@ func (p *AudioOptions) selectAudioFile(index int) { } if index == 0 { - // "Dynamic Audio" selected config.DynamicAudio = true - // Clear all file selections p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false) } else { - // Specific audio file selected config.DynamicAudio = false - fileIndex := index - 1 // offset for "Dynamic Audio" entry + fileIndex := index - 1 if fileIndex >= 0 && fileIndex < len(AudioFileList) { - // Clear all selections first p.Context.DbPtr.Model(&db.AudioFile{}).Where("1 = 1").Update("selected", false) - // Select the chosen file p.Context.DbPtr.Model(&db.AudioFile{}).Where("id = ?", AudioFileList[fileIndex].ID).Update("selected", true) } } @@ -313,8 +301,6 @@ func (p *AudioOptions) selectAudioFile(index int) { p.selectedFileIndex = index } -// ── Volume ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) CreateVolumePercentItem() error { if p.volumePercent.ID != 0 { gl.DeleteTextures(1, &p.volumePercent.ID) @@ -358,8 +344,6 @@ func (p *AudioOptions) UpdateVolume(level int) { p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 } -// ── Layout ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) layout() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -392,25 +376,72 @@ func (p *AudioOptions) layout() { p.volumePercent.X = barX + barW + gap p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 - // Audio toggle — centred below the volume row + // Audio toggle p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H - // Audio file list — each item centred and stacked below the toggle - nextY := p.AudioStatusToggle.Y - p.verticalMargin - for i := range p.audioFileTextures { - item := &p.audioFileTextures[i] - item.X = p.ScreenCenterX - item.W/2 - item.Y = nextY - item.H - nextY = item.Y - p.verticalMargin + // Section header — sits above the toggle, marks the start of the audio track list + p.sectionHeader.X = p.ScreenCenterX - p.sectionHeader.W/2 + p.sectionHeader.Y = p.AudioStatusToggle.Y - p.verticalMargin - p.sectionHeader.H + + // Scrollable list area boundaries + p.listItemHeight = p.audioFileTextures[0].H + listTop := p.sectionHeader.Y - p.listItemMargin + listBottomMargin := int32(12) + + // Available height for the list + listH := listTop - listBottomMargin + if listH < p.listItemHeight { + listH = p.listItemHeight } - // Reposition the triangle cursor for whatever row currently has focus + p.listAreaTop = listTop + p.listAreaBottom = listBottomMargin + + // How many items fit + itemStep := p.listItemHeight + p.listItemMargin + n := len(p.audioFileTextures) + p.maxVisible = int(listH / itemStep) + if p.maxVisible < 1 { + p.maxVisible = 1 + } + if p.maxVisible > n { + p.maxVisible = n + } + + // Compute left alignment: find the widest item to center the block + var maxItemW int32 + for i := range p.audioFileTextures { + if p.audioFileTextures[i].W > maxItemW { + maxItemW = p.audioFileTextures[i].W + } + } + p.listLeftX = p.ScreenCenterX - maxItemW/2 + + // Position each item — scrollOffset shifts the whole list up/down + firstVisibleY := p.listAreaTop - p.listItemHeight + for i := range p.audioFileTextures { + item := &p.audioFileTextures[i] + item.X = p.listLeftX + item.Y = firstVisibleY - int32(i-p.scrollOffset)*itemStep + } + + // Clamp scroll offset so we never show empty space + maxScroll := n - p.maxVisible + if maxScroll < 0 { + maxScroll = 0 + } + if p.scrollOffset > maxScroll { + p.scrollOffset = maxScroll + } + + // Scrollbar track position + p.scrollTrackY = p.listAreaBottom + p.scrollTrackH = p.listAreaTop - p.listAreaBottom + p.updateCursorPosition() } -// updateCursorPosition moves triX / triCenterY to sit just left of the -// focused row. Call this any time focusState changes or layout() reruns. func (p *AudioOptions) updateCursorPosition() { cursorGap := int32(12) @@ -428,8 +459,6 @@ func (p *AudioOptions) updateCursorPosition() { p.triCenterY = float32(item.Y) + float32(item.H)/2 } -// ── Toggle ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) ToggleAudio() { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -442,32 +471,38 @@ func (p *AudioOptions) ToggleAudio() { p.layout() } -// ── Events ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) HandleEvent(event *sdl.Event) error { e, ok := (*event).(*sdl.KeyboardEvent) if !ok || e.Type != sdl.KEYDOWN { return nil } + n := len(p.audioFileTextures) + switch e.Keysym.Sym { case sdl.K_RETURN: if p.focusState == focusToggle { p.ToggleAudio() - } else if p.focusState >= 0 && p.focusState < len(p.audioFileTextures) { + } else if p.focusState >= 0 && p.focusState < n { p.selectAudioFile(p.focusState) p.updateCursorPosition() } case sdl.K_DOWN: if p.focusState == focusToggle { - if len(p.audioFileTextures) > 0 { + if n > 0 { p.focusState = 0 + p.scrollOffset = 0 + p.layout() + } + } else if p.focusState < n-1 { + p.focusState++ + if p.focusState >= p.scrollOffset+p.maxVisible { + p.scrollOffset++ + p.layout() + } else { p.updateCursorPosition() } - } else if p.focusState < len(p.audioFileTextures)-1 { - p.focusState++ - p.updateCursorPosition() } case sdl.K_UP: @@ -476,7 +511,35 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { p.updateCursorPosition() } else if p.focusState > 0 { p.focusState-- - p.updateCursorPosition() + if p.focusState < p.scrollOffset { + p.scrollOffset-- + p.layout() + } else { + p.updateCursorPosition() + } + } + + case sdl.K_PAGEUP: + if p.focusState > 0 { + p.focusState -= p.maxVisible - 1 + if p.focusState < 0 { + p.focusState = 0 + } + p.scrollOffset = p.focusState + p.layout() + } + + case sdl.K_PAGEDOWN: + if p.focusState < n-1 { + p.focusState += p.maxVisible - 1 + if p.focusState >= n { + p.focusState = n - 1 + } + p.scrollOffset = p.focusState - p.maxVisible + 1 + if p.scrollOffset < 0 { + p.scrollOffset = 0 + } + p.layout() } case sdl.K_RIGHT: @@ -492,8 +555,6 @@ func (p *AudioOptions) HandleEvent(event *sdl.Event) error { return nil } -// ── Update ──────────────────────────────────────────────────────────────────── - func (p *AudioOptions) Update(dt float32) error { p.spinElapsed += dt if p.spinElapsed >= 1.0 { @@ -502,8 +563,6 @@ func (p *AudioOptions) Update(dt float32) error { return nil } -// ── Render helpers ──────────────────────────────────────────────────────────── - func (p *AudioOptions) renderVolumeBar() error { config, err := db.GetConfig(p.Context.DbPtr) if err != nil { @@ -543,7 +602,6 @@ func (p *AudioOptions) renderVolumeBar() error { return nil } -// renderCursor draws the spinning triangle cursor next to the focused row. func (p *AudioOptions) renderCursor() error { scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) @@ -570,7 +628,33 @@ func (p *AudioOptions) renderCursor() error { return nil } -// ── Render ──────────────────────────────────────────────────────────────────── +func (p *AudioOptions) renderScrollbar() { + n := len(p.audioFileTextures) + if n <= p.maxVisible { + return + } + + trackColor := sdl.Color{R: 255, G: 255, B: 255, A: 30} + thumbColor := sdl.Color{R: 255, G: 255, B: 255, A: 100} + + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)}, + mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackH)}, + trackColor) + + visibleRatio := float32(p.maxVisible) / float32(n) + scrollRatio := float32(p.scrollOffset) / float32(n) + thumbHeight := float32(p.scrollTrackH) * visibleRatio + if thumbHeight < 20 { + thumbHeight = 20 + } + thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackH)*scrollRatio + + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY}, + mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight}, + thumbColor) +} func (p *AudioOptions) Render() error { if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil { @@ -580,7 +664,6 @@ func (p *AudioOptions) Render() error { return fmt.Errorf("render prompt: %w", err) } - // Volume row if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { return fmt.Errorf("render volume label: %w", err) } @@ -591,23 +674,52 @@ func (p *AudioOptions) Render() error { return fmt.Errorf("render volume percent: %w", err) } - // Toggle if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { return fmt.Errorf("render audio toggle: %w", err) } - // Audio file list - for i := range p.audioFileTextures { + // Section header + p.RenderTexture(p.textShader, p.sectionHeader.ID, p.sectionHeader.Position(), p.sectionHeader.RawDimensions(), p.sectionHeader.Color) + + // List background + listBgColor := sdl.Color{R: 255, G: 255, B: 255, A: 12} + bgPad := int32(16) + bgLeft := float32(p.listLeftX - bgPad) + bgTop := float32(p.listAreaBottom) + bgRight := float32(p.scrollTrackX) + bgBottom := float32(p.listAreaTop) + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{bgLeft, bgTop}, + mgl32.Vec2{bgRight - bgLeft, bgBottom - bgTop}, + listBgColor) + + // Audio file list (visible window only) + for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.audioFileTextures); i++ { item := &p.audioFileTextures[i] - color := colors.White + clr := colors.White if i == p.selectedFileIndex { - color = colors.Green + clr = colors.Green } - if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil { + + if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), clr); err != nil { return fmt.Errorf("render audio file %d: %w", i, err) } + + // Selection dot — small filled circle left of the item name + if i == p.selectedFileIndex { + dotX := float32(item.X) - p.dotSize - 6 + dotY := float32(item.Y) + float32(item.H)/2 - p.dotSize/2 + p.RenderSolidColorQuad(p.solidShader, + mgl32.Vec2{dotX, dotY}, + mgl32.Vec2{p.dotSize, p.dotSize}, + colors.Green) + } + } + // Scrollbar + p.renderScrollbar() + // Spinning triangle cursor — always on top if err := p.renderCursor(); err != nil { return fmt.Errorf("render cursor: %w", err) @@ -616,14 +728,13 @@ func (p *AudioOptions) Render() error { return nil } -// ── Destroy ─────────────────────────────────────────────────────────────────── - func (p *AudioOptions) Destroy() error { gl.DeleteTextures(1, &p.title.ID) gl.DeleteTextures(1, &p.prompt.ID) gl.DeleteTextures(1, &p.VolumeLabel.ID) gl.DeleteTextures(1, &p.volumePercent.ID) gl.DeleteTextures(1, &p.AudioStatusToggle.ID) + gl.DeleteTextures(1, &p.sectionHeader.ID) for i := range p.audioFileTextures { gl.DeleteTextures(1, &p.audioFileTextures[i].ID) } diff --git a/internal/page/home.go b/internal/page/home.go index 087828b..b8d30d8 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -13,7 +13,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "07112026-1710" +var GitVersion = "07292026_1445" type Home struct { Base diff --git a/scripts/setup-debian.sh b/scripts/setup-debian.sh new file mode 100755 index 0000000..0ffc806 --- /dev/null +++ b/scripts/setup-debian.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== Debian dev environment setup ===" + +# Update package index +sudo apt-get update + +# --- Core dev & build tools --- +sudo apt-get install -y \ + curl \ + g++ \ + gcc \ + git \ + make \ + cmake \ + ninja-build \ + pkg-config + +# --- SDL2 and all official extension libraries --- +sudo apt-get install -y \ + libsdl2-dev \ + libsdl2-mixer-dev \ + libsdl2-net-dev \ + libsdl2-ttf-dev \ + libsdl2-image-dev \ + libsdl2-gfx-dev + +# --- SDL3 (available in Debian testing/sid, but not bookworm) --- +# If this fails, SDL3 isn't in your Debian release yet. +# See https://github.com/libsdl-org/SDL for building from source. +sudo apt-get install -y libsdl3-dev || echo "SDL3 not found in apt (expected on older Debian releases)" + +# --- Go --- +if ! command -v go &>/dev/null; then + echo "Installing Go..." + GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text | head -1) + GO_TAR="${GO_VERSION}.linux-amd64.tar.gz" + curl -sLO "https://go.dev/dl/${GO_TAR}" + sudo rm -rf /usr/local/go + sudo tar -C /usr/local -xzf "${GO_TAR}" + rm "${GO_TAR}" + export PATH=$PATH:/usr/local/go/bin + if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then + echo 'export PATH=$PATH:/usr/local/go/bin' >> "$HOME/.profile" + fi +else + echo "Go already installed: $(go version)" +fi + +# --- Nix (multi-user install) --- +if ! command -v nix &>/dev/null; then + echo "Installing Nix..." + sh <(curl -sL https://nixos.org/nix/install) --daemon + echo "Nix installed. You may need to log out and back in, or run:" + echo " . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" +else + echo "Nix already installed: $(nix --version)" +fi + +echo "" +echo "=== Setup complete ===" +echo "Installed: gcc, g++, make, cmake, ninja, pkg-config" +echo "Installed: SDL2 + mixer/net/ttf/image/gfx" +echo "Installed: Go $(go version 2>/dev/null || echo '(check PATH)')" +echo "Installed: Nix $(nix --version 2>/dev/null || echo '(restart shell)')" +if dpkg -l libsdl3-dev &>/dev/null; then + echo "Installed: SDL3" +else + echo "SDL3: not in apt for this release (install from https://github.com/libsdl-org/SDL)" +fi