diff --git a/AGENTS.md b/AGENTS.md index c072e0f..8b0c36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,23 @@ type EventSource interface { OnMouseMove(func(x, y float64)) // ... 12 methods total } + +// IME (ADR-045): positions are UTF-8 byte offsets (IMETextRange). +type IMEController interface { // implemented by the host window system + SetIMEEnabled(bool) + SetIMECursorArea(IMECursorArea) // logical DIP + SetIMEContentType(ContentPurpose, ContentHint) + SetIMESurroundingText(IMESurroundingText) + CancelIME() + IMECapabilities() IMECapabilities // which optional operations the platform performs +} +// Optional events: if ime, ok := source.(IMEEventSource); ok { ime.OnIMECanceled(...) } ``` +IME composition arrives through EventSource's OnIMECompositionStart/Update/End; `IMEState.Cursor` is +`{-1, -1}` when the IME hides the caret (`HasCursor`). Every IMEController method and `RequestRedraw` are safe from +any goroutine. + ### Opaque Handles (8 bytes, zero alloc, GC-safe) ```go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d04258..3e00a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.32.0] - 2026-11-08 + +### Changed (breaking) + +- **IME contract (ADR-045)**, based on gpucontext#30 by @besmpl. One extended `IMEController` instead of versioned + interfaces; all text positions are UTF-8 byte offsets. + - `IMEController`: `SetIMEPosition(x, y int)` is replaced by `SetIMECursorArea(IMECursorArea)` (logical DIP). + New methods: `SetIMEContentType`, `SetIMESurroundingText`, `CancelIME`, `IMECapabilities`. Every method must + be safe from any goroutine. + - `IMEState`: `CursorPos`, `SelectionStart` and `SelectionEnd` are replaced by `Cursor` and `Selection` + (`IMETextRange`). A `{-1, -1}` cursor means the IME hides it (`HasCursor`). + - Migration: hosts implement the new methods. Methods for unsupported operations are no-ops, and + `IMECapabilities` reports what the platform does. Consumers read `state.Cursor.Start` where they read + `state.CursorPos`. + +### Added + +- `IMETextRange`, `IMESurroundingText`, `IMECursorArea`, `IMEDeleteSurroundingEvent`, with UTF-8 validation that + never clamps. +- `ContentPurpose` (incl. `Chat`) and the `ContentHint` bitset, with names from tables keyed by the constants. +- `IMECapabilities` in three groups, as in winit: cursor area, content type, surrounding text. +- Optional `IMEEventSource` (`OnIMECanceled`, `OnIMEDisabled`, `OnIMEDeleteSurrounding`), found by type assertion. + +### Documentation + +- `WindowProvider.RequestRedraw` must be safe to call from any goroutine, and requests coalesce. +- Package doc, AGENTS.md and ROADMAP list the IME interfaces. + +### Changed + +- deps: gputypes v0.8.0 → v0.8.2 + ## [0.31.3] - 2026-08-31 ### Changed diff --git a/README.md b/README.md index 6c982ab..f621dc2 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,9 @@ func (app *App) OnKeyPress(fn func(gpucontext.Key, gpucontext.Modifiers)) { ### IME Support (CJK Input) -`IMEState` and related interfaces enable Input Method Editor support for Chinese, Japanese, and Korean input: +`IMEState`, `IMEController` and the optional `IMEEventSource` enable Input Method Editor support for Chinese, +Japanese, Korean and other complex input. All text positions are UTF-8 byte offsets (`IMETextRange`), as in +Wayland text-input, GTK4 and winit: ```go // In gogpu/ui - handle IME composition @@ -181,8 +183,8 @@ func (input *TextInput) AttachIME(source gpucontext.EventSource) { }) source.OnIMECompositionUpdate(func(state gpucontext.IMEState) { - // Show composition text with cursor - input.setCompositionText(state.CompositionText, state.CursorPos) + // Composition text with its cursor (UTF-8 byte range; hidden when the IME asks) + input.setCompositionText(state.CompositionText, state.Cursor, state.HasCursor()) }) source.OnIMECompositionEnd(func(committed string) { @@ -190,12 +192,21 @@ func (input *TextInput) AttachIME(source gpucontext.EventSource) { input.insertText(committed) input.hideCompositionWindow() }) + + // Optional events, found by type assertion + if ime, ok := source.(gpucontext.IMEEventSource); ok { + ime.OnIMECanceled(input.clearComposition) + ime.OnIMEDeleteSurrounding(input.deleteAroundCursor) + } } -// Control IME position (for composition window placement) +// Enable IME and tell it where the caret is and what the field expects func (input *TextInput) Focus(controller gpucontext.IMEController) { controller.SetIMEEnabled(true) - controller.SetIMEPosition(input.cursorX, input.cursorY) + controller.SetIMECursorArea(gpucontext.IMECursorArea{X: input.caretX, Y: input.caretY, Height: input.lineHeight}) + if controller.IMECapabilities().Has(gpucontext.IMECapabilityContentType) { + controller.SetIMEContentType(gpucontext.ContentPurposeEmail, gpucontext.ContentHintCompletion) + } } ``` diff --git a/ROADMAP.md b/ROADMAP.md index 046b9f4..01c1c53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,8 +4,10 @@ `gpucontext` is the shared foundation for the [gogpu](https://github.com/gogpu) ecosystem, providing interfaces and utilities for GPU resource sharing without circular dependencies. -## Current: v0.31.2 +## Current: v0.32.0 +- IME contract (ADR-045): one `IMEController` (cursor area, content type, surrounding text, cancel, + capabilities), `IMEState` with UTF-8 byte ranges, optional `IMEEventSource` - `TextureRegionUpdater.UpdateRegion` struct API (`image.Rectangle` + `ImageDataLayout`, #484) - `DeviceProvider.DownlevelCapabilities()` (ADR-071) - `DeviceProvider.Features()` — query device capabilities (FeatureRayQuery, etc.) @@ -21,6 +23,14 @@ ## Released +### v0.32.0 (2026-11-08) +- IME contract per ADR-045 (breaking: `SetIMEPosition` → `SetIMECursorArea`, `IMEState` cursor fields → ranges), + based on gpucontext#30 by @besmpl +- docs: `WindowProvider.RequestRedraw` is safe from any goroutine + +### v0.31.3 (2026-08-31) +- deps: gputypes v0.8.0 (ADR-072 struct params) + ### v0.31.2 (2026-08-30) - docs: fix README and ROADMAP for struct-based UpdateRegion API diff --git a/doc.go b/doc.go index 2857c6b..13bc55d 100644 --- a/doc.go +++ b/doc.go @@ -4,7 +4,9 @@ // projects to enable GPU resource sharing without circular dependencies: // // - DeviceProvider: Interface for providing GPU device and queue -// - EventSource: Interface for window/input events (keyboard, mouse) +// - EventSource: Interface for window/input events (keyboard, mouse, IME composition) +// - IMEController, IMEEventSource: Input Method Editor control and optional events +// (ADR-045; UTF-8 byte offsets, see IMETextRange) // - PointerEventSource: Interface for unified pointer events (W3C Level 3, mouse+touch+pen) // - WindowProvider: Interface for window geometry, DPI, and redraw requests // - PlatformProvider: Interface for clipboard, cursor, dark mode, accessibility @@ -53,8 +55,9 @@ package gpucontext const ( - stringNone = "None" - stringLeft = "Left" - stringRight = "Right" - stringMiddle = "Middle" + stringNone = "None" + stringUnknown = "Unknown" + stringLeft = "Left" + stringRight = "Right" + stringMiddle = "Middle" ) diff --git a/events.go b/events.go index ea8de60..5c0ab38 100644 --- a/events.go +++ b/events.go @@ -90,44 +90,6 @@ type EventSource interface { OnIMECompositionEnd(fn func(committed string)) } -// IMEState represents the current state of the Input Method Editor. -// This is used for CJK (Chinese, Japanese, Korean) and other complex text input. -// -// During IME composition, the user types phonetic characters that are converted -// to ideographic characters. The IMEState contains the current preview text -// and cursor information for rendering the composition inline. -type IMEState struct { - // Composing indicates whether IME is currently in composition mode. - Composing bool - - // CompositionText is the text currently being composed (e.g., pinyin for Chinese). - // This should be displayed inline at the cursor position with special styling. - CompositionText string - - // CursorPos is the cursor position within the composition text. - CursorPos int - - // SelectionStart is the start of the selection within the composition text. - // This is used for candidate selection in some IME systems. - SelectionStart int - - // SelectionEnd is the end of the selection within the composition text. - SelectionEnd int -} - -// IMEController allows widgets to control IME behavior. -// This interface is typically implemented by the host window system. -type IMEController interface { - // SetIMEPosition tells the platform where to show the IME candidate window. - // The coordinates are in screen pixels relative to the window. - SetIMEPosition(x, y int) - - // SetIMEEnabled enables or disables IME for the current input context. - // When disabled, key presses are delivered directly without IME processing. - // This is useful for password fields or non-text inputs. - SetIMEEnabled(enabled bool) -} - // Key represents a keyboard key. // // Values use grouped ranges with explicit base offsets (the net/http pattern). diff --git a/events_test.go b/events_test.go index 62a1d2a..41c7b9e 100644 --- a/events_test.go +++ b/events_test.go @@ -215,70 +215,3 @@ func TestMouseButtonConstants(t *testing.T) { t.Error("MouseButtonMiddle should be 2") } } - -func TestIMEState(t *testing.T) { - // Test IMEState struct fields - state := IMEState{ - Composing: true, - CompositionText: "nihao", - CursorPos: 5, - SelectionStart: 2, - SelectionEnd: 4, - } - - if !state.Composing { - t.Error("Composing should be true") - } - if state.CompositionText != "nihao" { - t.Errorf("CompositionText = %q, want \"nihao\"", state.CompositionText) - } - if state.CursorPos != 5 { - t.Errorf("CursorPos = %d, want 5", state.CursorPos) - } - if state.SelectionStart != 2 { - t.Errorf("SelectionStart = %d, want 2", state.SelectionStart) - } - if state.SelectionEnd != 4 { - t.Errorf("SelectionEnd = %d, want 4", state.SelectionEnd) - } -} - -func TestIMEStateZeroValue(t *testing.T) { - // Test IMEState zero value - var state IMEState - - if state.Composing { - t.Error("Zero value Composing should be false") - } - if state.CompositionText != "" { - t.Errorf("Zero value CompositionText = %q, want empty", state.CompositionText) - } - if state.CursorPos != 0 { - t.Errorf("Zero value CursorPos = %d, want 0", state.CursorPos) - } - if state.SelectionStart != 0 { - t.Errorf("Zero value SelectionStart = %d, want 0", state.SelectionStart) - } - if state.SelectionEnd != 0 { - t.Errorf("Zero value SelectionEnd = %d, want 0", state.SelectionEnd) - } -} - -// mockIMEController is used to verify IMEController interface at compile time. -type mockIMEController struct{} - -func (mockIMEController) SetIMEPosition(_, _ int) {} -func (mockIMEController) SetIMEEnabled(_ bool) {} - -// Ensure mockIMEController implements IMEController. -var _ IMEController = mockIMEController{} - -func TestIMEControllerInterface(t *testing.T) { - // Verify IMEController can be used through the interface - var controller IMEController = mockIMEController{} - - // These should not panic - controller.SetIMEPosition(100, 200) - controller.SetIMEEnabled(true) - controller.SetIMEEnabled(false) -} diff --git a/go.mod b/go.mod index da8126e..90c7739 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/gogpu/gpucontext go 1.25 -require github.com/gogpu/gputypes v0.8.0 +require github.com/gogpu/gputypes v0.8.2 diff --git a/go.sum b/go.sum index dc5ee4f..3b36332 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/gogpu/gputypes v0.8.0 h1:Iw4g9lqydTcAFKKQWfgbUpdY6WxObGtjf9SuRkzYMJw= -github.com/gogpu/gputypes v0.8.0/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= +github.com/gogpu/gputypes v0.8.2 h1:XGn8Kib6fXOREwKLzYyfEqaXTBCi9Zs2oAWj1+yW+ic= +github.com/gogpu/gputypes v0.8.2/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= diff --git a/ime.go b/ime.go new file mode 100644 index 0000000..c1b7129 --- /dev/null +++ b/ime.go @@ -0,0 +1,386 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +// IME contract (ADR-045). One IMEController, extended in place before v1.0, +// and one optional IMEEventSource found by type assertion. All text positions +// are UTF-8 byte offsets, the representation used by the Wayland text-input +// protocol, GTK4 and winit; native backends convert their platform's units +// (UTF-16 on Windows and macOS) at the boundary. + +// IMETextRange is a half-open range [Start, End) in a UTF-8 string. +// +// Start and End are byte offsets, not rune or UTF-16 indexes, and must land on +// UTF-8 rune boundaries. +type IMETextRange struct { + // Start is the first byte included in the range. + Start int + + // End is the first byte excluded from the range. + End int +} + +// Empty reports whether the range contains no bytes. +func (r IMETextRange) Empty() bool { + return r.Start == r.End +} + +// IsValid reports whether the range is within text and starts and ends on +// UTF-8 rune boundaries. Invalid UTF-8 text is rejected as well because all +// IME payloads use UTF-8 offsets. An out-of-range or mid-rune offset is +// invalid; it is never clamped, so a host drops such a payload instead of +// guessing. +func (r IMETextRange) IsValid(text string) bool { + if !utf8.ValidString(text) || r.Start < 0 || r.End < r.Start || r.End > len(text) { + return false + } + if r.Start < len(text) && !utf8.RuneStart(text[r.Start]) { + return false + } + return r.End == len(text) || utf8.RuneStart(text[r.End]) +} + +// imeHiddenCursor is the Cursor value of an IMEState whose IME asked for no +// visible cursor (Wayland text-input-v3 sends -1 for both ends). +var imeHiddenCursor = IMETextRange{Start: -1, End: -1} + +// IMEState is the current Input Method Editor composition, delivered through +// EventSource.OnIMECompositionUpdate. It is used for CJK and other complex +// text input: the user types phonetic characters that the IME converts, and +// the consumer renders CompositionText inline with composition styling. +// +// Cursor and Selection are UTF-8 byte ranges in CompositionText. Most IMEs +// report a collapsed Cursor (Start == End). Selection is the target or +// clause segment the IME is converting, when the platform reports one. A +// Cursor of {-1, -1} means the IME asked for no visible cursor. +type IMEState struct { + // Composing indicates whether IME is currently in composition mode. + Composing bool + + // CompositionText is the current preedit text. + CompositionText string + + // Cursor is the cursor range in CompositionText, or {-1, -1} when the IME + // hides the cursor. + Cursor IMETextRange + + // Selection is the target or clause range in CompositionText; empty when + // the platform reports none. + Selection IMETextRange +} + +// HasCursor reports whether the IME asked for a visible cursor. +func (s IMEState) HasCursor() bool { + return s.Cursor != imeHiddenCursor +} + +// IsValid reports whether Cursor (unless hidden) and Selection are valid UTF-8 +// ranges in CompositionText. +func (s IMEState) IsValid() bool { + cursorValid := !s.HasCursor() || s.Cursor.IsValid(s.CompositionText) + return cursorValid && s.Selection.IsValid(s.CompositionText) +} + +// IMESurroundingText is the text around the insertion point supplied to the +// platform IME. Cursor and Anchor are UTF-8 byte offsets into Text. They differ +// when the surrounding text contains a selection; their order is preserved +// because some native IMEs use the direction of the selection. +// +// A platform may pass the IME only part of Text around the cursor: Wayland +// text-input-v3 caps a request at 4000 bytes. Consumers send a window around +// the insertion point rather than a whole document. +type IMESurroundingText struct { + // Text is the UTF-8 text visible to the IME around the insertion point. + Text string + + // Cursor is the insertion point byte offset in Text. + Cursor int + + // Anchor is the other endpoint of the selection byte offset in Text. + Anchor int +} + +// SelectionRange returns the normalized selection range represented by Cursor +// and Anchor. +func (s IMESurroundingText) SelectionRange() IMETextRange { + if s.Cursor <= s.Anchor { + return IMETextRange{Start: s.Cursor, End: s.Anchor} + } + return IMETextRange{Start: s.Anchor, End: s.Cursor} +} + +// IsValid reports whether Text is valid UTF-8 and both endpoints are on rune +// boundaries within it. +func (s IMESurroundingText) IsValid() bool { + return IMETextRange{Start: s.Cursor, End: s.Cursor}.IsValid(s.Text) && + IMETextRange{Start: s.Anchor, End: s.Anchor}.IsValid(s.Text) +} + +// IMECursorArea is the caret rectangle used to place an IME candidate window. +// Coordinates are logical DIP relative to the window content area, matching +// WindowProvider and the EventSource pointer callbacks; the host scales them +// to the platform's units. Width and Height may be zero when only a caret +// point is available. +type IMECursorArea struct { + // X is the left edge in logical DIP. + X float64 + + // Y is the top edge in logical DIP. + Y float64 + + // Width is the rectangle width in logical DIP. + Width float64 + + // Height is the rectangle height in logical DIP. + Height float64 +} + +// ContentPurpose describes the kind of text expected by an IME. The values +// follow the cross-platform purpose set of winit and the Wayland text-input +// protocol, plus Chat. A host treats a value its platform cannot express +// (Chat on most platforms) and any unknown value as Normal. +type ContentPurpose uint8 + +const ( + // ContentPurposeNormal is unrestricted text. + ContentPurposeNormal ContentPurpose = iota + // ContentPurposeAlpha accepts alphabetic characters. + ContentPurposeAlpha + // ContentPurposeDigits accepts decimal digits. + ContentPurposeDigits + // ContentPurposeNumber accepts a numeric value (including punctuation). + ContentPurposeNumber + // ContentPurposePhone accepts a telephone number. + ContentPurposePhone + // ContentPurposeURL accepts a URL. + ContentPurposeURL + // ContentPurposeEmail accepts an email address. + ContentPurposeEmail + // ContentPurposeName accepts a person's name. + ContentPurposeName + // ContentPurposePassword accepts a password. + ContentPurposePassword + // ContentPurposePin accepts a short numeric PIN. + ContentPurposePin + // ContentPurposeDate accepts a calendar date. + ContentPurposeDate + // ContentPurposeTime accepts a time of day. + ContentPurposeTime + // ContentPurposeDateTime accepts a date and time. + ContentPurposeDateTime + // ContentPurposeTerminal accepts shell/terminal input. + ContentPurposeTerminal + // ContentPurposeChat accepts chat or conversational text. Platforms + // without a chat purpose treat it as Normal. + ContentPurposeChat + + // contentPurposeEnd stays last: the name-coverage test walks every value + // below it. + contentPurposeEnd +) + +// contentPurposeNames is keyed by the constants, so a new purpose without a +// name shows up in TestContentPurposeStringCoversEveryValue. +var contentPurposeNames = map[ContentPurpose]string{ + ContentPurposeNormal: "Normal", + ContentPurposeAlpha: "Alpha", + ContentPurposeDigits: "Digits", + ContentPurposeNumber: "Number", + ContentPurposePhone: "Phone", + ContentPurposeURL: "URL", + ContentPurposeEmail: "Email", + ContentPurposeName: "Name", + ContentPurposePassword: "Password", + ContentPurposePin: "Pin", + ContentPurposeDate: "Date", + ContentPurposeTime: "Time", + ContentPurposeDateTime: "DateTime", + ContentPurposeTerminal: "Terminal", + ContentPurposeChat: "Chat", +} + +// String returns a stable name for the content purpose. +func (p ContentPurpose) String() string { + if name, ok := contentPurposeNames[p]; ok { + return name + } + return stringUnknown +} + +// ContentHint is a bitmask of input-method hints. Hints are advisory: a host +// ignores a hint its platform cannot express. ContentHintNone is the zero +// value; the other values are independent flags. +type ContentHint uint16 + +const ( + // ContentHintNone requests no additional hint. + ContentHintNone ContentHint = 0 + // ContentHintCompletion permits completion suggestions. + ContentHintCompletion ContentHint = 1 << (iota - 1) + // ContentHintSpellcheck permits spell checking. + ContentHintSpellcheck + // ContentHintAutoCapitalization permits automatic capitalization. + ContentHintAutoCapitalization + // ContentHintLowercase requests lowercase text. + ContentHintLowercase + // ContentHintUppercase requests uppercase text. + ContentHintUppercase + // ContentHintTitlecase requests title-case text. + ContentHintTitlecase + // ContentHintHiddenText marks text that must not be displayed. + ContentHintHiddenText + // ContentHintSensitiveData marks text that must not be learned or stored. + ContentHintSensitiveData + // ContentHintLatin requests Latin-script input where possible. + ContentHintLatin + // ContentHintMultiline permits line breaks. + ContentHintMultiline + + // contentHintEnd stays last: the name-coverage test checks that every + // flag below it has a name. + contentHintEnd +) + +// contentHintNames lists every hint flag in bit order, keyed by the constants. +var contentHintNames = []struct { + hint ContentHint + name string +}{ + {ContentHintCompletion, "Completion"}, + {ContentHintSpellcheck, "Spellcheck"}, + {ContentHintAutoCapitalization, "AutoCapitalization"}, + {ContentHintLowercase, "Lowercase"}, + {ContentHintUppercase, "Uppercase"}, + {ContentHintTitlecase, "Titlecase"}, + {ContentHintHiddenText, "HiddenText"}, + {ContentHintSensitiveData, "SensitiveData"}, + {ContentHintLatin, "Latin"}, + {ContentHintMultiline, "Multiline"}, +} + +// Has reports whether all bits in hint are present. +func (h ContentHint) Has(hint ContentHint) bool { + return hint != ContentHintNone && h&hint == hint +} + +// String returns a stable, pipe-separated list of set hint names. +func (h ContentHint) String() string { + if h == ContentHintNone { + return stringNone + } + parts := make([]string, 0, len(contentHintNames)) + known := ContentHintNone + for _, item := range contentHintNames { + if h.Has(item.hint) { + parts = append(parts, item.name) + known |= item.hint + } + } + if unknown := h &^ known; unknown != 0 { + parts = append(parts, "0x"+strconv.FormatUint(uint64(unknown), 16)) + } + return strings.Join(parts, "|") +} + +// IMECapabilities tells a consumer which optional IMEController operations +// the host's platform really performs. Composition, commit, cancel and +// enable/disable are the baseline of every IMEController and have no bit. +// The groups follow winit, where each one is a real platform boundary. +type IMECapabilities uint8 + +const ( + // IMECapabilityCursorArea means SetIMECursorArea positions the candidate + // window. + IMECapabilityCursorArea IMECapabilities = 1 << iota + // IMECapabilityContentType means SetIMEContentType reaches the platform + // IME (purpose and hints). + IMECapabilityContentType + // IMECapabilitySurroundingText means SetIMESurroundingText reaches the + // platform IME and delete-surrounding requests are delivered through + // IMEEventSource. + IMECapabilitySurroundingText +) + +// Has reports whether all capabilities in c2 are present. +func (c IMECapabilities) Has(c2 IMECapabilities) bool { + return c2 != 0 && c&c2 == c2 +} + +// IMEController lets widgets control the platform IME. It is implemented by +// the host window system. IME is disabled until SetIMEEnabled(true); a host +// stops exposing surrounding text once it is disabled. Methods for an +// operation the platform does not support (see IMECapabilities) are no-ops. +// +// Threading: every method must be safe to call from any goroutine, including +// from a draw callback. Platform IME APIs are bound to one thread (TSF and +// IMM32 to the window's thread, Cocoa to the main thread, Wayland to its event +// queue), so an implementation marshals the call there and may apply it +// asynchronously, no later than the next frame. It never blocks the caller +// waiting for that thread. +type IMEController interface { + // SetIMEEnabled enables or disables IME for the focused input. When + // disabled, key presses are delivered directly without IME processing, + // as for password or non-text inputs. + SetIMEEnabled(enabled bool) + + // SetIMECursorArea updates the caret rectangle used to place the + // candidate window. Coordinates are logical DIP in the content area. + SetIMECursorArea(area IMECursorArea) + + // SetIMEContentType supplies an advisory purpose and set of hints. + SetIMEContentType(purpose ContentPurpose, hints ContentHint) + + // SetIMESurroundingText supplies the text around the insertion point for + // context-sensitive candidates and delete-surrounding requests. A host + // ignores an invalid value (see IMESurroundingText.IsValid). + SetIMESurroundingText(text IMESurroundingText) + + // CancelIME cancels an active composition without committing it. + CancelIME() + + // IMECapabilities reports which optional operations the platform + // performs. + IMECapabilities() IMECapabilities +} + +// IMEDeleteSurroundingEvent asks the consumer to delete text around its +// current insertion point. Before and After are non-negative UTF-8 byte +// counts in the surrounding text, not rune counts. The consumer applies the +// deletion atomically and then publishes the updated surrounding text. +type IMEDeleteSurroundingEvent struct { + // Before is the number of bytes to delete immediately before the cursor. + Before int + + // After is the number of bytes to delete immediately after the cursor. + After int +} + +// IsValid reports whether both deletion lengths are non-negative. +func (e IMEDeleteSurroundingEvent) IsValid() bool { + return e.Before >= 0 && e.After >= 0 +} + +// IMEEventSource is the optional IME event extension of an EventSource, +// found by type assertion. EventSource's OnIMECompositionStart, Update and +// End callbacks carry composition; these carry the rest of the IME lifecycle. +type IMEEventSource interface { + // OnIMECanceled registers a callback delivered when an active composition + // is canceled. Cancellation never commits the preedit text. + OnIMECanceled(fn func()) + + // OnIMEDisabled registers a callback delivered when the platform disables + // text input, for example after focus is lost. It carries no text and + // must not commit anything. + OnIMEDisabled(fn func()) + + // OnIMEDeleteSurrounding registers a callback for an IME request to + // delete text around the current insertion point. + OnIMEDeleteSurrounding(fn func(IMEDeleteSurroundingEvent)) +} diff --git a/ime_test.go b/ime_test.go new file mode 100644 index 0000000..e338490 --- /dev/null +++ b/ime_test.go @@ -0,0 +1,241 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "testing" + +func TestIMETextRangeUsesUTF8ByteOffsets(t *testing.T) { + text := "A你B" + // A = 1 byte, 你 = 3 bytes, B = 1 byte. + cases := []struct { + name string + textRange IMETextRange + valid bool + }{ + {"empty at start", IMETextRange{Start: 0, End: 0}, true}, + {"multibyte rune", IMETextRange{Start: 1, End: 4}, true}, + {"end of string", IMETextRange{Start: 5, End: 5}, true}, + {"inside rune", IMETextRange{Start: 2, End: 4}, false}, + {"end inside rune", IMETextRange{Start: 1, End: 3}, false}, + {"reversed", IMETextRange{Start: 4, End: 1}, false}, + {"past end", IMETextRange{Start: 0, End: 6}, false}, + {"negative", IMETextRange{Start: -1, End: 0}, false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := tt.textRange.IsValid(text); got != tt.valid { + t.Fatalf("IMETextRange%+v.IsValid(%q) = %v, want %v", tt.textRange, text, got, tt.valid) + } + }) + } + if (IMETextRange{Start: 0, End: 1}).IsValid("\xff") { + t.Fatal("a range in invalid UTF-8 must be invalid") + } + + if !(IMETextRange{Start: 1, End: 1}).Empty() { + t.Fatal("collapsed range should be empty") + } + if (IMETextRange{Start: 1, End: 4}).Empty() { + t.Fatal("non-collapsed range should not be empty") + } +} + +func TestIMEStateRanges(t *testing.T) { + state := IMEState{ + Composing: true, + CompositionText: "にほ", + Cursor: IMETextRange{Start: 3, End: 6}, + Selection: IMETextRange{Start: 0, End: 3}, + } + if !state.HasCursor() || !state.IsValid() { + t.Fatal("state with UTF-8 boundary ranges should be valid with a visible cursor") + } + + state.Cursor.End = 2 // inside the first three-byte rune, and before Start. + if state.IsValid() { + t.Fatal("state with a non-boundary cursor must be invalid") + } + + state.Cursor = IMETextRange{Start: -1, End: -1} + if state.HasCursor() || !state.IsValid() { + t.Fatal("a {-1, -1} cursor should be a valid hidden cursor") + } + + state.Selection = IMETextRange{Start: 1, End: 3} + if state.IsValid() { + t.Fatal("state with a non-boundary selection must be invalid") + } + + if !(IMEState{}).IsValid() { + t.Fatal("the zero IMEState (no composition) must be valid") + } +} + +func TestIMESurroundingTextSelection(t *testing.T) { + surrounding := IMESurroundingText{Text: "ab你cd", Cursor: 6, Anchor: 1} + if !surrounding.IsValid() { + t.Fatal("surrounding text endpoints should be valid UTF-8 offsets") + } + if got := surrounding.SelectionRange(); got != (IMETextRange{Start: 1, End: 6}) { + t.Fatalf("SelectionRange() = %+v", got) + } + surrounding.Cursor, surrounding.Anchor = 1, 6 + if got := surrounding.SelectionRange(); got != (IMETextRange{Start: 1, End: 6}) { + t.Fatalf("forward SelectionRange() = %+v", got) + } + + surrounding.Cursor = 3 // inside 你. + if surrounding.IsValid() { + t.Fatal("surrounding text with a non-boundary cursor must be invalid") + } +} + +func TestContentPurposeString(t *testing.T) { + tests := []struct { + purpose ContentPurpose + want string + }{ + {ContentPurposeNormal, "Normal"}, + {ContentPurposeAlpha, "Alpha"}, + {ContentPurposeDigits, "Digits"}, + {ContentPurposeNumber, "Number"}, + {ContentPurposePhone, "Phone"}, + {ContentPurposeURL, "URL"}, + {ContentPurposeEmail, "Email"}, + {ContentPurposeName, "Name"}, + {ContentPurposePassword, "Password"}, + {ContentPurposePin, "Pin"}, + {ContentPurposeDate, "Date"}, + {ContentPurposeTime, "Time"}, + {ContentPurposeDateTime, "DateTime"}, + {ContentPurposeTerminal, "Terminal"}, + {ContentPurposeChat, "Chat"}, + {ContentPurpose(255), "Unknown"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.purpose.String(); got != tt.want { + t.Fatalf("ContentPurpose(%d).String() = %q, want %q", tt.purpose, got, tt.want) + } + }) + } +} + +// A purpose added to the iota block without a name in contentPurposeNames +// fails here. +func TestContentPurposeStringCoversEveryValue(t *testing.T) { + for p := ContentPurposeNormal; p < contentPurposeEnd; p++ { + if p.String() == stringUnknown { + t.Errorf("ContentPurpose(%d) has no name", p) + } + } + if got, want := len(contentPurposeNames), int(contentPurposeEnd); got != want { + t.Fatalf("contentPurposeNames has %d entries, want %d", got, want) + } +} + +func TestContentHintFlags(t *testing.T) { + hints := ContentHintCompletion | ContentHintSensitiveData | ContentHintMultiline + for _, hint := range []ContentHint{ContentHintCompletion, ContentHintSensitiveData, ContentHintMultiline} { + if !hints.Has(hint) { + t.Errorf("%v should be present in %v", hint, hints) + } + } + if hints.Has(ContentHintLatin) { + t.Error("Latin should not be present") + } + if got := hints.String(); got != "Completion|SensitiveData|Multiline" { + t.Fatalf("ContentHint.String() = %q", got) + } + if got := ContentHintNone.String(); got != stringNone { + t.Fatalf("ContentHintNone.String() = %q, want %q", got, stringNone) + } + if ContentHintNone.Has(ContentHintNone) { + t.Fatal("ContentHintNone must not report a hint bit") + } + if got := ContentHint(1 << 15).String(); got != "0x8000" { + t.Fatalf("unknown ContentHint.String() = %q, want 0x8000", got) + } + if got := (ContentHintLatin | ContentHint(1<<14)).String(); got != "Latin|0x4000" { + t.Fatalf("mixed ContentHint.String() = %q, want Latin|0x4000", got) + } +} + +// Every hint flag in the iota block has a distinct single bit and a name. +func TestContentHintNamesCoverEveryFlag(t *testing.T) { + var all ContentHint + for _, item := range contentHintNames { + if item.hint == 0 || item.hint&(item.hint-1) != 0 { + t.Errorf("%s is not a single bit: %#x", item.name, item.hint) + } + if all&item.hint != 0 { + t.Errorf("%s reuses a bit", item.name) + } + all |= item.hint + } + if want := contentHintEnd - 1; all != want { + t.Fatalf("named hints cover %#x, want every flag in the block (%#x)", uint64(all), uint64(want)) + } +} + +func TestIMEDeleteSurroundingValidation(t *testing.T) { + if !(IMEDeleteSurroundingEvent{Before: 2, After: 3}).IsValid() { + t.Fatal("non-negative deletion lengths should be valid") + } + if (IMEDeleteSurroundingEvent{Before: -1}).IsValid() { + t.Fatal("negative deletion length should be invalid") + } + if (IMEDeleteSurroundingEvent{After: -1}).IsValid() { + t.Fatal("negative deletion length should be invalid") + } +} + +func TestIMECapabilitiesHas(t *testing.T) { + caps := IMECapabilityCursorArea | IMECapabilitySurroundingText + if !caps.Has(IMECapabilityCursorArea) || !caps.Has(IMECapabilitySurroundingText) { + t.Fatal("set capabilities must be reported") + } + if caps.Has(IMECapabilityContentType) { + t.Fatal("ContentType is not set") + } + if caps.Has(IMECapabilityCursorArea | IMECapabilityContentType) { + t.Fatal("Has must require every requested bit") + } + if caps.Has(0) { + t.Fatal("Has(0) must be false") + } +} + +// fullIMEHost implements the whole contract: IMEController plus the optional +// IMEEventSource. +type fullIMEHost struct{ caps IMECapabilities } + +func (fullIMEHost) SetIMEEnabled(bool) {} +func (fullIMEHost) SetIMECursorArea(IMECursorArea) {} +func (fullIMEHost) SetIMEContentType(ContentPurpose, ContentHint) {} +func (fullIMEHost) SetIMESurroundingText(IMESurroundingText) {} +func (fullIMEHost) CancelIME() {} +func (h fullIMEHost) IMECapabilities() IMECapabilities { return h.caps } +func (fullIMEHost) OnIMECanceled(func()) {} +func (fullIMEHost) OnIMEDisabled(func()) {} +func (fullIMEHost) OnIMEDeleteSurrounding(func(IMEDeleteSurroundingEvent)) {} + +var ( + _ IMEController = fullIMEHost{} + _ IMEEventSource = fullIMEHost{} +) + +func TestIMEEventSourceIsFoundByTypeAssertion(t *testing.T) { + var controller IMEController = fullIMEHost{caps: IMECapabilitySurroundingText} + if !controller.IMECapabilities().Has(IMECapabilitySurroundingText) { + t.Fatal("capabilities come from the controller itself") + } + if _, ok := controller.(IMEEventSource); !ok { + t.Fatal("a host with IME events must be found by a plain type assertion") + } + var plain any = struct{}{} + if _, ok := plain.(IMEEventSource); ok { + t.Fatal("a value without the methods must not satisfy IMEEventSource") + } +} diff --git a/window.go b/window.go index 9c0fc67..1a56dc5 100644 --- a/window.go +++ b/window.go @@ -38,6 +38,10 @@ type WindowProvider interface { // RequestRedraw requests the host to render a new frame. // In on-demand rendering mode, this triggers a single frame render. // In continuous mode, this is a no-op. + // + // It must be safe to call from any goroutine, including while the host is + // rendering (animation drivers call it from their own goroutines), and + // repeated requests before the next frame coalesce into one. RequestRedraw() }