Skip to content

Commit

Permalink
Merge pull request #1477 from cogentcore/content-nav
Browse files Browse the repository at this point in the history
Improve navigation options in Cogent Content
  • Loading branch information
rcoreilly authored Feb 24, 2025
2 parents 01c163b + 48132f9 commit 1cf828a
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 67 deletions.
141 changes: 141 additions & 0 deletions content/buttons.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2025, Cogent Core. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package content

import (
"slices"

"cogentcore.org/core/colors"
"cogentcore.org/core/core"
"cogentcore.org/core/events"
"cogentcore.org/core/icons"
"cogentcore.org/core/keymap"
"cogentcore.org/core/styles"
"cogentcore.org/core/system"
"cogentcore.org/core/tree"
)

func (ct *Content) MakeToolbar(p *tree.Plan) {
if false && ct.SizeClass() == core.SizeCompact { // TODO: implement hamburger menu for compact
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.Menu)
w.SetTooltip("Navigate pages and headings")
w.OnClick(func(e events.Event) {
d := core.NewBody("Navigate")
// tree.MoveToParent(ct.leftFrame, d)
d.AddBottomBar(func(bar *core.Frame) {
d.AddCancel(bar)
})
d.RunDialog(w)
})
})
}
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.Icon(core.AppIcon))
w.SetTooltip("Home")
w.OnClick(func(e events.Event) {
ct.Open("")
})
})
// Superseded by browser navigation on web.
if core.TheApp.Platform() != system.Web {
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.ArrowBack).SetKey(keymap.HistPrev)
w.SetTooltip("Back")
w.Updater(func() {
w.SetEnabled(ct.historyIndex > 0)
})
w.OnClick(func(e events.Event) {
ct.historyIndex--
ct.open(ct.history[ct.historyIndex].URL, false) // do not add to history while navigating history
})
})
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.ArrowForward).SetKey(keymap.HistNext)
w.SetTooltip("Forward")
w.Updater(func() {
w.SetEnabled(ct.historyIndex < len(ct.history)-1)
})
w.OnClick(func(e events.Event) {
ct.historyIndex++
ct.open(ct.history[ct.historyIndex].URL, false) // do not add to history while navigating history
})
})
}
tree.Add(p, func(w *core.Button) {
w.SetText("Search").SetIcon(icons.Search).SetKey(keymap.Menu)
w.Styler(func(s *styles.Style) {
s.Background = colors.Scheme.SurfaceVariant
s.Padding.Right.Em(5)
})
w.OnClick(func(e events.Event) {
ct.Scene.MenuSearchDialog("Search", "Search "+core.TheApp.Name())
})
})
}

func (ct *Content) MenuSearch(items *[]core.ChooserItem) {
newItems := make([]core.ChooserItem, len(ct.pages))
for i, pg := range ct.pages {
newItems[i] = core.ChooserItem{
Value: pg,
Text: pg.Name,
Icon: icons.Article,
Func: func() {
ct.Open(pg.URL)
},
}
}
*items = append(newItems, *items...)
}

// makeBottomButtons makes the previous and next buttons if relevant.
func (ct *Content) makeBottomButtons(p *tree.Plan) {
if len(ct.currentPage.Categories) == 0 {
return
}
cat := ct.currentPage.Categories[0]
pages := ct.pagesByCategory[cat]
idx := slices.Index(pages, ct.currentPage)

ct.prevPage, ct.nextPage = nil, nil

if idx > 0 {
ct.prevPage = pages[idx-1]
}
if idx < len(pages)-1 {
ct.nextPage = pages[idx+1]
}

if ct.prevPage == nil && ct.nextPage == nil {
return
}

tree.Add(p, func(w *core.Frame) {
w.Styler(func(s *styles.Style) {
s.Align.Items = styles.Center
s.Grow.Set(1, 0)
})
w.Maker(func(p *tree.Plan) {
if ct.prevPage != nil {
tree.Add(p, func(w *core.Button) {
w.SetText("Previous").SetIcon(icons.ArrowBack).SetType(core.ButtonTonal)
ct.Context.LinkButtonUpdating(w, func() string { // needed to prevent stale URL variable
return ct.prevPage.URL
})
})
}
if ct.nextPage != nil {
tree.Add(p, func(w *core.Stretch) {})
tree.Add(p, func(w *core.Button) {
w.SetText("Next").SetIcon(icons.ArrowForward).SetType(core.ButtonTonal)
ct.Context.LinkButtonUpdating(w, func() string { // needed to prevent stale URL variable
return ct.nextPage.URL
})
})
}
})
})
}
112 changes: 46 additions & 66 deletions content/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,24 @@ package content

import (
"bytes"
"cmp"
"fmt"
"io/fs"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"

"golang.org/x/exp/maps"

"cogentcore.org/core/base/errors"
"cogentcore.org/core/base/fsx"
"cogentcore.org/core/base/strcase"
"cogentcore.org/core/content/bcontent"
"cogentcore.org/core/core"
"cogentcore.org/core/events"
"cogentcore.org/core/htmlcore"
"cogentcore.org/core/icons"
"cogentcore.org/core/keymap"
"cogentcore.org/core/math32"
"cogentcore.org/core/styles"
"cogentcore.org/core/system"
Expand Down Expand Up @@ -58,6 +60,10 @@ type Content struct {
// pagesByCategory has the [bcontent.Page]s for each of all [bcontent.Page.Categories].
pagesByCategory map[string][]*bcontent.Page

// categories has all unique [bcontent.Page.Categories], sorted such that the categories
// with the most pages are listed first.
categories []string

// history is the history of pages that have been visited.
// The oldest page is first.
history []*bcontent.Page
Expand All @@ -72,16 +78,24 @@ type Content struct {
renderedPage *bcontent.Page

// leftFrame is the frame on the left side of the widget,
// used for displaying the table of contents.
// used for displaying the table of contents and the categories.
leftFrame *core.Frame

// rightFrame is the frame on the right side of the widget,
// used for displaying the page content.
rightFrame *core.Frame

// tocNodes are all of the tree nodes in the table of contents
// by kebab-case heading name.
tocNodes map[string]*core.Tree

// currentHeading is the currently selected heading in the table of contents,
// if any (in kebab-case).
currentHeading string

// The previous and next page, if applicable. They must be stored on this struct
// to avoid stale local closure variables.
prevPage, nextPage *bcontent.Page
}

func init() {
Expand Down Expand Up @@ -137,6 +151,7 @@ func (ct *Content) Init() {
ct.leftFrame = w
})
tree.Add(p, func(w *core.Frame) {
ct.rightFrame = w
w.Maker(func(p *tree.Plan) {
if ct.currentPage.Title != "" {
tree.Add(p, func(w *core.Text) {
Expand All @@ -155,6 +170,7 @@ func (ct *Content) Init() {
errors.Log(ct.loadPage(w))
})
})
ct.makeBottomButtons(p)
})
})
})
Expand Down Expand Up @@ -204,6 +220,11 @@ func (ct *Content) SetSource(source fs.FS) *Content {
}
return nil
}))
ct.categories = maps.Keys(ct.pagesByCategory)
slices.SortFunc(ct.categories, func(a, b string) int {
return cmp.Compare(len(ct.pagesByCategory[b]), len(ct.pagesByCategory[a]))
})

if url := ct.getWebURL(); url != "" {
ct.Open(url)
return ct
Expand Down Expand Up @@ -275,6 +296,7 @@ func (ct *Content) open(url string, history bool) {

func (ct *Content) openHeading(heading string) {
if heading == "" {
ct.rightFrame.ScrollDimToContentStart(math32.Y)
return
}
tr := ct.tocNodes[strcase.ToKebab(heading)]
Expand All @@ -300,7 +322,6 @@ func (ct *Content) loadPage(w *core.Frame) error {
return err
}

w.Parent.(*core.Frame).ScrollDimToContentStart(math32.Y) // the parent is the one that scrolls
ct.leftFrame.DeleteChildren()
ct.makeTableOfContents(w)
ct.makeCategories()
Expand All @@ -314,6 +335,13 @@ func (ct *Content) loadPage(w *core.Frame) error {
func (ct *Content) makeTableOfContents(w *core.Frame) {
ct.tocNodes = map[string]*core.Tree{}
contents := core.NewTree(ct.leftFrame).SetText("<b>Contents</b>")
contents.OnSelect(func(e events.Event) {
if contents.IsRootSelected() {
ct.rightFrame.ScrollDimToContentStart(math32.Y)
ct.currentHeading = ""
ct.saveWebURL()
}
})
// last is the most recent tree node for each heading level, used for nesting.
last := map[int]*core.Tree{}
w.WidgetWalkDown(func(cw core.Widget, cwb *core.WidgetBase) bool {
Expand Down Expand Up @@ -353,23 +381,32 @@ func (ct *Content) makeTableOfContents(w *core.Frame) {

// makeCategories makes the categories tree for the current page and adds it to [Content.leftFrame].
func (ct *Content) makeCategories() {
if len(ct.currentPage.Categories) == 0 {
if len(ct.categories) == 0 {
return
}

cats := core.NewTree(ct.leftFrame).SetText("<b>Categories</b>")
for _, cat := range ct.currentPage.Categories {
catTree := core.NewTree(cats).SetText(cat)
cats.OnSelect(func(e events.Event) {
if cats.IsRootSelected() {
ct.Open("")
}
})
for _, cat := range ct.categories {
catTree := core.NewTree(cats).SetText(cat).SetClosed(true)
if ct.currentPage.Name == cat {
catTree.SetSelected(true)
}
catTree.OnSelect(func(e events.Event) {
if catPage := ct.pageByName(cat); catPage != nil {
ct.Open(catPage.URL)
}
})
for _, pg := range ct.pagesByCategory[cat] {
pgTree := core.NewTree(catTree).SetText(pg.Name)
if pg == ct.currentPage {
continue
pgTree.SetSelected(true)
catTree.SetClosed(false)
}
pgTree := core.NewTree(catTree).SetText(pg.Name)
pgTree.OnSelect(func(e events.Event) {
ct.Open(pg.URL)
})
Expand Down Expand Up @@ -409,60 +446,3 @@ func (ct *Content) setStageTitle() {
rw.SetStageTitle(name)
}
}

func (ct *Content) MakeToolbar(p *tree.Plan) {
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.Icon(core.AppIcon))
w.SetTooltip("Home")
w.OnClick(func(e events.Event) {
ct.Open("")
})
})
// Superseded by browser navigation on web.
if core.TheApp.Platform() != system.Web {
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.ArrowBack).SetKey(keymap.HistPrev)
w.SetTooltip("Back")
w.Updater(func() {
w.SetEnabled(ct.historyIndex > 0)
})
w.OnClick(func(e events.Event) {
ct.historyIndex--
ct.open(ct.history[ct.historyIndex].URL, false) // do not add to history while navigating history
})
})
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.ArrowForward).SetKey(keymap.HistNext)
w.SetTooltip("Forward")
w.Updater(func() {
w.SetEnabled(ct.historyIndex < len(ct.history)-1)
})
w.OnClick(func(e events.Event) {
ct.historyIndex++
ct.open(ct.history[ct.historyIndex].URL, false) // do not add to history while navigating history
})
})
}
tree.Add(p, func(w *core.Button) {
w.SetIcon(icons.Search).SetKey(keymap.Menu)
w.SetTooltip("Search")
w.OnClick(func(e events.Event) {
ct.Scene.MenuSearchDialog("Search", "Search "+core.TheApp.Name())
})
})
}

func (ct *Content) MenuSearch(items *[]core.ChooserItem) {
newItems := make([]core.ChooserItem, len(ct.pages))
for i, pg := range ct.pages {
newItems[i] = core.ChooserItem{
Value: pg,
Text: pg.Name,
Icon: icons.Article,
Func: func() {
ct.Open(pg.URL)
},
}
}
*items = append(newItems, *items...)
}
14 changes: 13 additions & 1 deletion core/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ func AsTree(n tree.Node) *Tree {
//
// Standard [events.Event]s are sent to any listeners, including
// [events.Select], [events.Change], and [events.DoubleClick].
// The selected nodes are in the root [Tree.SelectedNodes] list.
// The selected nodes are in the root [Tree.SelectedNodes] list;
// select events are sent to both selected nodes and the root node.
// See [Tree.IsRootSelected] to check whether a select event on the root
// node corresponds to the root node or another node.
type Tree struct {
WidgetBase

Expand Down Expand Up @@ -670,6 +673,15 @@ func (tr *Tree) RenderWidget() {

//////// Selection

// IsRootSelected returns whether the root node is the only node selected.
// This can be used in [events.Select] event handlers to check whether a
// select event on the root node truly corresponds to the root node or whether
// it is for another node, as select events are sent to the root when any node
// is selected.
func (tr *Tree) IsRootSelected() bool {
return len(tr.SelectedNodes) == 1 && tr.SelectedNodes[0] == tr.Root
}

// GetSelectedNodes returns a slice of the currently selected
// Trees within the entire tree, using a list maintained
// by the root node.
Expand Down
Loading

0 comments on commit 1cf828a

Please sign in to comment.