diff --git a/elements/containers/document.go b/elements/containers/document.go index 3e12bea..13b36dd 100644 --- a/elements/containers/document.go +++ b/elements/containers/document.go @@ -219,7 +219,6 @@ func (element *DocumentContainer) NotifyFlexibleHeightChange (child elements.Fle element.core.DamageAll() } - // SetTheme sets the element's theme. func (element *DocumentContainer) SetTheme (new theme.Theme) { if new == element.theme.Theme { return } diff --git a/elements/containers/scroll.go b/elements/containers/scroll.go index 5f29b26..0d0a51e 100644 --- a/elements/containers/scroll.go +++ b/elements/containers/scroll.go @@ -152,6 +152,9 @@ func (element *ScrollContainer) HandleScroll ( x, y int, deltaX, deltaY float64, ) { + horizontal, vertical := element.child.ScrollAxes() + if !horizontal { deltaX = 0 } + if !vertical { deltaY = 0 } element.scrollChildBy(int(deltaX), int(deltaY)) } diff --git a/elements/file/directory.go b/elements/file/directory.go new file mode 100644 index 0000000..3a7c785 --- /dev/null +++ b/elements/file/directory.go @@ -0,0 +1,375 @@ +package fileElements + +import "io/fs" +import "image" +import "path/filepath" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/input" +import "git.tebibyte.media/sashakoshka/tomo/artist" +import "git.tebibyte.media/sashakoshka/tomo/canvas" +import "git.tebibyte.media/sashakoshka/tomo/config" +import "git.tebibyte.media/sashakoshka/tomo/textdraw" +import "git.tebibyte.media/sashakoshka/tomo/elements" +import "git.tebibyte.media/sashakoshka/tomo/elements/core" + +type fileLayoutEntry struct { + *File + fs.DirEntry + Bounds image.Rectangle + Drawer textdraw.Drawer + TextPoint image.Point +} + +type historyEntry struct { + location string + filesystem ReadDirStatFS +} + +// Directory displays a list of files within a particular directory and +// file system. +type Directory struct { + *core.Core + *core.Propagator + core core.CoreControl + + children []fileLayoutEntry + scroll image.Point + contentBounds image.Rectangle + + config config.Wrapped + theme theme.Wrapped + + onScrollBoundsChange func () + + history []historyEntry + historyIndex int + onChoose func (file string) +} + +// NewDirectory creates a new directory view. If within is nil, it will use +// the OS file system. +func NewDirectory ( + location string, + within ReadDirStatFS, +) ( + element *Directory, + err error, +) { + element = &Directory { } + element.theme.Case = theme.C("files", "Directory") + element.Core, element.core = core.NewCore(element, element.redoAll) + element.Propagator = core.NewPropagator(element, element.core) + err = element.SetLocation(location, within) + return +} + +// Location returns the directory's location and filesystem. +func (element *Directory) Location () (string, ReadDirStatFS) { + if len(element.history) < 1 { return "", nil } + current := element.history[element.historyIndex] + return current.location, current.filesystem +} + +// SetLocation sets the directory's location and filesystem. If within is nil, +// it will use the OS file system. +func (element *Directory) SetLocation ( + location string, + within ReadDirStatFS, +) error { + if within == nil { + within = defaultFS { } + } + element.scroll = image.Point { } + + if element.history != nil { + element.historyIndex ++ + } + element.history = append ( + element.history[:element.historyIndex], + historyEntry { location, within }) + return element.Update() +} + +// Backward goes back a directory in history +func (element *Directory) Backward () (bool, error) { + if element.historyIndex > 1 { + element.historyIndex -- + return true, element.Update() + } else { + return false, nil + } +} + +// Forward goes forward a directory in history +func (element *Directory) Forward () (bool, error) { + if element.historyIndex < len(element.history) - 1 { + element.historyIndex ++ + return true, element.Update() + } else { + return false, nil + } +} + +// Update refreshes the directory's contents. +func (element *Directory) Update () error { + location, filesystem := element.Location() + entries, err := filesystem.ReadDir(location) + + // disown all entries + for _, file := range element.children { + file.DrawTo(nil, image.Rectangle { }, nil) + file.SetParent(nil) + + if file.Focused() { + file.HandleUnfocus() + } + } + + element.children = make([]fileLayoutEntry, len(entries)) + for index, entry := range entries { + filePath := filepath.Join(location, entry.Name()) + file, err := NewFile(filePath, filesystem) + if err != nil { continue } + file.SetParent(element) + file.OnChoose (func () { + if element.onChoose != nil { + element.onChoose(filePath) + } + }) + element.children[index].File = file + element.children[index].DirEntry = entry + element.children[index].Drawer.SetFace (element.theme.FontFace( + theme.FontStyleRegular, + theme.FontSizeNormal)) + element.children[index].Drawer.SetText([]rune(entry.Name())) + element.children[index].Drawer.SetAlign(textdraw.AlignCenter) + } + + if element.core.HasImage() { + element.redoAll() + element.core.DamageAll() + } + return err +} + +// OnChoose sets a function to be called when the user double-clicks a file or +// sub-directory within the directory view. +func (element *Directory) OnChoose (callback func (file string)) { + element.onChoose = callback +} + +// CountChildren returns the amount of children contained within this element. +func (element *Directory) CountChildren () (count int) { + return len(element.children) +} + +// Child returns the child at the specified index. If the index is out of +// bounds, this method will return nil. +func (element *Directory) Child (index int) (child elements.Element) { + if index < 0 || index > len(element.children) { return } + return element.children[index].File +} + +func (element *Directory) HandleMouseDown (x, y int, button input.Button) { + if button == input.ButtonLeft { + var file *File + for _, entry := range element.children { + if image.Pt(x, y).In(entry.Bounds) { + file = entry.File + } + } + if file != nil { + file.SetSelected(!file.Selected()) + } + } + element.Propagator.HandleMouseDown(x, y, button) +} + +func (element *Directory) redoAll () { + if !element.core.HasImage() { return } + + // do a layout + element.doLayout() + + maxScrollHeight := element.maxScrollHeight() + if element.scroll.Y > maxScrollHeight { + element.scroll.Y = maxScrollHeight + element.doLayout() + } + + // draw a background + rocks := make([]image.Rectangle, len(element.children)) + for index, entry := range element.children { + rocks[index] = entry.Bounds + } + pattern := element.theme.Pattern ( + theme.PatternPinboard, + theme.State { }) + artist.DrawShatter(element.core, pattern, element.Bounds(), rocks...) + + element.partition() + if parent, ok := element.core.Parent().(elements.ScrollableParent); ok { + parent.NotifyScrollBoundsChange(element) + } + if element.onScrollBoundsChange != nil { + element.onScrollBoundsChange() + } + + // draw labels + foreground := element.theme.Color(theme.ColorForeground, theme.State { }) + for _, entry := range element.children { + entry.Drawer.Draw(element.core, foreground, entry.TextPoint) + } +} + +func (element *Directory) partition () { + for _, entry := range element.children { + entry.DrawTo(nil, entry.Bounds, nil) + } + + // cut our canvas up and give peices to child elements + for _, entry := range element.children { + if entry.Bounds.Overlaps(element.Bounds()) { + entry.DrawTo ( + canvas.Cut(element.core, entry.Bounds), + entry.Bounds, func (region image.Rectangle) { + element.core.DamageRegion(region) + }) + } + } +} + +// NotifyMinimumSizeChange notifies the container that the minimum size of a +// child element has changed. +func (element *Directory) NotifyMinimumSizeChange (child elements.Element) { + element.redoAll() + element.core.DamageAll() +} + +// SetTheme sets the element's theme. +func (element *Directory) SetTheme (new theme.Theme) { + if new == element.theme.Theme { return } + element.theme.Theme = new + element.Propagator.SetTheme(new) + element.redoAll() +} + +// SetConfig sets the element's configuration. +func (element *Directory) SetConfig (new config.Config) { + if new == element.config.Config { return } + element.Propagator.SetConfig(new) + element.redoAll() +} +// ScrollContentBounds returns the full content size of the element. +func (element *Directory) ScrollContentBounds () image.Rectangle { + return element.contentBounds +} + +// ScrollViewportBounds returns the size and position of the element's +// viewport relative to ScrollBounds. +func (element *Directory) ScrollViewportBounds () image.Rectangle { + padding := element.theme.Padding(theme.PatternPinboard) + bounds := padding.Apply(element.Bounds()) + bounds = bounds.Sub(bounds.Min).Add(element.scroll) + return bounds +} + +// ScrollTo scrolls the viewport to the specified point relative to +// ScrollBounds. +func (element *Directory) ScrollTo (position image.Point) { + if position.Y < 0 { + position.Y = 0 + } + maxScrollHeight := element.maxScrollHeight() + if position.Y > maxScrollHeight { + position.Y = maxScrollHeight + } + element.scroll = position + if element.core.HasImage() { + element.redoAll() + element.core.DamageAll() + } +} + +// OnScrollBoundsChange sets a function to be called when the element's viewport +// bounds, content bounds, or scroll axes change. +func (element *Directory) OnScrollBoundsChange (callback func ()) { + element.onScrollBoundsChange = callback +} + +// ScrollAxes returns the supported axes for scrolling. +func (element *Directory) ScrollAxes () (horizontal, vertical bool) { + return false, true +} + +func (element *Directory) maxScrollHeight () (height int) { + padding := element.theme.Padding(theme.PatternSunken) + viewportHeight := element.Bounds().Dy() - padding.Vertical() + height = element.contentBounds.Dy() - viewportHeight + if height < 0 { height = 0 } + return +} + +func (element *Directory) doLayout () { + margin := element.theme.Margin(theme.PatternPinboard) + padding := element.theme.Padding(theme.PatternPinboard) + bounds := padding.Apply(element.Bounds()) + element.contentBounds = image.Rectangle { } + + beginningOfRow := true + dot := bounds.Min.Sub(element.scroll) + rowHeight := 0 + for index, entry := range element.children { + width, height := entry.MinimumSize() + + if dot.X + width > bounds.Max.X { + dot.X = bounds.Min.Sub(element.scroll).X + dot.Y += rowHeight + if index > 1 { + dot.Y += margin.Y + } + beginningOfRow = true + } + + if beginningOfRow { + beginningOfRow = false + } else { + dot.X += margin.X + } + + entry.Drawer.SetMaxWidth(width) + bounds := image.Rect(dot.X, dot.Y, dot.X + width, dot.Y + height) + entry.Bounds = bounds + + drawerHeight := entry.Drawer.ReccomendedHeightFor(width) + entry.TextPoint = + image.Pt(bounds.Min.X, bounds.Max.Y + margin.Y). + Sub(entry.Drawer.LayoutBounds().Min) + bounds.Max.Y += margin.Y + drawerHeight + height += margin.Y + drawerHeight + if rowHeight < height { + rowHeight = height + } + + element.contentBounds = element.contentBounds.Union(bounds) + element.children[index] = entry + dot.X += width + } + + element.contentBounds = + element.contentBounds.Sub(element.contentBounds.Min) +} + +func (element *Directory) updateMinimumSize () { + padding := element.theme.Padding(theme.PatternPinboard) + minimumWidth := 0 + for _, entry := range element.children { + width, _ := entry.MinimumSize() + if width > minimumWidth { + minimumWidth = width + } + } + element.core.SetMinimumSize ( + minimumWidth + padding.Horizontal(), + padding.Vertical()) +} diff --git a/elements/file/file.go b/elements/file/file.go new file mode 100644 index 0000000..9d27bbe --- /dev/null +++ b/elements/file/file.go @@ -0,0 +1,216 @@ +package fileElements + +import "time" +import "io/fs" +import "image" +import "git.tebibyte.media/sashakoshka/tomo/input" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/artist" +import "git.tebibyte.media/sashakoshka/tomo/config" +import "git.tebibyte.media/sashakoshka/tomo/elements/core" + +// File displays an interactive visual representation of a file within any +// file system. +type File struct { + *core.Core + *core.FocusableCore + core core.CoreControl + focusableControl core.FocusableCoreControl + + config config.Wrapped + theme theme.Wrapped + + lastClick time.Time + pressed bool + iconID theme.Icon + filesystem fs.StatFS + location string + selected bool + + onChoose func () +} + +// NewFile creates a new file element. If within is nil, it will use the OS file +// system +func NewFile ( + location string, + within fs.StatFS, +) ( + element *File, + err error, +) { + element = &File { } + element.theme.Case = theme.C("files", "file") + element.Core, element.core = core.NewCore(element, element.drawAll) + element.FocusableCore, + element.focusableControl = core.NewFocusableCore(element.core, element.drawAndPush) + err = element.SetLocation(location, within) + return +} + +// Location returns the file's location and filesystem. +func (element *File) Location () (string, fs.StatFS) { + return element.location, element.filesystem +} + +// SetLocation sets the file's location and filesystem. If within is nil, it +// will use the OS file system. +func (element *File) SetLocation ( + location string, + within fs.StatFS, +) error { + if within == nil { + within = defaultFS { } + } + element.location = location + element.filesystem = within + return element.Update() +} + +// Update refreshes the element to match the file it represents. +func (element *File) Update () error { + element.iconID = theme.IconError + info, err := element.filesystem.Stat(element.location) + if err != nil { return err } + + // TODO: choose icon based on file mime type + if info.IsDir() { + element.iconID = theme.IconDirectory + } else { + element.iconID = theme.IconFile + } + + element.updateMinimumSize() + element.drawAndPush() + return err +} + +func (element *File) Selected () bool { + return element.selected +} + +func (element *File) SetSelected (selected bool) { + if element.selected == selected { return } + element.selected = selected + element.drawAndPush() +} + +func (element *File) HandleKeyDown (key input.Key, modifiers input.Modifiers) { + if !element.Enabled() { return } + if key == input.KeyEnter { + element.pressed = true + element.drawAndPush() + } +} + +func (element *File) HandleKeyUp(key input.Key, modifiers input.Modifiers) { + if key == input.KeyEnter && element.pressed { + element.pressed = false + element.drawAndPush() + if !element.Enabled() { return } + if element.onChoose != nil { + element.onChoose() + } + } +} + +func (element *File) OnChoose (callback func ()) { + element.onChoose = callback +} + +func (element *File) HandleMouseDown (x, y int, button input.Button) { + if !element.Enabled() { return } + if !element.Focused() { element.Focus() } + if button != input.ButtonLeft { return } + element.pressed = true + element.drawAndPush() +} + +func (element *File) HandleMouseUp (x, y int, button input.Button) { + if button != input.ButtonLeft { return } + element.pressed = false + within := image.Point { x, y }. + In(element.Bounds()) + if time.Since(element.lastClick) < time.Second / 2 { + if element.Enabled() && within && element.onChoose != nil { + element.onChoose() + } + } else { + element.lastClick = time.Now() + } + element.drawAndPush() +} + +// SetTheme sets the element's theme. +func (element *File) SetTheme (new theme.Theme) { + if new == element.theme.Theme { return } + element.theme.Theme = new + element.drawAndPush() +} + +// SetConfig sets the element's configuration. +func (element *File) SetConfig (new config.Config) { + if new == element.config.Config { return } + element.config.Config = new + element.drawAndPush() +} + +func (element *File) state () theme.State { + return theme.State { + Disabled: !element.Enabled(), + Focused: element.Focused(), + Pressed: element.pressed, + On: element.selected, + } +} + +func (element *File) icon () artist.Icon { + return element.theme.Icon(element.iconID, theme.IconSizeLarge) +} + +func (element *File) updateMinimumSize () { + padding := element.theme.Padding(theme.PatternButton) + icon := element.icon() + if icon == nil { + element.core.SetMinimumSize ( + padding.Horizontal(), + padding.Vertical()) + } else { + bounds := padding.Inverse().Apply(icon.Bounds()) + element.core.SetMinimumSize(bounds.Dx(), bounds.Dy()) + } +} + +func (element *File) drawAndPush () { + if element.core.HasImage() { + element.drawAll() + element.core.DamageAll() + } +} + +func (element *File) drawAll () { + // background + state := element.state() + bounds := element.Bounds() + sink := element.theme.Sink(theme.PatternButton) + element.theme. + Pattern(theme.PatternButton, state). + Draw(element.core, bounds) + + // icon + icon := element.icon() + if icon != nil { + iconBounds := icon.Bounds() + offset := image.Pt ( + (bounds.Dx() - iconBounds.Dx()) / 2, + (bounds.Dy() - iconBounds.Dy()) / 2) + if element.pressed { + offset = offset.Add(sink) + } + icon.Draw ( + element.core, + element.theme.Color ( + theme.ColorForeground, state), + bounds.Min.Add(offset)) + } +} diff --git a/elements/file/fs.go b/elements/file/fs.go new file mode 100644 index 0000000..85572e3 --- /dev/null +++ b/elements/file/fs.go @@ -0,0 +1,25 @@ +package fileElements + +import "os" +import "io/fs" + +// ReadDirStatFS is a combination of fs.ReadDirFS and fs.StatFS. It is the +// minimum filesystem needed to satisfy a directory view. +type ReadDirStatFS interface { + fs.ReadDirFS + fs.StatFS +} + +type defaultFS struct { } + +func (defaultFS) Open (name string) (fs.File, error) { + return os.Open(name) +} + +func (defaultFS) ReadDir (name string) ([]fs.DirEntry, error) { + return os.ReadDir(name) +} + +func (defaultFS) Stat (name string) (fs.FileInfo, error) { + return os.Stat(name) +} diff --git a/examples/fileBrowser/main.go b/examples/fileBrowser/main.go new file mode 100644 index 0000000..beabfc2 --- /dev/null +++ b/examples/fileBrowser/main.go @@ -0,0 +1,92 @@ +package main + +import "os" +import "path/filepath" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/layouts/basic" +import "git.tebibyte.media/sashakoshka/tomo/elements/file" +import "git.tebibyte.media/sashakoshka/tomo/elements/basic" +import "git.tebibyte.media/sashakoshka/tomo/elements/containers" +import _ "git.tebibyte.media/sashakoshka/tomo/backends/all" + +func main () { + tomo.Run(run) +} + +func run () { + window, _ := tomo.NewWindow(384, 384) + window.SetTitle("File browser") + container := containers.NewContainer(basicLayouts.Vertical { true, true }) + window.Adopt(container) + homeDir, _ := os.UserHomeDir() + + controlBar := containers.NewContainer(basicLayouts.Horizontal { }) + backButton := basicElements.NewButton("Back") + backButton.SetIcon(theme.IconBackward) + backButton.ShowText(false) + forwardButton := basicElements.NewButton("Forward") + forwardButton.SetIcon(theme.IconForward) + forwardButton.ShowText(false) + refreshButton := basicElements.NewButton("Refresh") + refreshButton.SetIcon(theme.IconRefresh) + refreshButton.ShowText(false) + upwardButton := basicElements.NewButton("Go Up") + upwardButton.SetIcon(theme.IconUpward) + upwardButton.ShowText(false) + locationInput := basicElements.NewTextBox("Location", "") + + statusBar := containers.NewContainer(basicLayouts.Horizontal { true, false }) + directory, _ := fileElements.NewFile(homeDir, nil) + baseName := basicElements.NewLabel(filepath.Base(homeDir), false) + + scrollContainer := containers.NewScrollContainer(false, true) + directoryView, _ := fileElements.NewDirectory(homeDir, nil) + updateStatus := func () { + filePath, _ := directoryView.Location() + directory.SetLocation(filePath, nil) + locationInput.SetValue(filePath) + baseName.SetText(filepath.Base(filePath)) + } + choose := func (filePath string) { + directoryView.SetLocation(filePath, nil) + updateStatus() + } + directoryView.OnChoose(choose) + locationInput.OnEnter (func () { + choose(locationInput.Value()) + }) + choose(homeDir) + backButton.OnClick (func () { + directoryView.Backward() + updateStatus() + }) + forwardButton.OnClick (func () { + directoryView.Forward() + updateStatus() + }) + refreshButton.OnClick (func () { + directoryView.Update() + updateStatus() + }) + upwardButton.OnClick (func () { + filePath, _ := directoryView.Location() + choose(filepath.Dir(filePath)) + }) + + controlBar.Adopt(backButton, false) + controlBar.Adopt(forwardButton, false) + controlBar.Adopt(refreshButton, false) + controlBar.Adopt(upwardButton, false) + controlBar.Adopt(locationInput, true) + scrollContainer.Adopt(directoryView) + statusBar.Adopt(directory, false) + statusBar.Adopt(baseName, false) + + container.Adopt(controlBar, false) + container.Adopt(scrollContainer, true) + container.Adopt(statusBar, false) + + window.OnClose(tomo.Stop) + window.Show() +}