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

Add IME support #4614

Draft
wants to merge 13 commits into
base: develop
Choose a base branch
from
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
github: [fyne-io, andydotxyz, toaster, Jacalz, changkun]
github: [fyne-io, andydotxyz, toaster, Jacalz, changkun, dweymouth, lucor]
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ More detailed release notes can be found on the [releases page](https://github.c
* Reduce calls to C and repeated size checks in painter and driver code


## 2.4.2 - 21 November 2023
## 2.4.2 - 22 November 2023

### Fixed

Expand Down
12 changes: 12 additions & 0 deletions canvasobject.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ type Focusable interface {
TypedKey(*KeyEvent)
}

type CursorPositionChangedCallback func(pos Position, size Size)

// Preeditable describes any CanvasObject that can respond to IME inputs.
// Originally, it should be a method of Focusable, but only a few widgets need PreeditChanged,
// and adding a method to Focusable will have a big impact on others, so defined as a new interface.
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 with this sentiment - adding it to Focusable would be a breaking change.
However can it be worded less like a workaround.
i.e. "Widgets that implement Focusable may also want to implement PreeditChanged to handle IME input?

Copy link
Author

Choose a reason for hiding this comment

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

That comment certainly makes the relationship with Focusable clearer. People who see this code for the first time may not understand the deep relationship between Focusable and Preeditable.

type Preeditable interface {
// PreeditChanged is a hook called by window with IME is on and typed multi-byte characters inputed
PreeditChanged(preedit string)

ReceiveCursorPositionChangedCallback(callback CursorPositionChangedCallback)
}

// Scrollable describes any CanvasObject that can also be scrolled.
// This is mostly used to implement the widget.ScrollContainer.
type Scrollable interface {
Expand Down
12 changes: 12 additions & 0 deletions internal/driver/common/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@ func (c *Canvas) Focused() fyne.Focusable {
return mgr.Focused()
}

// Preeditable return the current preeditable object only when focused
Copy link
Member

Choose a reason for hiding this comment

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

This will crash if the input is not Preeditable. It should not be needed as it will always be the same object returned by Focused()

Copy link
Author

@kanryu kanryu Feb 6, 2024

Choose a reason for hiding this comment

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

This will crash if the input is not Preeditable.

I didn't notice it when running fyne_demo. Thank you for pointing that out. I had a chance to learn the correct casting method.

Copy link
Member

Choose a reason for hiding this comment

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

It would only happen if you started to interact with an input that had not implemented that interface.
Something like the checkbox I would think for example.

func (c *Canvas) Preeditable() fyne.Preeditable {
focused := c.Focused()
if focused == nil {
return nil
}
if preeditable, ok := focused.(fyne.Preeditable); ok {
return preeditable
}
return nil
}

// FocusGained signals to the manager that its content got focus.
// Valid only on Desktop.
func (c *Canvas) FocusGained() {
Expand Down
3 changes: 3 additions & 0 deletions internal/driver/glfw/loop_desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import (
"github.com/go-gl/glfw/v3.3/glfw"
)

const GLFW_X11_ONTHESPOT glfw.Hint = 0x00052002
Copy link
Member

Choose a reason for hiding this comment

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

Won't this be declared in the GLFW project?
I guess it relates to my other comment about not being sure of how to compile or which GLFW version is needed

Copy link
Author

Choose a reason for hiding this comment

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

@andydotxyz Thanks for trying. As I wrote at the beginning of this PR, in order to check the operation of this PR, you need to clone both the modified fyne and glfw and change go.mod.

Copy link
Member

@Jacalz Jacalz Apr 8, 2024

Choose a reason for hiding this comment

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

@kanryu If this is under active development but not completed, please mark the PR as draft.

Copy link
Author

Choose a reason for hiding this comment

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

@Jacalz It's difficult to explain in a few words because the work spanned multiple projects, but achieving this PR required major modifications to go-gl/glfw, and they effectively refused to merge. Because of this, we are currently unable to proceed with this PR any further.
Other than that, I think this PR is of a quality worthy of being tested by many people.

It's up to the people at the Fyne project to decide whether or not this PR should be marked as draft. Not that I would complain.

Copy link
Member

@Jacalz Jacalz Apr 8, 2024

Choose a reason for hiding this comment

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

@kanryu I am entirely aware of the situation. My personal opinion is that if a PR can't be tested as it is, i.e. isn't ready to be merged, it should be a draft. Those that are interested in testing it can do so but we maintainers don't have to remember that it isn't ready. It makes it easier to grasp which pull requests are waiting for reviews.

Copy link
Member

Choose a reason for hiding this comment

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

@andydotxyz Thanks for trying. As I wrote at the beginning of this PR, in order to check the operation of this PR, you need to clone both the modified fyne and glfw and change go.mod.

This can be done with a replace directive in the go.mod so that this PR can simply be checked out and tested without pre-requisites. That also makes it clear of the requirements to compile.


func (d *gLDriver) initGLFW() {
initOnce.Do(func() {
glfw.InitHint(GLFW_X11_ONTHESPOT, 1) // enable IME callbacks, hint must be set before Init()
err := glfw.Init()
if err != nil {
fyne.LogError("failed to initialise GLFW", err)
Expand Down
8 changes: 8 additions & 0 deletions internal/driver/glfw/window.go
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,14 @@ func (w *window) processCharInput(char rune) {
}
}

// preedit defines the character with modifiers callback
// witch is called when character input on IME
func (w *window) processPreedit(preedit string) {
if preeditable := w.canvas.Preeditable(); preeditable != nil {
w.QueueEvent(func() { preeditable.PreeditChanged(preedit) })
}
}

func (w *window) processFocused(focus bool) {
if focus {
if curWindow == nil {
Expand Down
46 changes: 46 additions & 0 deletions internal/driver/glfw/window_desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package glfw
import (
"bytes"
"context"
"fmt"
"image"
_ "image/png" // for the icon
"runtime"
Expand Down Expand Up @@ -677,6 +678,47 @@ func (w *window) charInput(viewport *glfw.Window, char rune) {
w.processCharInput(char)
}

var counterIme int

// IME
Copy link
Member

Choose a reason for hiding this comment

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

A comment should not be needed here. Where comments are added it usually implies naming is insufficient.

func (w *window) imeStatus(_ *glfw.Window) {
counterIme++
if preeditable := w.canvas.Preeditable(); preeditable != nil {
// This callback function is called by the focused widget and tells the system the exact canvas coordinates
// of the text cursor on the window. The system will display an IME Candidate Window with those coordinates
preeditable.ReceiveCursorPositionChangedCallback(func(pos fyne.Position, size fyne.Size) {
canvasScale := w.canvas.scale
w.viewport.SetPreeditCursorRectangle(int(pos.X*canvasScale), int(pos.Y*canvasScale), 100, int(size.Height))
w.viewport.UpdatePreeditCursorRectangle()
})
}
}

func (w *window) preedit(
_ *glfw.Window,
preeditCount int,
preeditString string,
blockCount int,
blockSizes string,
focusedBlock int,
caret int,
) {
w.processPreedit(preeditString)
Copy link
Member

Choose a reason for hiding this comment

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

It looks like it would be easier to just inline processPreedit here as it is barely a single line and only called from this location

Copy link
Author

Choose a reason for hiding this comment

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

@andydotxyz Unfortunately processPreedit and Preeditable.PreeditChanged are temporary interfaces. This is because the original Preedit response requires a more complex response. It is necessary to additionally display the conversion candidates for input content provided by the IME and the IME's own caret.

Copy link
Member

Choose a reason for hiding this comment

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

OK. Should we mark this as a draft PR then instead of one ready for review?

}

func (w *window) preeditCandidate(
glwin *glfw.Window,
candidatesCount int,
selectedIndex int,
pageStart int,
pageSize int,
) {
candidate := glwin.GetPreeditCandidate(selectedIndex)
if candidate != nil {
fmt.Println("candidates", *candidate)
}
}

func (w *window) focused(_ *glfw.Window, focused bool) {
w.processFocused(focused)
}
Expand Down Expand Up @@ -765,11 +807,15 @@ func (w *window) create() {
win.SetRefreshCallback(w.refresh)
win.SetContentScaleCallback(w.scaled)
win.SetCursorPosCallback(w.mouseMoved)
win.SetPreeditCallback(w.preedit)
Copy link
Member

Choose a reason for hiding this comment

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

This won't compile for me. I guess the go.mod is missing some alterations?

win.SetImeStatusCallback(w.imeStatus)
win.SetPreeditCandidateCallback(w.preeditCandidate)
win.SetMouseButtonCallback(w.mouseClicked)
win.SetScrollCallback(w.mouseScrolled)
win.SetKeyCallback(w.keyPressed)
win.SetCharCallback(w.charInput)
win.SetFocusCallback(w.focused)
win.SetInputMode(glfw.ImeOwnerDraw, glfw.False)

w.canvas.detectedScale = w.detectScale()
w.canvas.scale = w.calculatedScale()
Expand Down
Loading