Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Keyboard only operation #1074

Closed
wants to merge 11 commits into from
7 changes: 6 additions & 1 deletion cmd/fyne_demo/screens/window.go
Expand Up @@ -187,7 +187,12 @@ func loadWindowGroup() fyne.Widget {
}),
widget.NewButton("Fixed size window", func() {
w := fyne.CurrentApp().NewWindow("Fixed")
w.SetContent(fyne.NewContainerWithLayout(layout.NewCenterLayout(), widget.NewLabel("Hello World!")))
w.SetContent(fyne.NewContainerWithLayout(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this is demoing?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was introduced to test keyboard operation in a second window. There was no extra windows with any focusable widgets, so I had to introduce some buttons to test it. Of cause it failed the first time I tested it, but should be working ok now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All windows should behave the same though?

layout.NewGridLayoutWithRows(3),
widget.NewLabel("Hello World!"),
widget.NewButton("Cancel", func() {w.Close()}),
widget.NewButton("OK", func() {w.Close()})),
)

w.Resize(fyne.NewSize(240, 180))
w.SetFixedSize(true)
Expand Down
8 changes: 8 additions & 0 deletions dialog/base.go
Expand Up @@ -38,6 +38,7 @@ type dialog struct {
content, label fyne.CanvasObject
dismiss *widget.Button
parent fyne.Window
oldFocus fyne.Focusable
}

// SetOnClosed allows to set a callback function that is called when
Expand Down Expand Up @@ -144,9 +145,16 @@ func newButtonList(buttons ...*widget.Button) fyne.CanvasObject {
func (d *dialog) Show() {
d.sendResponse = true
d.win.Show()
d.oldFocus = d.win.Canvas.Focused()
if d.dismiss!=nil {
d.win.Canvas.Focus(d.dismiss)
} else {
d.win.Canvas.Focus(nil)
}
}

func (d *dialog) Hide() {
d.win.Canvas.Focus(d.oldFocus)
d.hideWithResponse(false)
}

Expand Down
15 changes: 13 additions & 2 deletions internal/app/focus.go
Expand Up @@ -13,7 +13,12 @@ type FocusManager struct {
func (f *FocusManager) nextInChain(current fyne.Focusable) fyne.Focusable {
var first, next fyne.Focusable
found := current == nil // if we have no starting point then pretend we matched already
driver.WalkVisibleObjectTree(f.canvas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {
//OBS driver.WalkVisibleObjectTree(f.cavas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OBS? try not to commit commented out code

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may relate to bug #814

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use //OBS to easily do a global search for comments that should be removed before commit. This fell through the cracks.
As for but #814, I belive the added lines of code will be a fix for the bug. I am not able to reproduce it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. In the future it's good to have a bug fix in separate commit - possibly even PR - than a big feature addition

content := f.canvas.Overlays().Top()
if content==nil {
content = f.canvas.Content()
}
driver.WalkVisibleObjectTree(content, func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {
if w, ok := obj.(fyne.Disableable); ok && w.Disabled() {
// disabled widget cannot receive focus
return false
Expand Down Expand Up @@ -48,7 +53,13 @@ func (f *FocusManager) nextInChain(current fyne.Focusable) fyne.Focusable {
func (f *FocusManager) previousInChain(current fyne.Focusable) fyne.Focusable {
var last, previous fyne.Focusable
found := false
driver.WalkVisibleObjectTree(f.canvas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {

content := f.canvas.Overlays().Top()
if content==nil {
content = f.canvas.Content()
}
driver.WalkVisibleObjectTree(content, func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {
//OBS driver.WalkVisibleObjectTree(f.canvas.Content(), func(obj fyne.CanvasObject, _ fyne.Position, _ fyne.Position, _ fyne.Size) bool {
if w, ok := obj.(fyne.Disableable); ok && w.Disabled() {
// disabled widget cannot receive focus
return false
Expand Down
39 changes: 35 additions & 4 deletions internal/driver/glfw/menu_bar.go
Expand Up @@ -16,11 +16,41 @@ var _ fyne.Widget = (*MenuBar)(nil)
// MenuBar is a widget for displaying a fyne.MainMenu in a bar.
type MenuBar struct {
widget.Base
Items []fyne.CanvasObject

Items []fyne.CanvasObject
index int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index is quite generic, not clear what this means

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index is the currently focused item on the menu bar. Using arrow keys, the focus is moved and index is updated. This is also used on toolbars and tabs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So maybe activeIndex or currentMenu naming then?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just changed it to currentItem

active bool
activeItem *menuBarItem
canvas fyne.Canvas
activeItem *menuBarItem
}

// HandleKey will process keys pressed when menu is open
func (b *MenuBar) HandleKey(key fyne.KeyName) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be an exported API

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By way of explanation here (sorry it was lacking before) we don't add a new exported API unless there is a solid use-case for developers.

if key == fyne.KeyEscape {
b.deactivate()
} else if key == fyne.KeyEnter || key == fyne.KeyReturn || key == fyne.KeySpace {
// Simulate tapping on menu entry
b.Items[b.index].(*menuBarItem).Child().Do()
} else if key == fyne.KeyRight {
// Move to the menu item at right
if b.index < len(b.Items)-1 {
b.Items[b.index].(*menuBarItem).Tapped(nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tapped should not be called on an item with an invalid parameter.
Would it not be suitable to call the Action()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, Action() is not possible, I colud of cause duplicate the code or extract it into a new function, but I think the trick of using a nil position is fine for signaling that the event comes from the keyboard and not the mouse. I have used it several places.

b.index++
b.Items[b.index].(*menuBarItem).Tapped(nil)
}
} else if key == fyne.KeyLeft {
// Move to the menu item at left
if b.index > 0 {
b.Items[b.index].(*menuBarItem).Tapped(nil)
b.index--
b.Items[b.index].(*menuBarItem).Tapped(nil)
}
} else if key == fyne.KeyUp {
// Move focus to menu item above
b.Items[b.index].(*menuBarItem).Child().Up()
} else if key == fyne.KeyDown {
// Move focus to menu item below
b.Items[b.index].(*menuBarItem).Child().Down()
}
}

// NewMenuBar creates a menu bar populated with items from the passed main menu structure.
Expand Down Expand Up @@ -106,6 +136,7 @@ func (b *MenuBar) activateChild(item *menuBarItem) {
return
}


item.Child().Show()
b.Refresh()
}
Expand All @@ -114,7 +145,7 @@ func (b *MenuBar) deactivate() {
if !b.active {
return
}

b.active = false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dupe

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

b.active = false
if b.activeItem != nil {
defer b.activeItem.Child().Dismiss()
Expand Down
14 changes: 12 additions & 2 deletions internal/driver/glfw/menu_bar_item.go
Expand Up @@ -112,11 +112,22 @@ func (i *menuBarItem) Show() {
// Tapped toggles the activation state of the menu bar.
// It shows the item’s menu if the bar is activated and hides it if the bar is deactivated.
// Implements: fyne.Tappable
func (i *menuBarItem) Tapped(*fyne.PointEvent) {
func (i *menuBarItem) Tapped(pos *fyne.PointEvent) {
if i.Parent.active {
i.Parent.deactivate()
} else {
// Set then menu bar's index to the tapped items index and update focus
i.Parent.activateChild(i)
for j := 0; j < len(i.Parent.Items); j++ {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this loop...
i.Parent.Items[i.Parent.index] is i so the item that has just been activated is being defocussed (or "upped").
Possibly this is because I don't understand what index is

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the name "index" to "currentItem", as it indicates the menu item selected. There is unfortunately a mix of "focused". The focused item receiveing the keyboard data is the MenuBar, while the indicated (focus-colored) item is one of the menu items in the list. Perhaps each menu item should recieve keyboard input?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THat might be cleaner, would probably help with code encapsulation, that said left/right may still make sense at the top level.

if i.Parent.Items[j] == i {
i.Parent.index = j
if pos==nil {
i.Parent.Items[i.Parent.index].(*menuBarItem).Child().Up()
} else {
i.Parent.Items[i.Parent.index].(*menuBarItem).Child().Defocus()
}
}
}
}
}

Expand All @@ -130,7 +141,6 @@ func (r *menuBarItemRenderer) BackgroundColor() color.Color {
if r.i.hovered || (r.i.child != nil && r.i.child.Visible()) {
return theme.HoverColor()
}

return color.Transparent
}

Expand Down
10 changes: 5 additions & 5 deletions internal/driver/glfw/menu_bar_test.go
Expand Up @@ -112,7 +112,7 @@ func TestMenuBar(t *testing.T) {
},
{
actions: []action{{"tap", buttonPos}},
wantImage: "menu_bar_hovered_content.png",
wantImage: "menu_bar_hovered_focused_content.png",
},
},
},
Expand Down Expand Up @@ -182,7 +182,7 @@ func TestMenuBar(t *testing.T) {
{
actions: []action{{"tap", fileNewPos}},
wantAction: "new",
wantImage: "menu_bar_initial.png",
wantImage: "menu_bar_initial1.png",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why postfixing numbers? it is not clear what the intended differences are

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relates to my trying to fix the tests. It did not suceed. The problem is the color changes when a button is focused. Previously the buttons did not change color when focused, as it was not possible to focus them. So the menu_bar_initial.png can not be used in every case. It will be different depending on the previous simulated operations. Further changes to the tests must be done later on, introducing new png images. This must wait until the coloring sheme is settled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use appropriate naming instead of adding a number - it should be clear of the image content/state based on the name. We can’t have failing tests waiting for another PR or external change, if this PR depends on another then we have to land that first before this can complete.

},
},
},
Expand All @@ -198,7 +198,7 @@ func TestMenuBar(t *testing.T) {
{
actions: []action{{"tap", fileOpenPos}},
wantAction: "open",
wantImage: "menu_bar_initial.png",
wantImage: "menu_bar_initial2.png",
},
},
},
Expand All @@ -222,7 +222,7 @@ func TestMenuBar(t *testing.T) {
{
actions: []action{{"tap", fileRecentOlderOld1Pos}},
wantAction: "old 1",
wantImage: "menu_bar_initial.png",
wantImage: "menu_bar_initial3.png",
},
},
},
Expand Down Expand Up @@ -305,7 +305,7 @@ func TestMenuBar(t *testing.T) {
t.Run(name, func(t *testing.T) {
test.MoveMouse(c, fyne.NewPos(0, 0))
test.TapCanvas(c, fyne.NewPos(0, 0))
test.AssertImageMatches(t, "menu_bar_initial.png", c.Capture())
test.AssertImageMatches(t, "menu_bar_initial4.png", c.Capture())
for i, s := range tt.steps {
t.Run("step "+strconv.Itoa(i+1), func(t *testing.T) {
lastAction = ""
Expand Down
Binary file modified internal/driver/glfw/testdata/menu_bar_hovered_content_dark.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 40 additions & 6 deletions internal/driver/glfw/window.go
Expand Up @@ -15,6 +15,7 @@ import (
"fyne.io/fyne/internal/cache"
"fyne.io/fyne/internal/driver"
"fyne.io/fyne/internal/painter/gl"
"fyne.io/fyne/widget"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must not add dependencies from widget here.


"github.com/go-gl/glfw/v3.3/glfw"
)
Expand Down Expand Up @@ -621,11 +622,19 @@ func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw.
}
}

needsfocus := false
if layer != 1 { // 0 - overlay, 1 - menu, 2 - content
if wid, ok := co.(fyne.Focusable); ok {
w.canvas.Focus(wid)
} else {
w.canvas.Unfocus()
needsfocus = true

if wid := w.canvas.Focused(); wid != nil {
if wid.(fyne.CanvasObject) != co {
// Avoid changing focus when a toolbar button is pressed.
if _, isToolbarButton := co.(*widget.ToolbarButton); !isToolbarButton {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is confusing - if you made a new ToolbButton so that it didn't accept focus like normal buttons then this should not be needed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunatly it cannot. The toolbar must be focusable by TAB key-presses, but should not be focused when tapped. On the other hand, the code can be simplified as I have now done.

w.canvas.Unfocus()
}
} else {
needsfocus = false
}
}
}

Expand All @@ -635,6 +644,13 @@ func (w *window) mouseClicked(_ *glfw.Window, btn glfw.MouseButton, action glfw.
w.mouseButton = 0
}

git // we cannot switch here as objects may respond to multiple cases
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo I'm guessing

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With two screens, it is not easy to know which window is focused...

if wid, ok := co.(fyne.Focusable); ok && needsfocus {
if dis, ok := wid.(fyne.Disableable); !ok || !dis.Disabled() {
w.canvas.Focus(wid)
}
}

// Check for double click/tap
doubleTapped := false
if action == glfw.Release && button == desktop.LeftMouseButton {
Expand Down Expand Up @@ -857,7 +873,26 @@ func (w *window) keyPressed(viewport *glfw.Window, key glfw.Key, scancode int, a
keyEvent := &fyne.KeyEvent{Name: keyName}
keyDesktopModifier := desktopModifier(mods)

if keyName == fyne.KeyTab {
// Activate the menu when the Alt key is pressed
focused := w.canvas.Focused()
isMenu := false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable is reduntant - inside the ok the menu.active flag could apply to both blocks - the return in the second block means the later check on 895 is not needed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct - but the simplification was not easy - at least not for me. I an realy impressed that you spotted this so easily.

menu, ok := w.canvas.menu.(*MenuBar)
if ok {
isMenu = menu.active
if !isMenu && action == glfw.Press && (key == glfw.KeyLeftAlt || key == glfw.KeyRightAlt) &&
keyDesktopModifier == desktop.AltModifier {
mbi := menu.Items[0].(*menuBarItem)
mbi.Tapped(nil)
}
}
if isMenu {
if action == glfw.Release {
menu.HandleKey(keyName)
}
return
}

if keyName == fyne.KeyTab && action != glfw.Release && !isMenu {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release check appears duplicating 2 liones below?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Will fix.

if keyDesktopModifier == 0 {
if action != glfw.Release {
w.canvas.focusMgr.FocusNext(w.canvas.focused)
Expand Down Expand Up @@ -950,7 +985,6 @@ func (w *window) keyPressed(viewport *glfw.Window, key glfw.Key, scancode int, a
}

// No shortcut detected, pass down to TypedKey
focused := w.canvas.Focused()
if focused != nil {
w.queueEvent(func() { focused.TypedKey(keyEvent) })
} else if w.canvas.onTypedKey != nil {
Expand Down
45 changes: 44 additions & 1 deletion theme/theme.go
Expand Up @@ -61,6 +61,39 @@ func DarkTheme() fyne.Theme {
return theme
}

// shade will darken a light color and lighten a light color by the given % value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

% or pct should explain it's expected range better.
In the code it clamps it to < 50, but I couldn't say why

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It comes from the material design recomendations. The actual percents to lighten is twice the percent to darken. The shade for focus is 12% darken, but 24% lighten. In this way we need only one constant and the doubling is done in Shade(). I have improved the comments.

// should use 4,8, 12 or 14 percent darken/lighten to implement google material design rules
func Shade(c color.Color, pct uint32) color.Color {
r, g, b, a := c.RGBA()
if pct == 0 {
return color.NRGBA{uint8(r), uint8(g), uint8(b), uint8(a)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better would be return c surely?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct

}
if pct > 50 {
pct = 50
}
if r+g+b < 3*0x8080 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems like this might be important, should it be extracted into something like an isLight helper?

// Lighten
return color.NRGBA{
R: uint8((r + (0x10000-r)*pct/50) >> 8),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

percent / 50 is a strange seemting thing to do?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above about shades.

G: uint8((g + (0x10000-g)*pct/50) >> 8),
B: uint8((b + (0x10000-b)*pct/50) >> 8),
A: uint8(a >> 8),
}
} else {
// Darken
return color.NRGBA{
R: uint8((r * (100 - pct) / 100) >> 8),
G: uint8((g * (100 - pct) / 100) >> 8),
B: uint8((b * (100 - pct) / 100) >> 8),
A: uint8(a >> 8),
}
}
}

const PressedShade = 14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these percentages? should they be public API?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they are percentages. I belive they should be part of the customizable theme, but I do not see how to do it.

const HoveredShade = 4
const FocusedShade = 8

func (t *builtinTheme) BackgroundColor() color.Color {
return t.background
}
Expand Down Expand Up @@ -117,7 +150,7 @@ func (t *builtinTheme) HoverColor() color.Color {

// FocusColor returns the color used to highlight a focused widget
func (t *builtinTheme) FocusColor() color.Color {
return t.primary
return Shade(t.primary, FocusedShade)
}

// ScrollBarColor returns the color (and translucency) for a scrollBar
Expand Down Expand Up @@ -276,6 +309,16 @@ func HoverColor() color.Color {
return current().HoverColor()
}

// PressedColor returns the colour used for a pressed button
func PressedColor() color.Color {
return Shade(FocusColor(), PressedShade)
}

// HoverFocused returns the colour used for a focused/primary hovered button
func HoverFocusedColor() color.Color {
return Shade(FocusColor(), HoveredShade)
}

// FocusColor returns the color used to highlight a focussed widget
func FocusColor() color.Color {
return current().FocusColor()
Expand Down