diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 00d05a84d..582760a8e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,6 +15,8 @@ /dotnet-tui/ @busec0 @kristopherjohnson /dotnet-winforms/ @busec0 @kristopherjohnson /flutter_app/ @pvditto @teodorciuraru +/go-tui/ @kristopherjohnson +/java-spring/ @phatblat @busec0 /java-server/ @russhwolf @phatblat @busec0 /javascript-tui/ @konstantinbe @pvditto @teodorciuraru /javascript-web/ @konstantinbe @pvditto @teodorciuraru diff --git a/go-tui/.editorconfig b/go-tui/.editorconfig new file mode 100644 index 000000000..d407bf299 --- /dev/null +++ b/go-tui/.editorconfig @@ -0,0 +1,2 @@ +[{*.go,*.go2}] +indent_style = tab diff --git a/go-tui/.gitignore b/go-tui/.gitignore new file mode 100644 index 000000000..624207980 --- /dev/null +++ b/go-tui/.gitignore @@ -0,0 +1,5 @@ +ditto-tasks-termui +go-tui +*.log +*.out +go-sdk/ diff --git a/go-tui/Makefile b/go-tui/Makefile new file mode 100644 index 000000000..d60d70e16 --- /dev/null +++ b/go-tui/Makefile @@ -0,0 +1,80 @@ +# Makefile for Ditto QuickStart Go TUI Tasks application + +# Display help information +.PHONY: help +help: + @echo "Ditto Go TUI Tasks Application" + @echo "" + @echo "Available targets:" + @echo " build - Build the FFI library and application" + @echo " run - Build and run the application" + @echo " run-go - Run directly with 'go run'" + @echo " clean - Remove build artifacts" + @echo " help - Show this help message" + @echo "" + @echo "Note: Ensure .env file exists with proper Ditto configuration" + +GO=go + +# Ditto SDK version and platform detection +DITTO_SDK_VERSION ?= 5.0.0-go-preview.3 +PLATFORM := $(shell uname -s | tr '[:upper:]' '[:lower:]') +ARCH := $(shell uname -m) + +# Determine Ditto SDK platform string +ifeq ($(PLATFORM),linux) + DITTO_PLATFORM = linux-$(ARCH) +else ifeq ($(PLATFORM),darwin) + DITTO_PLATFORM = macos-aarch64 + + # avoid version mismatch warnings when linking + export MACOSX_DEPLOYMENT_TARGET := 11.0 + export CGO_CFLAGS := -mmacosx-version-min=11.0 + export CGO_LDFLAGS := -mmacosx-version-min=11.0 +else + $(error Unsupported platform: $(PLATFORM)) +endif + +DITTO_SDK_URL = "https://software.ditto.live/go/Ditto/$(DITTO_SDK_VERSION)/libs/libdittoffi-$(DITTO_PLATFORM).tar.gz" + +# Build the application +.PHONY: build +build: go-sdk ditto-tasks-termui + +ditto-tasks-termui: main.go redirect_unix.go redirect_windows.go widgets.go Makefile go.mod go.sum + @echo "Building ditto-tasks-termui..." + $(GO) mod tidy + $(GO) build -o ditto-tasks-termui -ldflags='-extldflags "-L./go-sdk"' + +.PHONY: go-sdk +go-sdk: ## Downloads and installs the Ditto Go SDK library to go-sdk directory + @ if [ ! -f go-sdk/libdittoffi.so ] && [ ! -f go-sdk/libdittoffi.dylib ] ; then \ + echo "📥 Downloading Ditto Go SDK v$(DITTO_SDK_VERSION) for $(DITTO_PLATFORM) $(DITTO_SDK_URL)..."; \ + mkdir -p go-sdk; \ + if curl -L -f $(DITTO_SDK_URL) | tar xz --strip-components=0 -C go-sdk/; then \ + echo "✅ Ditto Go SDK v$(DITTO_SDK_VERSION) installed successfully"; \ + else \ + echo "❌ Failed to download SDK for $(DITTO_PLATFORM)"; \ + fi; \ + else \ + echo "✅ Ditto Go SDK already installed"; \ + fi + +# Run the application (built binary) +.PHONY: run +run: build + @echo "Running ditto-tasks-termui..." + LD_LIBRARY_PATH="$$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$$(pwd)/go-sdk" ./ditto-tasks-termui 2>/dev/null + +# Run directly with Go +.PHONY: run-go +run-go: + @echo "Running ditto tasks-termui with go run..." + LD_LIBRARY_PATH="$$(pwd)/go-sdk" DYLD_LIBRARY_PATH="$$(pwd)/go-sdk" $(GO) run main.go 2>/dev/null + +# Clean build artifacts +.PHONY: clean +clean: + @echo "Cleaning ditto-tasks-termui and build artifacts..." + rm -f ditto-tasks-termui + rm -rf go-sdk diff --git a/go-tui/README.md b/go-tui/README.md new file mode 100644 index 000000000..17e26f69f --- /dev/null +++ b/go-tui/README.md @@ -0,0 +1,139 @@ +# Ditto Go Quickstart App 🚀 + +This directory contains Ditto's quickstart app for the Go SDK. +This app is a Terminal User Interface (TUI) that allows for creating +a todo list that syncs between multiple peers. + +## Getting Started + +To get started, you'll first need to create an app in the [Ditto Portal][0] +with the "Online Playground" authentication type. You'll need to find your +AppID and Online Playground Token, Auth URL, and Websocket URL in order to use this quickstart. + +[0]: https://portal.ditto.live + +Create a `.env` file in this directory with your Ditto credentials: + +```bash +# Create .env file +cat > .env << 'EOF' +DITTO_APP_ID=your-app-id +DITTO_PLAYGROUND_TOKEN=your-playground-token +DITTO_AUTH_URL=https://your-app-id.cloud.ditto.live +EOF +``` + +Alternatively, you can set these as environment variables: + +```bash +export DITTO_APP_ID="your-app-id" +export DITTO_PLAYGROUND_TOKEN="your-playground-token" +export DITTO_AUTH_URL="https://your-app-id.cloud.ditto.live" +``` + +## Building + +From this directory (`go-tui`): + +```bash +# Using the Makefile +make build + +# Or build directly with Go +go build -o ditto-tasks-termui +``` + + +## Running + +**Note:** the Ditto Go SDK `libdittoffi.so` (Linux) or `libdittoffi.dylib` +shared library must be present and in one of the directories searched by the +system's dynamic linker to run the application. This is handled automatically +by `make run`. If you use one of the other options, you may need to perform +additional steps. See the [Go SDK Install Guide](https://docs.ditto.live/sdk/latest/install-guides/go) +for details. + +Run the quickstart app with the following command: + + +```bash +# Using the Makefile (which will download the shared library and set shared-library load paths automatically) +make run +``` + +```bash +# Run the executable that was created via make build +./ditto-tasks-termui 2>/dev/null +``` + +Or run directly with Go: +```bash +go run main.go 2>/dev/null +``` + +> NOTE: The `2>/dev/null` is a workaround to silence output on `stderr`, since +> that would interfere with the TUI application. Without it, the screen will +> quickly become garbled due to Ditto's internal logging. + +## Controls + +- **j/↓**: Move down +- **k/↑**: Move up +- **Enter/Space**: Toggle task completion +- **c**: Create new task +- **e**: Edit selected task +- **d**: Delete selected task +- **q**: Quit application +- **Esc**: Cancel input mode + +## Features + +- ✅ Create, edit, and delete tasks +- ✅ Mark tasks as complete/incomplete +- ✅ Real-time synchronization across devices +- ✅ Terminal-based interface using termui +- ✅ Cross-platform compatibility with other Ditto quickstart apps + + +## Troubleshooting + +### Logs + +To find errors and messages that are not printed to the TUI display, check the application logs. +Logs are output to `/tmp/ditto-tasks-termui.log`. + +### libdittoffi Library not found + +If you get a library loading error, ensure that the `libdittoffi.so` (Linux) or +`libdittoffi.dylib` (macOS) shared library is present and that `LD_LIBRARY_PATH` +(Linux) or `DYLD_LIBRARY_PATH` (macOS) is set appropriately. + +### Environment variables not found + +The app looks for a `.env` file in the current directory. Ensure it exists with +all required variables set, or export them as environment variables. + +### Garbled screen output + +Always run the application with `2>/dev/null` to suppress stderr output that can +interfere with the TUI display: + +```bash +./ditto-tasks-termui 2>/dev/null +``` + +## Development + +The application uses: +- [termui v3](https://github.com/gizak/termui) for the TUI framework (similar to Rust's ratatui) +- [Ditto Go SDK](https://github.com/getditto/ditto-go-sdk) for edge sync +- Channels for async communication between Ditto observers and the UI + +## Architecture + +The app follows an event-driven architecture: +- Direct event loop handling keyboard input +- Table widget for displaying tasks (similar to Rust's ratatui) +- Manual text input handling for create/edit modes +- Async updates from Ditto observers via Go channels +- Real-time sync with other Ditto peers running the same app diff --git a/go-tui/go.mod b/go-tui/go.mod new file mode 100644 index 000000000..16c3152eb --- /dev/null +++ b/go-tui/go.mod @@ -0,0 +1,22 @@ +module github.com/getditto/quickstart/go-tui/ditto-tasks-termui + +go 1.24.0 + +require ( + github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3 + github.com/gizak/termui/v3 v3.1.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 +) + +require ( + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + github.com/x448/float16 v0.8.4 // indirect +) diff --git a/go-tui/go.sum b/go-tui/go.sum new file mode 100644 index 000000000..ed0dcd7b4 --- /dev/null +++ b/go-tui/go.sum @@ -0,0 +1,30 @@ +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3 h1:u3ocpt4YNd9WPu4j4ZfYs/sZ4o6Fl4wuthUrzVZr/pw= +github.com/getditto/ditto-go-sdk/v5 v5.0.0-go-preview.3/go.mod h1:LFVfgkbjAENnhaxjd4rUfOUVOH7BC3yEuKCo1Ps/Kbg= +github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= +github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= +github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= diff --git a/go-tui/main.go b/go-tui/main.go new file mode 100644 index 000000000..f4fbdb1a0 --- /dev/null +++ b/go-tui/main.go @@ -0,0 +1,575 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/getditto/ditto-go-sdk/v5/ditto" + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" + "github.com/google/uuid" + "github.com/joho/godotenv" +) + +type Task struct { + ID string `json:"_id"` + Title string `json:"title"` + Done bool `json:"done"` + Deleted bool `json:"deleted"` +} + +type InputMode int + +const ( + NormalMode InputMode = iota + CreateMode + EditMode +) + +type App struct { + ditto *ditto.Ditto + observer *ditto.StoreObserver + subscription *ditto.SyncSubscription + + tasks []Task + tasksChan chan []Task + selectedIdx int + + inputMode InputMode + inputBuffer string + editingID string + + errorMsg struct { + value string // Owned by the app goroutine + ch chan string + reset <-chan time.Time + } + + ctx context.Context + cancel context.CancelFunc + + // UI widgets + taskTable *widgets.Table + inputBox *widgets.Paragraph + statusBar *widgets.Paragraph + errorBar *widgets.Paragraph +} + +func main() { + // Platform-specific stderr redirection (Unix: /dev/null, Windows: no-op for now) + redirectStderr() + + // Also redirect Go's log output to a file for debugging + logPath := filepath.Join(os.TempDir(), "ditto-tasks-termui.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err == nil { + log.SetOutput(logFile) + defer logFile.Close() + } else { + log.Printf("Failed to open log file: %v", err) + } + + // Set Ditto log level to Error to suppress most logs + ditto.SetMinimumLogLevel(ditto.LogLevelError) + + // Load environment variables + if err := loadEnv(); err != nil { + log.Printf("Warning: Could not load .env file: %v", err) + } + + // Get config from environment + appID := os.Getenv("DITTO_APP_ID") + token := os.Getenv("DITTO_PLAYGROUND_TOKEN") + authURL := os.Getenv("DITTO_AUTH_URL") + + if appID == "" || token == "" || authURL == "" { + log.Fatal("Missing required environment variables. Please set DITTO_APP_ID, DITTO_PLAYGROUND_TOKEN, and DITTO_AUTH_URL") + } + + // Create temp directory for persistence + tempDir, err := os.MkdirTemp("", "ditto-quickstart-*") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(tempDir) + + // Initialize Ditto with Server connection API + config := ditto.DefaultDittoConfig(). + WithDatabaseID(appID). + WithPersistenceDirectory(tempDir). + WithConnect(&ditto.DittoConfigConnectServer{URL: authURL}) + + d, err := ditto.Open(config) + if err != nil { + log.Fatal("Failed to open Ditto:", err) + } + defer d.Close() + + // Set up authentication handler for development mode + if auth := d.Auth(); auth != nil { + auth.SetExpirationHandler( + func(d *ditto.Ditto, timeUntilExpiration time.Duration) { + log.Printf("Expiration handler called with time until expiration: %v", timeUntilExpiration) + + // For development mode, login with the playground token + provider := ditto.DevelopmentAuthenticationProvider() + clientInfoJSON, err := d.Auth().Login(token, provider) + if err != nil { + log.Printf("Failed to login: %v", err) + } else { + log.Printf("Login successful") + if clientInfoJSON != "" { + log.Printf("Client info: %s", clientInfoJSON) + } + } + }) + } + + // Start sync (authentication handler will be called automatically if needed) + if err := d.Sync().Start(); err != nil { + log.Fatal("Failed to start sync:", err) + } + + // Initialize termui + if err := ui.Init(); err != nil { + log.Fatalf("failed to initialize termui: %v", err) + } + defer ui.Close() + + // Create app + app := NewApp(d) + + // Create subscription for syncing + subscription, err := d.Sync().RegisterSubscription("SELECT * FROM tasks") + if err != nil { + log.Fatal("Failed to register subscription:", err) + } + defer subscription.Cancel() + app.subscription = subscription + + // Create observer for local changes + observer, err := d.Store().RegisterObserver( + "SELECT * FROM tasks WHERE deleted = false ORDER BY _id", + nil, + func(result *ditto.QueryResult) { + defer result.Close() + + tasks := parseTasks(result) + select { + case app.tasksChan <- tasks: + case <-app.ctx.Done(): + } + }) + if err != nil { + log.Fatal("Failed to register observer:", err) + } + defer observer.Cancel() + app.observer = observer + + // Run the app + app.Run() +} + +func NewApp(d *ditto.Ditto) *App { + ctx, cancel := context.WithCancel(context.Background()) + app := &App{ + ditto: d, + tasks: []Task{}, + selectedIdx: 0, + inputMode: NormalMode, + tasksChan: make(chan []Task, 1), // Buffer size 1 - latest update wins + ctx: ctx, + cancel: cancel, + } + + app.errorMsg.ch = make(chan string, 1) + + // Create widgets + app.taskTable = widgets.NewTable() + app.taskTable.Title = " Tasks (j↓, k↑, ⏎ toggle done) " + app.taskTable.BorderStyle = ui.NewStyle(ui.ColorCyan) + app.taskTable.RowSeparator = false + app.taskTable.FillRow = true + app.taskTable.TextStyle = ui.NewStyle(ui.ColorWhite, ui.ColorClear, ui.ModifierBold) + + app.inputBox = widgets.NewParagraph() + app.inputBox.Title = " New Task " + app.inputBox.BorderStyle = ui.NewStyle(ui.ColorMagenta) + + app.statusBar = BorderlessParagraph() + app.statusBar.Text = "[c](fg:yellow): create [e](fg:yellow): edit [d](fg:yellow): delete [q](fg:yellow): quit [s](fg:yellow): toggle sync" + + app.errorBar = BorderlessParagraph() + app.errorBar.TextStyle = ui.NewStyle(ui.ColorRed) + + return app +} + +func (a *App) Run() { + defer a.cancel() // Ensure context is canceled when Run exits + + // Initial render + a.render() + + // Create event polling channel + uiEvents := ui.PollEvents() + + // Main event loop + for { + select { + case <-a.ctx.Done(): + return + case e := <-uiEvents: + a.handleEvent(e) + + case tasks := <-a.tasksChan: + a.updateTasks(tasks) + a.render() + case msg := <-a.errorMsg.ch: + a.errorMsg.value = msg + a.render() + case <-a.errorMsg.reset: + a.errorMsg.value = "" + a.render() + } + } +} + +func (a *App) handleEvent(e ui.Event) { + if e.ID == "" { + a.render() + return + } + switch a.inputMode { + case NormalMode: + a.handleNormalMode(e) + case CreateMode: + a.handleInputMode(e, false) + case EditMode: + a.handleInputMode(e, true) + } +} + +func (a *App) handleNormalMode(e ui.Event) { + switch e.ID { + case "q", "": + a.cancel() // signal main event loop to exit + case "j", "": + if a.selectedIdx < len(a.tasks)-1 { + a.selectedIdx++ + } + a.render() + + case "k", "": + if a.selectedIdx > 0 { + a.selectedIdx-- + } + a.render() + + case "", " ": + if task, ok := a.getSelectedTask(); ok { + go a.toggleTask(task.ID, !task.Done) + } + + case "c": + a.inputMode = CreateMode + a.inputBuffer = "" + a.render() + + case "e": + if task, ok := a.getSelectedTask(); ok { + a.inputMode = EditMode + a.inputBuffer = task.Title + a.editingID = task.ID + a.render() + } + + case "d": + if task, ok := a.getSelectedTask(); ok { + go a.deleteTask(task.ID) + } + + case "s": + // Toggle sync (placeholder for now - could implement sync toggle) + a.setError("Sync toggle not yet implemented") + a.render() + } +} + +func (a *App) handleInputMode(e ui.Event, isEdit bool) { + switch e.ID { + case "": + a.inputMode = NormalMode + a.inputBuffer = "" + a.editingID = "" + a.render() + case "": + if strings.TrimSpace(a.inputBuffer) != "" { + if isEdit { + go a.updateTask(a.editingID, a.inputBuffer) + } else { + go a.createTask(a.inputBuffer) + } + a.inputMode = NormalMode + a.inputBuffer = "" + a.editingID = "" + a.render() + } + + case "": + if len(a.inputBuffer) > 0 { + a.inputBuffer = a.inputBuffer[:len(a.inputBuffer)-1] + a.render() + } + + case "": + a.inputBuffer += " " + a.render() + + default: + // Handle regular character input + if len(e.ID) == 1 { + a.inputBuffer += e.ID + a.render() + } + } +} + +func (a *App) render() { + termWidth, termHeight := ui.TerminalDimensions() + + // Clear screen + ui.Clear() + + // Update table data + a.updateTable() + + // Layout calculations + tableHeight := termHeight - 2 // Leave room for status bar + + // Set widget positions + a.taskTable.SetRect(0, 0, termWidth, tableHeight) + a.statusBar.SetRect(0, termHeight-1, termWidth, termHeight) + + // Render main widgets + ui.Render(a.taskTable, a.statusBar) + + // Render input box if in input mode + if a.inputMode != NormalMode { + title := " New Task " + if a.inputMode == EditMode { + title = " Edit Task " + } + a.inputBox.Title = title + a.inputBox.Text = a.inputBuffer + "█" // Add cursor + + // Center the input box + boxWidth := termWidth - 10 + if boxWidth > 60 { + boxWidth = 60 + } + boxHeight := 8 + boxX := (termWidth - boxWidth) / 2 + boxY := (termHeight - boxHeight) / 2 + + a.inputBox.SetRect(boxX, boxY, boxX+boxWidth, boxY+boxHeight) + ui.Render(a.inputBox) + } + + // Render error if present + if a.errorMsg.value != "" { + a.errorBar.Text = fmt.Sprintf("Error: %s", a.errorMsg.value) + a.errorBar.SetRect(0, termHeight-2, termWidth, termHeight-1) + ui.Render(a.errorBar) + + // Clear error after 3 seconds using the reset channel + a.errorMsg.reset = time.After(3 * time.Second) + } +} + +func (a *App) updateTable() { + // Headers + headers := []string{"", "Done", "Title"} + + // Rows + rows := [][]string{headers} + for i, task := range a.tasks { + selector := " " + if i == a.selectedIdx { + selector = "❯❯" + } + + done := " ☐" + if task.Done { + done = " ✓" + } + + rows = append(rows, []string{selector, done, task.Title}) + } + + if len(rows) == 1 { + rows = append(rows, []string{"", "", "No tasks yet. Press 'c' to create one!"}) + } + + a.taskTable.Rows = rows + a.taskTable.ColumnWidths = []int{2, 5, a.taskTable.Dx() - 7} + + // Highlight selected row + a.taskTable.RowStyles = map[int]ui.Style{} // clear existing highlight(s) + if a.selectedIdx >= 0 && a.selectedIdx < len(a.tasks) { + a.taskTable.RowStyles[a.selectedIdx+1] = ui.NewStyle(ui.ColorBlue, ui.ColorClear, ui.ModifierBold) + } +} + +func (a *App) createTask(title string) { + task := map[string]interface{}{ + "_id": uuid.New().String(), + "title": title, + "done": false, + "deleted": false, + } + + result, err := a.ditto.Store().Execute( + "INSERT INTO tasks VALUES (:task)", + map[string]interface{}{"task": task}, + ) + if err != nil { + a.setError(err.Error()) + return + } + defer result.Close() +} + +func (a *App) updateTask(id, title string) { + result, err := a.ditto.Store().Execute( + "UPDATE tasks SET title = :title WHERE _id = :id", + map[string]interface{}{ + "title": title, + "id": id, + }, + ) + if err != nil { + a.setError(err.Error()) + return + } + defer result.Close() +} + +func (a *App) toggleTask(id string, done bool) { + result, err := a.ditto.Store().Execute( + "UPDATE tasks SET done = :done WHERE _id = :id", + map[string]interface{}{ + "done": done, + "id": id, + }, + ) + if err != nil { + a.setError(err.Error()) + return + } + defer result.Close() +} + +func (a *App) deleteTask(id string) { + result, err := a.ditto.Store().Execute( + "UPDATE tasks SET deleted = true WHERE _id = :id", + map[string]interface{}{"id": id}, + ) + if err != nil { + a.setError(err.Error()) + return + } + defer result.Close() +} + +// Set the UI error message. May be called by any goroutine. +func (a *App) setError(msg string) { + a.errorMsg.ch <- msg +} + +func loadEnv() error { + // Try to find .env file in parent directories + dir, _ := os.Getwd() + for { + envPath := filepath.Join(dir, ".env") + if err := godotenv.Load(envPath); err == nil || !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return fmt.Errorf(".env file not found") +} + +func parseTasks(result *ditto.QueryResult) []Task { + if result == nil { + return []Task{} + } + + // Don't pre-allocate when we're filtering + var tasks []Task + items := result.Items() + for _, queryItem := range items { + // Get the value as a map + item := queryItem.Value() + + // Parse the task from the document + task := Task{ + ID: getStringValue(item, "_id"), + Title: getStringValue(item, "title"), + Done: getBoolValue(item, "done"), + Deleted: getBoolValue(item, "deleted"), + } + if !task.Deleted { + tasks = append(tasks, task) + } + } + return tasks +} + +// getSelectedTask returns the currently selected task and whether the selection is valid +func (a *App) getSelectedTask() (Task, bool) { + if a.selectedIdx < len(a.tasks) { + return a.tasks[a.selectedIdx], true + } + return Task{}, false +} + +// updateTasks updates the task list and adjusts selection if needed +func (a *App) updateTasks(tasks []Task) { + a.tasks = tasks + if a.selectedIdx >= len(a.tasks) && len(a.tasks) > 0 { + a.selectedIdx = len(a.tasks) - 1 + } +} + +// Generic helper for type assertions +func getValueAs[T any](m map[string]any, key string) (T, bool) { + if v, ok := m[key].(T); ok { + return v, true + } + var zero T + return zero, false +} + +func getStringValue(m map[string]any, key string) string { + if v, ok := getValueAs[string](m, key); ok { + return v + } + return "" +} + +func getBoolValue(m map[string]any, key string) bool { + if v, ok := getValueAs[bool](m, key); ok { + return v + } + return false +} diff --git a/go-tui/redirect_unix.go b/go-tui/redirect_unix.go new file mode 100644 index 000000000..4c0993e51 --- /dev/null +++ b/go-tui/redirect_unix.go @@ -0,0 +1,35 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "log" + "os" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +// isTerminal checks if the given file descriptor is a terminal +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} + +// redirectStderr redirects stderr to /dev/null on Unix systems +func redirectStderr() { + // Redirect stderr to /dev/null so it doesn't interfere with TUI output + // This is similar to what the C++ TUI does with freopen + if isTerminal(os.Stderr.Fd()) { + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + log.Printf("Failed to open %s: %v", os.DevNull, err) + return + } + + // Redirect stderr to /dev/null + if err := unix.Dup2(int(devNull.Fd()), int(os.Stderr.Fd())); err != nil { + log.Printf("Failed to redirect stderr: %v", err) + } + } +} diff --git a/go-tui/redirect_windows.go b/go-tui/redirect_windows.go new file mode 100644 index 000000000..69e16cd74 --- /dev/null +++ b/go-tui/redirect_windows.go @@ -0,0 +1,21 @@ +//go:build windows +// +build windows + +package main + +import ( + "golang.org/x/term" +) + +// isTerminal checks if the given file descriptor is a terminal +func isTerminal(fd uintptr) bool { + return term.IsTerminal(int(fd)) +} + +// redirectStderr is a no-op on Windows for now +// TODO: Implement Windows-specific stderr redirection if needed +func redirectStderr() { + // On Windows, we don't redirect stderr for now + // The terminal UI might still work, or we could implement + // Windows-specific redirection using Windows API calls +} diff --git a/go-tui/widgets.go b/go-tui/widgets.go new file mode 100644 index 000000000..959ab2d92 --- /dev/null +++ b/go-tui/widgets.go @@ -0,0 +1,18 @@ +package main + +import "github.com/gizak/termui/v3/widgets" + +// BorderlessParagraph creates a paragraph without borders and removes the default inner padding. +// This allows safely rendering a paragraph that is smaller than three rows or three columns. +// The default calculation of the Inner rectangle in termui.Block#SetRect will produce an invalid rectangle when the +// height or width of the passed coordinates is two or less. Negating the introduced offsets with negative +// values for Padding allows the text to render as expected. +func BorderlessParagraph() *widgets.Paragraph { + p := widgets.NewParagraph() + p.Border = false + p.PaddingLeft = -1 + p.PaddingTop = -1 + p.PaddingRight = -1 + p.PaddingBottom = -1 + return p +}