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/file/directory.go b/elements/file/directory.go index 9dbe8a9..cdf559a 100644 --- a/elements/file/directory.go +++ b/elements/file/directory.go @@ -1,22 +1,44 @@ package fileElements import "io/fs" +import "image" import "path/filepath" -import "git.tebibyte.media/sashakoshka/tomo/elements/basic" +import "git.tebibyte.media/sashakoshka/tomo/theme" +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/elements" +import "git.tebibyte.media/sashakoshka/tomo/elements/core" -type ReadDirStatFS interface { - fs.ReadDirFS - fs.StatFS +type fileLayoutEntry struct { + *File + fs.DirEntry + Bounds image.Rectangle } +// DirectoryView displays a list of files within a particular directory and +// file system. type DirectoryView struct { - *basicElements.List + *core.Core + *core.Propagator + core core.CoreControl + + children []fileLayoutEntry + scroll image.Point + contentBounds image.Rectangle + + config config.Wrapped + theme theme.Wrapped + + onScrollBoundsChange func () filesystem ReadDirStatFS location string onChoose func (file string) } +// NewDirectoryView creates a new directory view. If within is nil, it will use +// the OS file system. func NewDirectoryView ( location string, within ReadDirStatFS, @@ -24,17 +46,21 @@ func NewDirectoryView ( element *DirectoryView, err error, ) { - element = &DirectoryView { - List: basicElements.NewList(), - } + element = &DirectoryView { } + element.theme.Case = theme.C("files", "directoryView") + 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 *DirectoryView) Location () (string, fs.ReadDirFS) { return element.location, element.filesystem } +// SetLocation sets the directory's location and filesystem. If within is nil, +// it will use the OS file system. func (element *DirectoryView) SetLocation ( location string, within ReadDirStatFS, @@ -42,32 +68,231 @@ func (element *DirectoryView) SetLocation ( if within == nil { within = defaultFS { } } + element.scroll = image.Point { } element.location = location element.filesystem = within return element.Update() } +// Update refreshes the directory's contents. func (element *DirectoryView) Update () error { + defer element.redoAll() entries, err := element.filesystem.ReadDir(element.location) - listEntries := make([]basicElements.ListEntry, len(entries)) + // 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(element.location, entry.Name()) - listEntries[index] = basicElements.NewListEntry ( - entry.Name(), - func () { - filePath := filePath - if element.onChoose != nil { - element.onChoose(filePath) - } - }) + file, err := NewFile ( + filePath, + element.filesystem) + if err != nil { return err } + file.SetParent(element) + element.children[index].File = file + element.children[index].DirEntry = entry + element.OnChoose (func (filepath string) { + if element.onChoose != nil { + element.onChoose(filePath) + } + }) } - element.Clear() - element.Append(listEntries...) 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 *DirectoryView) OnChoose (callback func (file string)) { element.onChoose = callback } + +// CountChildren returns the amount of children contained within this element. +func (element *DirectoryView) 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 *DirectoryView) Child (index int) (child elements.Element) { + if index < 0 || index > len(element.children) { return } + return element.children[index].File +} + +func (element *DirectoryView) 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() + } +} + +func (element *DirectoryView) 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 *DirectoryView) NotifyMinimumSizeChange (child elements.Element) { + element.redoAll() + element.core.DamageAll() +} + +// SetTheme sets the element's theme. +func (element *DirectoryView) 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 *DirectoryView) 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 *DirectoryView) ScrollContentBounds () image.Rectangle { + return element.contentBounds +} + +// ScrollViewportBounds returns the size and position of the element's +// viewport relative to ScrollBounds. +func (element *DirectoryView) 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 *DirectoryView) 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 *DirectoryView) OnScrollBoundsChange (callback func ()) { + element.onScrollBoundsChange = callback +} + +// ScrollAxes returns the supported axes for scrolling. +func (element *DirectoryView) ScrollAxes () (horizontal, vertical bool) { + return false, true +} + +func (element *DirectoryView) 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 *DirectoryView) 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) + 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 += height + if index > 1 { + dot.Y += margin.Y + } + beginningOfRow = true + } + + if beginningOfRow { + beginningOfRow = false + } else { + dot.X += margin.X + } + + entry.Bounds.Min = dot + entry.Bounds.Max = image.Pt(dot.X + width, dot.Y + height) + element.children[index] = entry + element.contentBounds = element.contentBounds.Union(entry.Bounds) + dot.X += width + } + + element.contentBounds = + element.contentBounds.Sub(element.contentBounds.Min) +} + +func (element *DirectoryView) 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 index 03a4e80..9db6731 100644 --- a/elements/file/file.go +++ b/elements/file/file.go @@ -1,24 +1,31 @@ package fileElements import "io/fs" +import "image" import "git.tebibyte.media/sashakoshka/tomo/theme" -import "git.tebibyte.media/sashakoshka/tomo/elements/basic" +import "git.tebibyte.media/sashakoshka/tomo/artist" +import "git.tebibyte.media/sashakoshka/tomo/config" +import "git.tebibyte.media/sashakoshka/tomo/elements/core" -// File is a +// File displays an interactive visual representation of a file within any +// file system. type File struct { - *basicElements.Icon - - // we inherit from Icon directly because it is not our responsibility - // to draw text. this will be the responsibility of the directory that - // contains the file. we don't handle mouse events on the file label - // text either because when the user clicks on that we want to rename - // the file. - + *core.Core + *core.FocusableCore + core core.CoreControl + focusableControl core.FocusableCoreControl + + config config.Wrapped + theme theme.Wrapped + + iconID theme.Icon filesystem fs.StatFS - location string - onChoose func () + location string + 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, @@ -26,17 +33,22 @@ func NewFile ( element *File, err error, ) { - element = &File { - Icon: basicElements.NewIcon(theme.IconFile, theme.IconSizeLarge), - } + 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, @@ -49,19 +61,79 @@ func (element *File) SetLocation ( 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.SetIcon(theme.IconDirectory, theme.IconSizeLarge) + element.iconID = theme.IconDirectory } else { - element.SetIcon(theme.IconFile, theme.IconSizeLarge) + element.iconID = theme.IconFile } - + + element.updateMinimumSize() + element.drawAndPush() return err } func (element *File) OnChoose (callback func ()) { element.onChoose = callback } + +func (element *File) state () theme.State { + return theme.State { + Disabled: !element.Enabled(), + Focused: element.Focused(), + // Pressed: element.pressed, + } +} + +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() + 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) + 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 index 812333b..85572e3 100644 --- a/elements/file/fs.go +++ b/elements/file/fs.go @@ -3,16 +3,23 @@ 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) ([]DirEntry, error) { +func (defaultFS) ReadDir (name string) ([]fs.DirEntry, error) { return os.ReadDir(name) } -func (defaultFS) Stat (name string) (FileInfo, error) { +func (defaultFS) Stat (name string) (fs.FileInfo, error) { return os.Stat(name) } diff --git a/examples/fileBrowser/main.go b/examples/fileBrowser/main.go index 34da5b0..6eea8e4 100644 --- a/examples/fileBrowser/main.go +++ b/examples/fileBrowser/main.go @@ -1,6 +1,7 @@ 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" @@ -18,6 +19,7 @@ func run () { 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") @@ -34,13 +36,17 @@ func run () { upwardButton.ShowText(false) locationInput := basicElements.NewTextBox("Location", "") - scrollContainer := containers.NewScrollContainer(false, true) - homeDir,_ := os.UserHomeDir() - directoryView, _ := fileElements.NewDirectoryView(homeDir) - directoryView.Collapse(0, 8) + 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.NewDirectoryView(homeDir, nil) choose := func (filePath string) { - directoryView.SetLocation(filePath) - locationInput.SetValue(directoryView.Location()) + directoryView.SetLocation(filePath, nil) + directory.SetLocation(filePath, nil) + locationInput.SetValue(filePath) + baseName.SetText(filepath.Base(filePath)) } directoryView.OnChoose(choose) locationInput.OnEnter (func () { @@ -48,14 +54,18 @@ func run () { }) choose(homeDir) + controlBar.Adopt(backButton, false) + controlBar.Adopt(forwardButton, false) + controlBar.Adopt(refreshButton, false) + controlBar.Adopt(upwardButton, false) + controlBar.Adopt(locationInput, true) scrollContainer.Adopt(directoryView) - controlBar.Adopt(backButton, false) - controlBar.Adopt(forwardButton, false) - controlBar.Adopt(refreshButton, false) - controlBar.Adopt(upwardButton, false) - controlBar.Adopt(locationInput, true) + 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()