diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02be482 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Block executable +diesel* diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df03ddb --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/fivemoreminix/diesel + +go 1.12 + +require github.com/gdamore/tcell/v2 v2.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cc272b9 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.2.0 h1:vSyEgKwraXPSOkvCk7IwOSyX+Pv3V2cV9CikJMXg4U4= +github.com/gdamore/tcell/v2 v2.2.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..209a368 --- /dev/null +++ b/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "io/fs" + + "github.com/fivemoreminix/diesel/ui" + "github.com/gdamore/tcell/v2" +) + +var theme = ui.Theme{} + +var focusedComponent ui.Component = nil + +func changeFocus(to ui.Component) { + if focusedComponent != nil { + focusedComponent.SetFocused(false) + } + focusedComponent = to + to.SetFocused(true) +} + +func main() { + s, e := tcell.NewScreen() + if e != nil { + fmt.Fprintf(os.Stderr, "%v\n", e) + os.Exit(1) + } + if e := s.Init(); e != nil { + fmt.Fprintf(os.Stderr, "%v\n", e) + os.Exit(1) + } + defer s.Fini() // Useful for handling panics + + // defer func() { + // if err := recover(); err != nil { + // s.Fini() + // fmt.Fprintln(os.Stderr, err) + // } + // }() + + sizex, sizey := s.Size() + + tabContainer := ui.NewTabContainer(&theme) + tabContainer.SetPos(0, 1) + tabContainer.SetSize(sizex, sizey-1) + + var fileSelector *ui.FileSelectorDialog // if nil, we don't draw it + + bar := ui.NewMenuBar(nil, &theme) + + barFocused := false + + // TODO: load menus in another function + bar.Menus = append(bar.Menus, ui.NewMenu("File", &theme, []ui.Item{&ui.ItemEntry{Name: "New File", Callback: func() { + textEdit := ui.NewTextEdit(&s, "", "", &theme) // No file path, no contents + tabContainer.AddTab("noname", textEdit) + }}, &ui.ItemEntry{Name: "Open...", Callback: func() { + callback := func(filePaths []string) { + for _, path := range filePaths { + file, err := os.Open(path) + if err != nil { + panic("Could not open file at path " + path) + } + defer file.Close() + + bytes, err := ioutil.ReadAll(file) + if err != nil { + panic("Could not read all of file") + } + + textEdit := ui.NewTextEdit(&s, path, string(bytes), &theme) + tabContainer.AddTab(path, textEdit) + } + fileSelector = nil // Hide the file selector + changeFocus(tabContainer) + barFocused = false + } + fileSelector = ui.NewFileSelectorDialog( + &s, + "Comma-separated files or a directory", + true, + &theme, + callback, + func() { // Dialog is canceled + fileSelector = nil + changeFocus(bar) + barFocused = true + }, + ) + changeFocus(fileSelector) + }}, &ui.ItemEntry{Name: "Save", Callback: func() { + if tabContainer.GetTabCount() > 0 { + tab := tabContainer.GetTab(tabContainer.Selected) + te := tab.Child.(*ui.TextEdit) + if len(te.FilePath) > 0 { + contents := te.String() + + // Write the contents into the file, creating one if it does + // not exist. + err := ioutil.WriteFile(te.FilePath, []byte(contents), fs.ModePerm) + if err != nil { + panic("Could not write file at path " + te.FilePath) + } + } + } + }}, &ui.ItemEntry{Name: "Save As...", Callback: func() { + // TODO: implement a "Save as" dialog system, and show that when trying to save noname files + callback := func(filePaths []string) { + fileSelector = nil // Hide the file selector + } + + fileSelector = ui.NewFileSelectorDialog( + &s, + "Select a file to overwrite", + false, + &theme, + callback, + func() { // Dialog canceled + fileSelector = nil + changeFocus(bar) + }, + ) + changeFocus(fileSelector) + }}, &ui.ItemSeparator{}, &ui.ItemEntry{Name: "Exit", Callback: func() { + s.Fini() + os.Exit(0) + }}})) + + bar.Menus = append(bar.Menus, ui.NewMenu("Edit", &theme, []ui.Item{&ui.ItemEntry{Name: "New", Callback: func() { + s.Beep() + }}})) + + bar.Menus = append(bar.Menus, ui.NewMenu("Search", &theme, []ui.Item{&ui.ItemEntry{Name: "New", Callback: func() { + s.Beep() + }}})) + + changeFocus(tabContainer) // TabContainer is focused by default + +main_loop: + for { + s.Clear() + + // Draw background (grey and black checkerboard) + ui.DrawRect(s, 0, 0, sizex, sizey, '▚', tcell.Style{}.Foreground(tcell.ColorGrey).Background(tcell.ColorBlack)) + + if tabContainer.GetTabCount() > 0 { // Draw the tab container only if a tab is open + tabContainer.Draw(s) + } + bar.Draw(s) // Always draw the menu bar + + if fileSelector != nil { + // Update fileSelector dialog pos and size + diagMinX, diagMinY := fileSelector.GetMinSize() + fileSelector.SetSize(diagMinX, diagMinY) + fileSelector.SetPos(sizex/2-diagMinX/2, sizey/2-diagMinY/2) // Center + + fileSelector.Draw(s) + } + + s.Show() + + switch ev := s.PollEvent().(type) { + case *tcell.EventResize: + sizex, sizey = s.Size() + + bar.SetSize(sizex, 1) + tabContainer.SetSize(sizex, sizey-1) + + s.Sync() // Redraw everything + case *tcell.EventKey: + // On Escape, we change focus between editor and the MenuBar. + if fileSelector == nil { // While no dialog is present... + if ev.Key() == tcell.KeyEscape { + barFocused = !barFocused + if barFocused { + changeFocus(bar) + } else { + changeFocus(tabContainer) + } + } + // Ctrl + Q is a shortcut to exit + if ev.Key() == tcell.KeyCtrlQ { // TODO: replace with shortcut keys in menus + break main_loop + } + } + + focusedComponent.HandleEvent(ev) + } + } +} diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 0000000..3e99076 --- /dev/null +++ b/ui/button.go @@ -0,0 +1,79 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" +) + +type Button struct { + Text string + Callback func() + + x, y int + width, height int + focused bool + + Theme *Theme +} + +func NewButton(text string, theme *Theme, callback func()) *Button { + return &Button{ + Text: text, + Callback: callback, + Theme: theme, + } +} + +func (b *Button) Draw(s tcell.Screen) { + var str string + if b.focused { + str = fmt.Sprintf("🭬 %s 🭮", b.Text) + } else { + str = fmt.Sprintf(" %s ", b.Text) + } + DrawStr(s, b.x, b.y, str, b.Theme.GetOrDefault("Button")) +} + +func (b *Button) SetFocused(v bool) { + b.focused = v +} + +func (b *Button) SetTheme(theme *Theme) { + b.Theme = theme +} + +func (b *Button) GetPos() (int, int) { + return b.x, b.y +} + +func (b *Button) SetPos(x, y int) { + b.x, b.y = x, y +} + +func (b *Button) GetMinSize() (int, int) { + return len(b.Text) + 4, 1 +} + +func (b *Button) GetSize() (int, int) { + return b.GetMinSize() +} + +func (b *Button) SetSize(width, height int) {} + +func (b *Button) HandleEvent(event tcell.Event) bool { + if b.focused { + switch ev := event.(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyEnter { + if b.Callback != nil { + b.Callback() + } + } + default: + return false + } + return true + } + return false +} diff --git a/ui/component.go b/ui/component.go new file mode 100644 index 0000000..d8f32cb --- /dev/null +++ b/ui/component.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" +) + +// A Component refers generally to the behavior of a UI "component". Components +// include buttons, input fields, and labels. It is expected that after constructing +// a component, to call the SetPos() function, and possibly SetSize() as well. +// +// Many components implement their own `New...()` function. In those constructor +// functions, it is good practice for that component to set its size to be its +// minimum size. TODO: implement that behavior +type Component interface { + // A component knows its position and size, which is used to draw itself in + // its bounding rectangle. + Draw(tcell.Screen) + // Components can be focused, which may affect how it handles events. For + // example, when a button is focused, the Return key may be pressed to + // activate the button. + SetFocused(bool) + // Applies the theme to the component and all of its children. + SetTheme(*Theme) + + // Get position of the Component. + GetPos() (int, int) + // Set position of the Component. + SetPos(int, int) + + // Returns the smallest size the Component can be. + GetMinSize() (int, int) + // Get size of the Component. + GetSize() (int, int) + // Set size of the component. If size is smaller than minimum, minimum is + // used, instead. + SetSize(int, int) + + tcell.EventHandler // A Component can handle events +} diff --git a/ui/container.go b/ui/container.go new file mode 100644 index 0000000..b111eee --- /dev/null +++ b/ui/container.go @@ -0,0 +1,336 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" +) + +// A Container has zero or more Components. Containers decide how Components are +// laid out in view, and may draw decorations like bounding boxes. +type Container interface { + Component +} + +// A BoxContainer draws an outline using the `Character` with `Style` attributes +// around the `Child` Component. +type BoxContainer struct { + Child Component + + x, y int + width, height int + ULRune rune // Rune for upper-left + URRune rune // Rune for upper-right + BLRune rune // Rune for bottom-left + BRRune rune // Rune for bottom-right + HorRune rune // Rune for horizontals + VertRune rune // Rune for verticals + + Style tcell.Style +} + +// New constructs a default BoxContainer using the terminal default style. +func NewBoxContainer(child Component, style tcell.Style) *BoxContainer { + return &BoxContainer{ + Child: child, + ULRune: '╭', + URRune: '╮', + BLRune: '╰', + BRRune: '╯', + HorRune: '—', + VertRune: '│', + Style: style, + } +} + +// Draw will draws the border of the BoxContainer, then it draws its child component. +func (c *BoxContainer) Draw(s tcell.Screen) { + DrawRectOutline(s, c.x, c.y, c.width, c.height, c.ULRune, c.URRune, c.BLRune, c.BRRune, c.HorRune, c.VertRune, c.Style) + + if c.Child != nil { + c.Child.Draw(s) + } +} + +// SetFocused calls SetFocused on the child Component. +func (c *BoxContainer) SetFocused(v bool) { + if c.Child != nil { + c.Child.SetFocused(v) + } +} + +func (c *BoxContainer) SetTheme(theme *Theme) {} + +// GetPos returns the position of the container. +func (c *BoxContainer) GetPos() (int, int) { + return c.x, c.y +} + +// SetPos sets the position of the container and updates the child Component. +func (c *BoxContainer) SetPos(x, y int) { + c.x, c.y = x, y + if c.Child != nil { + c.Child.SetPos(x+1, y+1) + } +} + +func (c *BoxContainer) GetMinSize() (int, int) { + return 0, 0 +} + +// GetSize gets the size of the container. +func (c *BoxContainer) GetSize() (int, int) { + return c.width, c.height +} + +// SetSize sets the size of the container and updates the size of the child Component. +func (c *BoxContainer) SetSize(width, height int) { + c.width, c.height = width, height + if c.Child != nil { + c.Child.SetSize(width-2, height-2) + } +} + +// HandleEvent forwards the event to the child Component and returns whether it was handled. +func (c *BoxContainer) HandleEvent(event tcell.Event) bool { + if c.Child != nil { + return c.Child.HandleEvent(event) + } + return false +} + +// A Tab is a child of a TabContainer; has a name and child Component. +type Tab struct { + Name string + Child Component +} + +// A TabContainer organizes children by showing only one of them at a time. +type TabContainer struct { + Selected int + + children []Tab + x, y int + width, height int + focused bool + + Theme *Theme +} + +func NewTabContainer(theme *Theme) *TabContainer { + return &TabContainer{ + children: make([]Tab, 0, 4), + Theme: theme, + } +} + +func (c *TabContainer) AddTab(name string, child Component) { + c.children = append(c.children, Tab{Name: name, Child: child}) + // Update new child's size and position + child.SetPos(c.x+1, c.y+1) + child.SetSize(c.width-2, c.height-2) +} + +// RemoveTab deletes the tab at `idx`. Returns true if the tab was found, +// false otherwise. +func (c *TabContainer) RemoveTab(idx int) bool { + if idx >= 0 && idx < len(c.children) { + copy(c.children[idx:], c.children[idx+1:]) // Shift all items after idx to the left + c.children = c.children[:len(c.children)-1] // Shrink slice by one + + return true + } + return false +} + +func (c *TabContainer) GetTabCount() int { + return len(c.children) +} + +func (c *TabContainer) GetTab(idx int) *Tab { + return &c.children[idx] +} + +// Draw will draws the border of the BoxContainer, then it draws its child component. +func (c *TabContainer) Draw(s tcell.Screen) { + // Draw outline + DrawRectOutlineDefault(s, c.x, c.y, c.width, c.height, c.Theme.GetOrDefault("TabContainer")) + + combinedTabLength := 0 + for _, tab := range c.children { + combinedTabLength += len(tab.Name) + 2 // 2 for padding + } + combinedTabLength += len(c.children)-1 // add for spacing between tabs + + // Draw tabs + col := c.x + c.width/2 - combinedTabLength/2 // Starting column + for i, tab := range c.children { + var sty tcell.Style + if c.Selected == i { + sty = c.Theme.GetOrDefault("TabSelected") + } else { + sty = c.Theme.GetOrDefault("Tab") + } + str := fmt.Sprintf(" %s ", tab.Name) + //DrawStr(s, c.x+c.width/2-len(str)/2, c.y, str, sty) + DrawStr(s, c.x+col, c.y, str, sty) + col += len(str) + 1 // Add one for spacing between tabs + } + + // Draw selected child in center + if c.Selected < len(c.children) { + c.children[c.Selected].Child.Draw(s) + } +} + +// SetFocused calls SetFocused on the visible child Component. +func (c *TabContainer) SetFocused(v bool) { + c.focused = v + if c.Selected < len(c.children) { + c.children[c.Selected].Child.SetFocused(v) + } +} + +// SetTheme sets the theme. +func (c *TabContainer) SetTheme(theme *Theme) { + c.Theme = theme + for _, tab := range c.children { + tab.Child.SetTheme(theme) // Update the theme for all children + } +} + +func (c *TabContainer) GetMinSize() (int, int) { + return 0, 0 +} + +// GetPos returns the position of the container. +func (c *TabContainer) GetPos() (int, int) { + return c.x, c.y +} + +// SetPos sets the position of the container and updates the child Component. +func (c *TabContainer) SetPos(x, y int) { + c.x, c.y = x, y + if c.Selected < len(c.children) { + c.children[c.Selected].Child.SetPos(x+1, y+1) + } +} + +// GetSize gets the size of the container. +func (c *TabContainer) GetSize() (int, int) { + return c.width, c.height +} + +// SetSize sets the size of the container and updates the size of the child Component. +func (c *TabContainer) SetSize(width, height int) { + c.width, c.height = width, height + if c.Selected < len(c.children) { + c.children[c.Selected].Child.SetSize(width-2, height-2) + } +} + +// HandleEvent forwards the event to the child Component and returns whether it was handled. +func (c *TabContainer) HandleEvent(event tcell.Event) bool { + switch ev := event.(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyTab { // Ctrl + Tab was pressed + if ev.Modifiers() == tcell.ModCtrl { + c.Selected++ + if c.Selected >= len(c.children) { + c.Selected = 0 + } + } else if ev.Modifiers() == tcell.ModCtrl & tcell.ModShift { // Ctrl + Shift + Tab was pressed + c.Selected-- + if c.Selected < 0 { + c.Selected = len(c.children)-1 + } + } + } + } + + if c.Selected < len(c.children) { + return c.children[c.Selected].Child.HandleEvent(event) + } + return false +} + +// TODO: replace window container with draw function +// A WindowContainer has a border, a title, and a button to close the window. +type WindowContainer struct { + Title string + Child Component + + x, y int + width, height int + focused bool + + Theme *Theme +} + +// New constructs a default WindowContainer using the terminal default style. +func NewWindowContainer(title string, child Component, theme *Theme) *WindowContainer { + return &WindowContainer{ + Title: title, + Child: child, + Theme: theme, + } +} + +// Draw will draws the border of the WindowContainer, then it draws its child component. +func (w *WindowContainer) Draw(s tcell.Screen) { + headerStyle := w.Theme.GetOrDefault("WindowHeader") + + DrawRect(s, w.x, w.y, w.width, 1, ' ', headerStyle) // Draw header + DrawStr(s, w.x+w.width/2 - len(w.Title)/2, w.y, w.Title, headerStyle) // Draw title + DrawRect(s, w.x, w.y+1, w.width, w.height-1, ' ', w.Theme.GetOrDefault("Window")) // Draw body background + + if w.Child != nil { + w.Child.Draw(s) + } +} + +// SetFocused calls SetFocused on the child Component. +func (w *WindowContainer) SetFocused(v bool) { + w.focused = v + if w.Child != nil { + w.Child.SetFocused(v) + } +} + +// GetPos returns the position of the container. +func (w *WindowContainer) GetPos() (int, int) { + return w.x, w.y +} + +// SetPos sets the position of the container and updates the child Component. +func (w *WindowContainer) SetPos(x, y int) { + w.x, w.y = x, y + if w.Child != nil { + w.Child.SetPos(x, y+1) + } +} + +func (w *WindowContainer) GetMinSize() (int, int) { + return 0, 0 +} + +// GetSize gets the size of the container. +func (w *WindowContainer) GetSize() (int, int) { + return w.width, w.height +} + +// SetSize sets the size of the container and updates the size of the child Component. +func (w *WindowContainer) SetSize(width, height int) { + w.width, w.height = width, height + if w.Child != nil { + w.Child.SetSize(width, height-2) + } +} + +// HandleEvent forwards the event to the child Component and returns whether it was handled. +func (w *WindowContainer) HandleEvent(event tcell.Event) bool { + if w.Child != nil { + return w.Child.HandleEvent(event) + } + return false +} diff --git a/ui/drawfunctions.go b/ui/drawfunctions.go new file mode 100644 index 0000000..9585186 --- /dev/null +++ b/ui/drawfunctions.go @@ -0,0 +1,51 @@ +package ui + +import "github.com/gdamore/tcell/v2" + +// DrawRect renders a filled box at `x` and `y`, of size `width` and `height`. +// Will not call `Show()`. +func DrawRect(s tcell.Screen, x, y, width, height int, char rune, style tcell.Style) { + for col := x; col < x+width; col++ { + for row := y; row < y+height; row++ { + s.SetContent(col, row, char, nil, style) + } + } +} + +// DrawStr will render each character of a string at `x` and `y`. +func DrawStr(s tcell.Screen, x, y int, str string, style tcell.Style) { + runes := []rune(str) + for idx := 0; idx < len(runes); idx++ { + s.SetContent(x+idx, y, runes[idx], nil, style) + } +} + +// DrawRectOutline draws only the outline of a rectangle, using `ul`, `ur`, `bl`, and `br` +// for the corner runes, and `hor` and `vert` for the horizontal and vertical runes, respectively. +func DrawRectOutline(s tcell.Screen, x, y, _width, _height int, ul, ur, bl, br, hor, vert rune, style tcell.Style) { + width := x + _width - 1 // Length across + height := y + _height - 1 // Length top-to-bottom + + // Horizontals and verticals + for col := x + 1; col < width; col++ { + s.SetContent(col, y, hor, nil, style) // Top line + s.SetContent(col, height, hor, nil, style) // Bottom line + } + for row := y + 1; row < height; row++ { + s.SetContent(x, row, vert, nil, style) // Left line + s.SetContent(width, row, vert, nil, style) // Right line + } + // Corners + s.SetContent(x, y, ul, nil, style) + s.SetContent(width, y, ur, nil, style) + s.SetContent(x, height, bl, nil, style) + s.SetContent(width, height, br, nil, style) +} + +// DrawRectOutlineDefault calls DrawRectOutline with the default edge runes. +func DrawRectOutlineDefault(s tcell.Screen, x, y, width, height int, style tcell.Style) { + DrawRectOutline(s, x, y, width, height, '┌', '┐', '└', '┘', '─', '│', style) +} + +// TODO: add DrawShadow(x, y, width, height int) +// TODO: add DrawWindow(x, y, width, height int, style tcell.Style) diff --git a/ui/fileselectordialog.go b/ui/fileselectordialog.go new file mode 100644 index 0000000..da3ccec --- /dev/null +++ b/ui/fileselectordialog.go @@ -0,0 +1,129 @@ +package ui + +import ( + "strings" + + "github.com/gdamore/tcell/v2" +) + +// A FileSelectorDialog is a WindowContainer with an input and buttons for selecting files. +// It can be used to open zero or more existing files, or select one non-existant file (for saving). +type FileSelectorDialog struct { + MustExist bool // Whether the dialog should have a user select an existing file. + FilesChosenCallback func([]string) // Returns slice of filenames selected. nil if user canceled. + CancelCallback func() // Called when the dialog has been canceled by the user + + container *WindowContainer + x, y int + width, height int + focused bool + + tabOrder []Component + tabOrderIdx int + + inputField *InputField + confirmButton *Button + cancelButton *Button + + Theme *Theme +} + +func NewFileSelectorDialog(screen *tcell.Screen, title string, mustExist bool, theme *Theme, filesChosenCallback func([]string), cancelCallback func()) *FileSelectorDialog { + dialog := &FileSelectorDialog{ + MustExist: mustExist, + FilesChosenCallback: filesChosenCallback, + container: NewWindowContainer(title, nil, theme), + Theme: theme, + } + + dialog.inputField = NewInputField(screen, "", theme) + dialog.confirmButton = NewButton("Confirm", theme, dialog.onConfirm) + dialog.cancelButton = NewButton("Cancel", theme, cancelCallback) + dialog.tabOrder = []Component{dialog.inputField, dialog.cancelButton, dialog.confirmButton} + + return dialog +} + +// onConfirm is a callback called by the confirm button. +func (d *FileSelectorDialog) onConfirm() { + if d.FilesChosenCallback != nil { + files := strings.Split(d.inputField.Text, ",") // Split input by commas + for i := range files { + files[i] = strings.TrimSpace(files[i]) // Trim all strings in slice + } + d.FilesChosenCallback(files) + } +} + +func (d *FileSelectorDialog) SetTitle(title string) { + d.container.Title = title +} + +func (d *FileSelectorDialog) Draw(s tcell.Screen) { + d.container.Draw(s) + + // Update positions of child components (dependent on size information that may not be available at SetPos() ) + btnWidth, _ := d.confirmButton.GetSize() + d.confirmButton.SetPos(d.x+d.width-btnWidth-1, d.y+4) // Place "Ok" button on right, bottom + + d.inputField.Draw(s) + d.confirmButton.Draw(s) + d.cancelButton.Draw(s) +} + +func (d *FileSelectorDialog) SetFocused(v bool) { + d.focused = v + d.tabOrder[d.tabOrderIdx].SetFocused(v) +} + +func (d *FileSelectorDialog) SetTheme(theme *Theme) { + d.Theme = theme +} + +func (d *FileSelectorDialog) GetPos() (int, int) { + return d.x, d.y +} + +func (d *FileSelectorDialog) SetPos(x, y int) { + d.x, d.y = x, y + d.container.SetPos(x, y) + d.inputField.SetPos(d.x+1, d.y+2) // Center input field + d.cancelButton.SetPos(d.x+1, d.y+4) // Place "Cancel" button on left, bottom +} + +func (d *FileSelectorDialog) GetMinSize() (int, int) { + return len(d.container.Title) + 2, 6 +} + +func (d *FileSelectorDialog) GetSize() (int, int) { + return d.width, d.height +} + +func (d *FileSelectorDialog) SetSize(width, height int) { + minX, minY := d.GetMinSize() + d.width, d.height = Max(width, minX), Max(height, minY) + d.container.SetSize(d.width, d.height) + + d.inputField.SetSize(d.width-2, 1) + d.cancelButton.SetSize(d.cancelButton.GetMinSize()) + d.confirmButton.SetSize(d.confirmButton.GetMinSize()) +} + +func (d *FileSelectorDialog) HandleEvent(event tcell.Event) bool { + switch ev := event.(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyTab { + d.tabOrder[d.tabOrderIdx].SetFocused(false) + + d.tabOrderIdx++ + if d.tabOrderIdx >= len(d.tabOrder) { + d.tabOrderIdx = 0 + } + + d.tabOrder[d.tabOrderIdx].SetFocused(true) + + return true + } + } + return d.tabOrder[d.tabOrderIdx].HandleEvent(event) +} diff --git a/ui/inputfield.go b/ui/inputfield.go new file mode 100644 index 0000000..092ab34 --- /dev/null +++ b/ui/inputfield.go @@ -0,0 +1,129 @@ +package ui + +import "github.com/gdamore/tcell/v2" + +// An InputField is a single-line input box. +type InputField struct { + Text string + + cursorPos int + scrollPos int + x, y int + width, height int + focused bool + screen *tcell.Screen + + Theme *Theme +} + +func NewInputField(screen *tcell.Screen, placeholder string, theme *Theme) *InputField { + return &InputField{ + Text: placeholder, + screen: screen, + Theme: theme, + } +} + +func (f *InputField) GetCursorPos() int { + return f.cursorPos +} + +// SetCursorPos sets the cursor position offset. Offset is clamped to possible values. +// The InputField is scrolled to show the new cursor position. +func (f *InputField) SetCursorPos(offset int) { + // Clamping + if offset < 0 { + offset = 0 + } else if offset > len(f.Text) { + offset = len(f.Text) + } + + // Scrolling + if offset >= f.scrollPos+f.width-2 { // If cursor position is out of view to the right... + f.scrollPos = offset - f.width+2 // Scroll just enough to view that column + } else if offset < f.scrollPos { // If cursor position is out of view to the left... + f.scrollPos = offset + } + + f.cursorPos = offset + if f.focused { + (*f.screen).ShowCursor(f.x+offset-f.scrollPos+1, f.y) + } +} + +func (f *InputField) Delete(forward bool) { + if forward { + //if f.cursorPos + } else { + + } +} + +func (f *InputField) Draw(s tcell.Screen) { + style := f.Theme.GetOrDefault("InputField") + + DrawRect(s, f.x, f.y, f.width, f.height, ' ', style) // Draw background + s.SetContent(f.x, f.y, '[', nil, style) + s.SetContent(f.x+f.width-1, f.y, ']', nil, style) + + if len(f.Text) > 0 { + endPos := f.scrollPos + Min(len(f.Text)-f.scrollPos, f.width-2) + DrawStr(s, f.x+1, f.y, f.Text[f.scrollPos:endPos], style) // Draw text + } + + // Update cursor + f.SetCursorPos(f.cursorPos) +} + +func (f *InputField) SetFocused(v bool) { + f.focused = v + if v { + f.SetCursorPos(f.cursorPos) + } else { + (*f.screen).HideCursor() + } +} + +func (f *InputField) SetTheme(theme *Theme) { + f.Theme = theme +} + +func (f *InputField) GetPos() (int, int) { + return f.x, f.y +} + +func (f *InputField) SetPos(x, y int) { + f.x, f.y = x, y +} + +func (f *InputField) GetMinSize() (int, int) { + return 0, 0 +} + +func (f *InputField) GetSize() (int, int) { + return f.width, f.height +} + +func (f *InputField) SetSize(width, height int) { + f.width, f.height = width, height +} + +func (f *InputField) HandleEvent(event tcell.Event) bool { + switch ev := event.(type) { + case *tcell.EventKey: + switch ev.Key() { + case tcell.KeyLeft: + f.SetCursorPos(f.cursorPos - 1) + case tcell.KeyRight: + f.SetCursorPos(f.cursorPos + 1) + case tcell.KeyRune: + ch := ev.Rune() + f.Text += string(ch) + f.SetCursorPos(f.cursorPos + 1) + default: + return false + } + return true + } + return false +} diff --git a/ui/label.go b/ui/label.go new file mode 100644 index 0000000..49556e9 --- /dev/null +++ b/ui/label.go @@ -0,0 +1,25 @@ +package ui + +// Align defines the text alignment of a label. +type Align uint8 + +const ( + // AlignLeft is the normal text alignment where text is aligned to the left + // of its bounding box. + AlignLeft Align = iota + // AlignRight causes text to be aligned to the right of its bounding box. + AlignRight + // AlignJustify causes text to be left-aligned, but also spaced so that it + // fits the entire box where it is being rendered. + AlignJustify +) + +// A Label is a component for rendering text. Text can be rendered easily +// without a Label, but this component forces the text to fit within its +// bounding box and allows for left-align, right-align, and justify. +type Label struct { + Text string + x, y int + width, height int + Alignment Align +} diff --git a/ui/menu.go b/ui/menu.go new file mode 100644 index 0000000..d513640 --- /dev/null +++ b/ui/menu.go @@ -0,0 +1,290 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" +) + +// Item is an interface implemented by ItemEntry and ItemMenu to be listed in Menus. +type Item interface { + isItem() + GetName() string +} + +// An ItemSeparator is like a blank Item that cannot actually be selected. It is useful +// for separating items in a Menu. +type ItemSeparator struct{} + +func (i *ItemSeparator) isItem() {} + +// GetName returns an empty string. +func (i *ItemSeparator) GetName() string { + return "" +} + +// ItemEntry is a listing in a Menu with a name and callback. +type ItemEntry struct { + Name string + Callback func() +} + +func (i *ItemEntry) isItem() {} + +// GetName returns the name of the ItemEntry. +func (i *ItemEntry) GetName() string { + return i.Name +} + +func (m *Menu) isItem() {} + +// GetName returns the name of the Menu. +func (m *Menu) GetName() string { + return m.Name +} + +// A MenuBar is a horizontal list of menus. +type MenuBar struct { + Menus []Menu + + x, y int + width, height int + menuExpanded bool + focused bool + selected int // Index of selection in MenuBar + + Theme *Theme +} + +func NewMenuBar(menus []Menu, theme *Theme) *MenuBar { + if menus == nil { + menus = make([]Menu, 0, 6) + } + + return &MenuBar{ + Menus: menus, + Theme: theme, + } +} + +// GetMenuXPos returns the X position of the name of Menu at `idx` visually. +func (b *MenuBar) GetMenuXPos(idx int) int { + x := 1 + for i := 0; i < idx; i++ { + x += len(b.Menus[i].Name) + 2 // two for padding + } + return x +} + +// Draw renders the MenuBar and its sub-menus. +func (b *MenuBar) Draw(s tcell.Screen) { + normalStyle := b.Theme.GetOrDefault("MenuBar") + + // Draw menus based on whether b.focused and which is selected + DrawRect(s, b.x, b.y, 200, 1, ' ', normalStyle) // TODO: calculate actual width + col := b.x + 1 + for i, item := range b.Menus { + str := fmt.Sprintf(" %s ", item.Name) // Surround the name in spaces + var sty tcell.Style + if b.selected == i && b.focused { // If we are drawing the selected item ... + sty = b.Theme.GetOrDefault("MenuBarSelected") // Use style for selected items + } else { + sty = normalStyle + } + DrawStr(s, col, b.y, str, sty) + col += len(str) + } + + if b.menuExpanded { + menu := b.Menus[b.selected] + menu.Draw(s) // Draw menu when it is expanded / visible + } +} + +// SetFocused highlights the MenuBar and focuses any sub-menus. +func (b *MenuBar) SetFocused(v bool) { + b.focused = v + if !v { + b.menuExpanded = false + } +} + +func (b *MenuBar) SetTheme(theme *Theme) { + b.Theme = theme +} + +// GetPos returns the position of the MenuBar. +func (b *MenuBar) GetPos() (int, int) { + return b.x, b.y +} + +// SetPos sets the position of the MenuBar. +func (b *MenuBar) SetPos(x, y int) { + b.x, b.y = x, y +} + +func (b *MenuBar) GetMinSize() (int, int) { + return 0, 1 +} + +// GetSize returns the size of the MenuBar. +func (b *MenuBar) GetSize() (int, int) { + return b.width, b.height +} + +// SetSize sets the size of the MenuBar. +func (b *MenuBar) SetSize(width, height int) { + b.width, b.height = width, height +} + +// HandleEvent will propogate events to sub-menus and returns true if +// any of them handled the event. +func (b *MenuBar) HandleEvent(event tcell.Event) bool { + switch ev := event.(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyEnter && !b.menuExpanded { + menu := &b.Menus[b.selected] + menu.SetPos(b.GetMenuXPos(b.selected), b.y+1) + menu.SetFocused(true) + + b.menuExpanded = true // Tells Draw() to render the menu + } else if ev.Key() == tcell.KeyLeft { + if b.selected <= 0 { + b.selected = len(b.Menus) - 1 // Wrap to end + } else { + b.selected-- + } + // Update position of new menu after changing menu selection + b.Menus[b.selected].SetPos(b.GetMenuXPos(b.selected), b.y+1) + } else if ev.Key() == tcell.KeyRight { + if b.selected >= len(b.Menus)-1 { + b.selected = 0 // Wrap to beginning + } else { + b.selected++ + } + // Update position of new menu after changing menu selection + b.Menus[b.selected].SetPos(b.GetMenuXPos(b.selected), b.y+1) + } else { + if b.menuExpanded { + return b.Menus[b.selected].HandleEvent(event) + } else { + return false // Nobody to propogate our event to + } + } + return true + } + return false +} + +// A Menu contains one or more ItemEntry or ItemMenus. +type Menu struct { + Name string + Items []Item + + x, y int + width, height int // Size may not be settable + selected int // Index of selected Item + + Theme *Theme +} + +// New creates a new Menu. `items` can be `nil`. +func NewMenu(name string, theme *Theme, items []Item) Menu { + if items == nil { + items = make([]Item, 0, 6) + } + + return Menu{ + Name: name, + Items: items, + Theme: theme, + } +} + +// Draw renders the Menu at its position. +func (m *Menu) Draw(s tcell.Screen) { + defaultStyle := m.Theme.GetOrDefault("Menu") + + m.GetSize() // Call this to update internal width and height + DrawRect(s, m.x, m.y, m.width, m.height, ' ', defaultStyle) // Fill background + DrawRectOutlineDefault(s, m.x, m.y, m.width, m.height, defaultStyle) // Draw outline + + // Draw items based on whether m.focused and which is selected + for i, item := range m.Items { + var sty tcell.Style + if m.selected == i { + sty = m.Theme.GetOrDefault("MenuSelected") + } else { + sty = defaultStyle + } + DrawStr(s, m.x+1, m.y+1+i, item.GetName(), sty) + } +} + +// SetFocused does not do anything for a Menu. +func (m *Menu) SetFocused(v bool) { + // TODO: wat do +} + +// GetPos returns the position of the Menu. +func (m *Menu) GetPos() (int, int) { + return m.x, m.y +} + +// SetPos sets the position of the Menu. +func (m *Menu) SetPos(x, y int) { + m.x, m.y = x, y +} + +// GetSize returns the size of the Menu. +func (m *Menu) GetSize() (int, int) { + // TODO: no, pls don't do this + maxLen := 0 + for _, item := range m.Items { + len := len(item.GetName()) + if len > maxLen { + maxLen = len + } + } + m.width = maxLen + 2 // Add two for padding + m.height = len(m.Items) + 2 // And another two for the same reason ... + return m.width, m.height +} + +// SetSize sets the size of the Menu. +func (m *Menu) SetSize(width, height int) { + // Cannot set the size of a Menu +} + +// HandleEvent will handle events for a Menu and may propogate them +// to sub-menus. Returns true if the event was handled. +func (m *Menu) HandleEvent(event tcell.Event) bool { + // TODO: simplify this function + switch ev := event.(type) { + case *tcell.EventKey: + if ev.Key() == tcell.KeyEnter { + switch item := m.Items[m.selected].(type) { + case *ItemEntry: + item.Callback() + case *Menu: + // TODO: implement sub-menus ... + } + return true + } else if ev.Key() == tcell.KeyUp { + if m.selected <= 0 { + m.selected = len(m.Items) - 1 // Wrap to end + } else { + m.selected-- + } + return true + } else if ev.Key() == tcell.KeyDown { + if m.selected >= len(m.Items)-1 { + m.selected = 0 // Wrap to beginning + } else { + m.selected++ + } + return true + } + } + return false +} diff --git a/ui/textedit.go b/ui/textedit.go new file mode 100644 index 0000000..e78d169 --- /dev/null +++ b/ui/textedit.go @@ -0,0 +1,414 @@ +package ui + +import ( + "fmt" + "math" + "strconv" + "strings" + + "github.com/gdamore/tcell/v2" +) + +// TextEdit is a field for line-based editing. It features syntax highlighting +// tools, is autocomplete ready, and contains the various information about +// content being edited. +type TextEdit struct { + LineNumbers bool // Whether to render line numbers (and therefore the column) + Dirty bool // Whether the buffer has been edited + UseHardTabs bool // When true, tabs are '\t' + TabSize int // How many spaces to indent by + IsCRLF bool // Whether the file's line endings are CRLF (\r\n) or LF (\n) + FilePath string // Will be empty if the file has not been saved yet + + buffer []string // TODO: replace line-based buffer with gap buffer + screen *tcell.Screen // We keep our own reference to the screen for cursor purposes. + x, y int + width, height int + focused bool + curx, cury int // Zero-based: cursor points before the character at that position. + prevCurCol int // Previous maximum column the cursor was at, when the user pressed left or right + scrollx, scrolly int // X and Y offset of view, known as scroll + + Theme *Theme +} + +// New will initialize the buffer using the given string `contents`. If the `filePath` or `FilePath` is empty, +// it can be assumed that the TextEdit has no file association, or it is unsaved. +func NewTextEdit(screen *tcell.Screen, filePath, contents string, theme *Theme) *TextEdit { + te := &TextEdit{ + LineNumbers: true, + UseHardTabs: true, + TabSize: 4, + FilePath: filePath, + buffer: nil, + screen: screen, + Theme: theme, + } + te.SetContents(contents) + return te +} + +// SetContents applies the string to the internal buffer of the TextEdit component. +// The string is determined to be either CRLF or LF based on line-endings. +func (t *TextEdit) SetContents(contents string) { +loop: + for _, r := range contents { + switch r { + case '\n': + t.IsCRLF = false + break loop + case '\r': + // We could check for a \n after, but what's the point? + t.IsCRLF = true + break loop + } + } + + delimiter := "\n" + if t.IsCRLF { + delimiter = "\r\n" + } + + t.buffer = strings.Split(contents, delimiter) // Split contents into lines +} + +func (t *TextEdit) String() string { + delimiter := "\n" + if t.IsCRLF { + delimiter = "\r\n" + } + return strings.Join(t.buffer, delimiter) +} + +// Changes a file's line delimiters. If `crlf` is true, then line delimiters are replaced +// with Windows CRLF (\r\n). If `crlf` is false, then line delimtiers are replaced with Unix +// LF (\n). The TextEdit `IsCRLF` variable is updated with the new value. +func (t *TextEdit) ChangeLineDelimiters(crlf bool) { + t.IsCRLF = crlf + // line delimiters are constructed with String() function +} + +// Delete with `forwards` false will backspace, destroying the character before the cursor, +// while Delete with `forwards` true will delete the character after (or on) the cursor. +// In insert mode, forwards is always true. +func (t *TextEdit) Delete(forwards bool) { + // TODO: deleting through lines + if forwards { // Delete the character after the cursor + if t.curx < len(t.buffer[t.cury]) { // If the cursor is not at the end of the line... + lineRunes := []rune(t.buffer[t.cury]) + copy(lineRunes[t.curx:], lineRunes[t.curx+1:]) // Shift runes at cursor + 1 left + lineRunes = lineRunes[:len(lineRunes)-1] // Shrink line length + t.buffer[t.cury] = string(lineRunes) // Reassign line + } else { // If the cursor is at the end of the line... + if t.cury < len(t.buffer)-1 { // And the cursor is not at the last line... + oldLineIdx := t.cury + 1 + curLineRunes := []rune(t.buffer[t.cury]) + oldLineRunes := []rune(t.buffer[oldLineIdx]) + curLineRunes = append(curLineRunes, oldLineRunes...) // Append runes from deleted line to current line + t.buffer[t.cury] = string(curLineRunes) // Update the current line with the new runes + + copy(t.buffer[oldLineIdx:], t.buffer[oldLineIdx+1:]) // Shift lines below the old line up + t.buffer = t.buffer[:len(t.buffer)-1] // Shrink buffer by one line + } + } + } else { // Delete the character before the cursor + if t.curx > 0 { // If the cursor is not at the beginning of the line... + lineRunes := []rune(t.buffer[t.cury]) + copy(lineRunes[t.curx-1:], lineRunes[t.curx:]) // Shift runes at cursor left + lineRunes = lineRunes[:len(lineRunes)-1] // Shrink line length + t.buffer[t.cury] = string(lineRunes) // Reassign line + + t.SetLineCol(t.cury, t.curx-1) // Shift cursor left + } else { // If the cursor is at the beginning of the line... + if t.cury > 0 { // And the cursor is not at the first line... + oldLineIdx := t.cury + t.SetLineCol(t.cury-1, len(t.buffer[t.cury-1])) // Cursor goes to the end of the above line + curLineRunes := []rune(t.buffer[t.cury]) + oldLineRunes := []rune(t.buffer[oldLineIdx]) + curLineRunes = append(curLineRunes, oldLineRunes...) // Append the old line to the current line + t.buffer[t.cury] = string(curLineRunes) // Update the current line to the new runes + + copy(t.buffer[oldLineIdx:], t.buffer[oldLineIdx+1:]) // Shift lines below the old line up + t.buffer = t.buffer[:len(t.buffer)-1] // Shrink buffer by one line + } + } + } +} + +// Writes `contents` at the cursor position. Line delimiters and tab character supported. +// Any other control characters will be printed. +func (t *TextEdit) Insert(contents string) { + runes := []rune(contents) + for i := 0; i < len(runes); i++ { + ch := runes[i] + switch ch { + case '\r': + // If the character after is a \n, then it is a CRLF + if i+1 < len(runes) && runes[i+1] == '\n' { + i++ + t.insertNewLine() + } + case '\n': + t.insertNewLine() + case '\b': + t.Delete(false) // Delete the character before the cursor + case '\t': + if !t.UseHardTabs { // If this file does not use hard tabs... + // Insert spaces + spaces := []rune(strings.Repeat(" ", t.TabSize)) + spacesLen := len(spaces) + + lineRunes := []rune(t.buffer[t.cury]) + lineRunes = append(lineRunes, spaces...) + copy(lineRunes[t.curx+spacesLen:], lineRunes[t.curx:]) // Shift runes at cursor to the right + copy(lineRunes[t.curx:], spaces) // Copy spaces into the gap + + t.buffer[t.cury] = string(lineRunes) // Reassign the line + + t.SetLineCol(t.cury, t.curx+spacesLen) // Advance the cursor + break + } + fallthrough // Append the \t character + default: + // TODO: this operation is not efficient by any means. OPTIMIZE + + // Insert character into line + lineRunes := []rune(t.buffer[t.cury]) + lineRunes = append(lineRunes, ch) // Extend the length of the string + copy(lineRunes[t.curx+1:], lineRunes[t.curx:]) // Shift runes at cursor to the right + lineRunes[t.curx] = ch + + t.buffer[t.cury] = string(lineRunes) // Reassign the line + + t.SetLineCol(t.cury, t.curx+1) // Advance the cursor + } + } +} + +// insertNewLine inserts a line break at the cursor and sets the cursor position to the first +// column of that new line. Text before the cursor on the current line remains on that line, +// text at or after the cursor on the current line is moved to the new line. +func (t *TextEdit) insertNewLine() { + lineRunes := []rune(t.buffer[t.cury]) // A slice of runes of the old line + movedRunes := lineRunes[t.curx:] // A slice of the old line containing runes to be moved + newLineRunes := make([]rune, len(movedRunes)) + copy(newLineRunes, movedRunes) // Copy old runes to new line + t.buffer[t.cury] = string(lineRunes[:t.curx]) // Shrink old line's length + + t.buffer = append(t.buffer, "") // Increment buffer length + copy(t.buffer[t.cury+2:], t.buffer[t.cury+1:]) // Shift lines after current line down + t.buffer[t.cury+1] = string(newLineRunes) // Assign the new line + + t.SetLineCol(t.cury+1, 0) // Go to start of new line +} + +// GetLineCol returns (line, col) of the cursor. Zero is origin for both. +func (t *TextEdit) GetLineCol() (int, int) { + return t.cury, t.curx +} + +// SetLineCol sets the cursor line and column position. Zero is origin for both. +// If `line` is out of bounds, `line` will be clamped to the closest available line. +// If `col` is out of bounds, `col` will be clamped to the closest column available for the line. +// Will scroll the TextEdit just enough to see the line the cursor is at. +func (t *TextEdit) SetLineCol(line, col int) { + // Clamp the line input + if line < 0 { + line = 0 + } else if len := len(t.buffer); line >= len { // If new line is beyond the length of the buffer... + line = len - 1 // Change that line to be the end of the buffer, instead + } + + // Clamp the column input + if col < 0 { + col = 0 + } else if len := len(t.buffer[line]); col > len { + col = len + } + + // Handle hard tabs + tabOffset := 0 // Offset for the current column (temporary; purely visual) + if t.UseHardTabs { // If the file is encoded as tabs, not spaces... + tabOffset = (t.TabSize - 1) * strings.Count(t.buffer[line][0:col], "\t") // Shift the cursor for tabs (they are rendered as spaces) + } + + // Scroll the screen when going to lines out of view + if line >= t.scrolly+t.height-1 { // If the new line is below view... + t.scrolly = line - t.height + 1 // Scroll just enough to view that line + } else if line < t.scrolly { // If the new line is above view + t.scrolly = line + } + + t.cury, t.curx = line, col + if t.focused { + columnWidth := t.getColumnWidth() + (*t.screen).ShowCursor(t.x+columnWidth+col+tabOffset, t.y+line-t.scrolly) + } +} + +// CursorUp moves the cursor up a line. +func (t *TextEdit) CursorUp() { + t.SetLineCol(t.cury-1, t.prevCurCol) +} + +// CursorDown moves the cursor down a line. +func (t *TextEdit) CursorDown() { + if t.cury >= len(t.buffer)-1 { // If the cursor is at the last line... + t.SetLineCol(t.cury, len(t.buffer[t.cury])) // Go to end of current line + } else { + t.SetLineCol(t.cury+1, t.prevCurCol) // Go to line below + } +} + +// CursorLeft moves the cursor left a column. +func (t *TextEdit) CursorLeft() { + if t.curx <= 0 && t.cury != 0 { // If we are at the beginning of the current line... + t.SetLineCol(t.cury-1, math.MaxInt32) // Go to end of line above + } else { + t.SetLineCol(t.cury, t.curx-1) + } + t.prevCurCol = t.curx +} + +// CursorRight moves the cursor right a column. +func (t *TextEdit) CursorRight() { + // If we are at the end of the current line, + // and not at the last line... + if t.curx >= len(t.buffer[t.cury]) && t.cury < len(t.buffer)-1 { + t.SetLineCol(t.cury+1, 0) // Go to beginning of line below + } else { + t.SetLineCol(t.cury, t.curx+1) + } + t.prevCurCol = t.curx +} + +// getColumnWidth returns the width of the line numbers column if it is present. +func (t *TextEdit) getColumnWidth() int { + columnWidth := 0 + if t.LineNumbers { + // Set columnWidth to max count of line number digits + columnWidth = Max(2, len(strconv.Itoa(len(t.buffer)))) // Column has minimum width of 2 + } + return columnWidth +} + +// Draw renders the TextEdit component. +func (t *TextEdit) Draw(s tcell.Screen) { + columnWidth := t.getColumnWidth() + bufferLen := len(t.buffer) + + textEditStyle := t.Theme.GetOrDefault("TextEdit") + columnStyle := t.Theme.GetOrDefault("TextEditColumn") + + DrawRect(s, t.x, t.y, t.width, t.height, ' ', textEditStyle) // Fill background + + for lineY := t.y; lineY < t.y+t.height; lineY++ { // For each line we can draw... + line := lineY + t.scrolly - t.y // The line number being drawn (starts at zero) + + lineNumStr := "" + + if line < bufferLen { // Only index buffer if we are within it... + lineNumStr = strconv.Itoa(line + 1) // Line number as a string + + var lineStr string // Line to be drawn + if t.UseHardTabs { + lineStr = strings.ReplaceAll(t.buffer[line], "\t", strings.Repeat(" ", t.TabSize)) + } else { + lineStr = t.buffer[line] + } + + DrawStr(s, t.x+columnWidth, lineY, lineStr, textEditStyle) // Draw line + } + + columnStr := fmt.Sprintf("%s%s", strings.Repeat(" ", columnWidth-len(lineNumStr)), lineNumStr) // Right align line number + + DrawStr(s, t.x, lineY, columnStr, columnStyle) // Draw column + } + + // Update cursor + t.SetLineCol(t.cury, t.curx) +} + +// SetFocused sets whether the TextEdit is focused. When focused, the cursor is set visible +// and its position is updated on every event. +func (t *TextEdit) SetFocused(v bool) { + t.focused = v + if v { + t.SetLineCol(t.curx, t.cury) + } else { + (*t.screen).HideCursor() + } +} + +func (t *TextEdit) SetTheme(theme *Theme) { + t.Theme = theme +} + +// GetPos gets the position of the TextEdit. +func (t *TextEdit) GetPos() (int, int) { + return t.x, t.y +} + +// SetPos sets the position of the TextEdit. +func (t *TextEdit) SetPos(x, y int) { + t.x, t.y = x, y +} + +func (t *TextEdit) GetMinSize() (int, int) { + return 0, 0 +} + +// GetSize gets the size of the TextEdit. +func (t *TextEdit) GetSize() (int, int) { + return t.width, t.height +} + +// SetSize sets the size of the TextEdit. +func (t *TextEdit) SetSize(width, height int) { + t.width, t.height = width, height +} + +// HandleEvent allows the TextEdit to handle `event` if it chooses, returns +// whether the TextEdit handled the event. +func (t *TextEdit) HandleEvent(event tcell.Event) bool { + switch ev := event.(type) { + case *tcell.EventKey: + switch ev.Key() { + // Cursor movement + case tcell.KeyUp: + t.CursorUp() + case tcell.KeyDown: + t.CursorDown() + case tcell.KeyLeft: + t.CursorLeft() + case tcell.KeyRight: + t.CursorRight() + case tcell.KeyHome: + t.SetLineCol(t.cury, 0) + case tcell.KeyEnd: + t.SetLineCol(t.cury, len(t.buffer[t.cury])) + + // Deleting + case tcell.KeyBackspace: + fallthrough + case tcell.KeyBackspace2: + t.Delete(false) + case tcell.KeyDelete: + t.Delete(true) + + // Other control + case tcell.KeyTab: + t.Insert("\t") // (can translate to four spaces) + case tcell.KeyEnter: + t.insertNewLine() + + // Inserting + case tcell.KeyRune: + t.Insert(string(ev.Rune())) // Insert rune + default: + return false + } + return true + } + return false +} diff --git a/ui/theme.go b/ui/theme.go new file mode 100644 index 0000000..bf6bd84 --- /dev/null +++ b/ui/theme.go @@ -0,0 +1,47 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" +) + +// A Theme is a map of string names to styles. Themes can be passed by reference to components +// to set their styles. Some components will depend upon the basic keys, but most components +// may use keys specific to their component. If a theme value cannot be found, then the +// `DefaultTheme` value will be used, instead. An updated list of theme keys can be found on +// the default theme. +type Theme map[string]tcell.Style + +func (theme *Theme) GetOrDefault(key string) tcell.Style { + if theme != nil { + if val, ok := (*theme)[key]; ok { + return val + } + } + + if val, ok := DefaultTheme[key]; ok { + return val + } else { + panic(fmt.Sprintf("key \"%v\" not present in default theme", key)) + } +} + +// DefaultTheme uses only the first 16 colors present in most colored terminals. +var DefaultTheme = Theme{ + "Normal": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "Button": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorWhite), + "InputField": tcell.Style{}.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack), + "MenuBar": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver), + "MenuBarSelected": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "Menu": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver), + "MenuSelected": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "QuickChar": tcell.Style{}.Foreground(tcell.ColorYellow).Background(tcell.ColorBlack), + "Tab": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "TabContainer": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "TabSelected": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver), + "TextEdit": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack), + "TextEditColumn": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorGray), + "Window": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver), + "WindowHeader": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorWhite), +} diff --git a/ui/util.go b/ui/util.go new file mode 100644 index 0000000..4cb7d44 --- /dev/null +++ b/ui/util.go @@ -0,0 +1,23 @@ +package ui + +// Max returns the larger integer. +func Max(a, b int) int { + if a > b { + return a + } + return b +} + +// Min returns the smaller integer. +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +// Clamp keeps `v` within `a` and `b` numerically. `a` must be smaller than `b`. +// Returns clamped `v`. +func Clamp(v, a, b int) int { + return Max(a, Min(v, b)) +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..d252776 --- /dev/null +++ b/util.go @@ -0,0 +1,10 @@ +package main + +// Max returns the larger of two integers. +func Max(a, b int) int { + if a > b { + return a + } else { + return b + } +}