diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec5b1b..3892db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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). +## [Unreleased] + +### Added + +- **Versioned IME contract (v2)** — additive `IMEControllerV2`, + `IMEEventSourceV2`, and `IMECapabilityProviderV2` interfaces. Existing + `EventSource` and `IMEController` implementations remain valid. +- **IME payload/range types** — UTF-8 byte-offset composition and surrounding + text ranges, candidate cursor area, cancellation, and delete-surrounding + events. +- **ContentPurpose/ContentHint** — cross-platform content type values and ten + advisory hint flags, plus capability discovery helpers. + ## [0.28.0] - 2026-08-13 ### Added diff --git a/README.md b/README.md index d27e7b5..dc2c301 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ go get github.com/gogpu/gpucontext - **ScrollEventSource** — Scroll/wheel events with pixel/line/page modes - **Texture** — Minimal interface for GPU textures with TextureUpdater/TextureRegionUpdater/TextureDrawer/TextureCreator - **IME Support** — Input Method Editor for CJK languages (Chinese, Japanese, Korean) +- **Versioned IME contract** — optional cursor ranges, surrounding text, content purpose/hints, cancellation, and capability discovery without changing existing implementors - **WindowChrome** — Custom window chrome for frameless windows (hit testing, minimize/maximize/close) + runtime fullscreen toggle - **Registry[T]** — Generic registry with priority-based backend selection - **WebGPU Interfaces** — Device, Queue, Adapter, Surface interfaces @@ -199,6 +200,49 @@ func (input *TextInput) Focus(controller gpucontext.IMEController) { } ``` +#### Versioned IME extension + +The original `EventSource` and `IMEController` interfaces are kept intact. A +host that supports the richer contract implements the optional `V2` interfaces +and advertises the operations it can provide: + +```go +if caps, ok := gpucontext.DiscoverIMECapabilities(app); ok { + if caps.Supports(gpucontext.IMECapabilityContentPurpose | + gpucontext.IMECapabilityContentHints) { + if controller, ok := app.(gpucontext.IMEControllerV2); ok { + controller.SetIMEContentType( + gpucontext.ContentPurposePassword, + gpucontext.ContentHintSensitiveData|gpucontext.ContentHintHiddenText, + ) + } + } +} + +if source, ok := app.(gpucontext.IMEEventSourceV2); ok { + source.OnIMECompositionUpdateV2(func(state gpucontext.IMEComposition) { + // CursorBegin/End and SelectionStart/End are half-open UTF-8 byte + // ranges into CompositionText (not rune or UTF-16 indexes). + // Check HasCursor before drawing a caret: -1,-1 hides it. + renderPreedit(state.CompositionText, state.CursorRange()) + }) + source.OnIMECanceled(func() { clearPreedit() }) + source.OnIMEDeleteSurrounding(func(event gpucontext.IMEDeleteSurroundingEvent) { + // Before and After are UTF-8 byte counts around the current cursor. + deleteSurrounding(event.Before, event.After) + }) +} +``` + +`IMECursorArea` uses logical DIP relative to the window content area, matching +`WindowProvider` and pointer callbacks. `IMESurroundingText.Cursor` and +`Anchor` are UTF-8 byte offsets and preserve selection direction. Hosts must +convert to native UTF-16 or protocol units at the platform boundary and must +not expose surrounding text while IME is disabled. A provider may implement +`IMEEventSourceV2`, `IMEControllerV2`, and `IMECapabilityProviderV2` +independently; consumers should check capability bits rather than assuming a +platform supports every operation. + ### Texture Interface `Texture` provides a minimal interface for GPU textures, enabling sharing between packages: diff --git a/doc.go b/doc.go index 2857c6b..6703441 100644 --- a/doc.go +++ b/doc.go @@ -4,7 +4,8 @@ // 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, legacy IME) +// - IMEControllerV2/IMEEventSourceV2: Optional versioned IME contract // - 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 diff --git a/events.go b/events.go index ea8de60..7293434 100644 --- a/events.go +++ b/events.go @@ -105,13 +105,27 @@ type IMEState struct { CompositionText string // CursorPos is the cursor position within the composition text. + // + // This field is retained for compatibility with the original v1 contract. + // Versioned IME providers should also populate CursorBegin and CursorEnd; + // when the cursor is collapsed, CursorPos should equal CursorBegin. CursorPos int + // CursorBegin is the first UTF-8 byte of the active cursor range within + // CompositionText. It is available to consumers using IMEContractVersion. + CursorBegin int + + // CursorEnd is the first UTF-8 byte after the active cursor range within + // CompositionText. A collapsed cursor has CursorBegin == CursorEnd. + CursorEnd int + // SelectionStart is the start of the selection within the composition text. - // This is used for candidate selection in some IME systems. + // This is used for candidate selection in some IME systems. Versioned IME + // providers report it as a UTF-8 byte offset. SelectionStart int // SelectionEnd is the end of the selection within the composition text. + // Versioned IME providers report it as a UTF-8 byte offset. SelectionEnd int } diff --git a/ime.go b/ime.go new file mode 100644 index 0000000..c7b2e3c --- /dev/null +++ b/ime.go @@ -0,0 +1,457 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import ( + "strings" + "unicode/utf8" +) + +// IMEContractVersion is the version of the optional, richer IME contract. +// +// The original EventSource IME callbacks and IMEController interface remain +// unchanged. A host advertises this version by implementing +// IMECapabilityProviderV2; consumers must still check the individual feature +// bits before using an optional operation. +const IMEContractVersion uint = 2 + +// 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. This is the +// representation used by the Wayland text-input protocol and lets a host +// forward a Go string without changing its encoding. Values must land on +// UTF-8 rune boundaries. Native backends are responsible for converting their +// platform's range units at the boundary. +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. +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]) +} + +// IMEComposition is the full-fidelity composition payload delivered through +// IMEEventSourceV2.OnIMECompositionUpdateV2. All positions refer to +// CompositionText and use UTF-8 byte offsets. The original +// EventSource.OnIMECompositionUpdate callback remains available for legacy +// consumers. +// +// CursorBegin and CursorEnd describe the active cursor range. Most IMEs report +// a collapsed range, but a range is required for platforms that expose a +// selected segment inside the preedit. SelectionStart and SelectionEnd are the +// target/marked range supplied by the IME, when one is available. A pair of +// -1 values in CursorBegin/CursorEnd means that the IME requests a hidden +// cursor; all other positions must be valid UTF-8 boundaries. +type IMEComposition struct { + // CompositionText is the current preedit text. + CompositionText string + + // CursorBegin is the first byte of the active cursor range. + CursorBegin int + + // CursorEnd is the first byte after the active cursor range. + CursorEnd int + + // SelectionStart is the first byte of the marked/selected range. + SelectionStart int + + // SelectionEnd is the first byte after the marked/selected range. + SelectionEnd int +} + +// Composition converts the legacy IMEState payload to the versioned +// full-fidelity shape. Providers that only populate the legacy collapsed +// CursorPos field are represented as a collapsed cursor range; providers that +// populate CursorBegin/End retain the richer range. +func (s IMEState) Composition() IMEComposition { + begin, end := s.CursorBegin, s.CursorEnd + if begin == 0 && end == 0 && s.CursorPos != 0 { + begin, end = s.CursorPos, s.CursorPos + } + return IMEComposition{ + CompositionText: s.CompositionText, + CursorBegin: begin, + CursorEnd: end, + SelectionStart: s.SelectionStart, + SelectionEnd: s.SelectionEnd, + } +} + +// CursorRange returns the active cursor range in CompositionText. +func (s IMEComposition) CursorRange() IMETextRange { + return IMETextRange{Start: s.CursorBegin, End: s.CursorEnd} +} + +// HasCursor reports whether the IME supplied a visible cursor range. +func (s IMEComposition) HasCursor() bool { + return s.CursorBegin >= 0 && s.CursorEnd >= 0 +} + +// SelectionRange returns the marked/selected range in CompositionText. +func (s IMEComposition) SelectionRange() IMETextRange { + return IMETextRange{Start: s.SelectionStart, End: s.SelectionEnd} +} + +// IsValid reports whether both ranges are valid UTF-8 ranges in the preedit. +func (s IMEComposition) IsValid() bool { + cursorValid := (!s.HasCursor() && s.CursorBegin == -1 && s.CursorEnd == -1) || + s.CursorRange().IsValid(s.CompositionText) + return cursorValid && + s.SelectionRange().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 may +// differ when the surrounding text contains a selection; their order is +// preserved because some native IMEs use the direction of the selection. +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. 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 +// intentionally match the cross-platform purpose set used by winit and the +// Wayland text-input protocol. Unknown values must be treated as Normal by a +// host. +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. + ContentPurposeChat +) + +// String returns a stable name for the content purpose. +func (p ContentPurpose) String() string { + switch p { + case ContentPurposeNormal: + return "Normal" + case ContentPurposeAlpha: + return "Alpha" + case ContentPurposeDigits: + return "Digits" + case ContentPurposeNumber: + return "Number" + case ContentPurposePhone: + return "Phone" + case ContentPurposeURL: + return "URL" + case ContentPurposeEmail: + return "Email" + case ContentPurposeName: + return "Name" + case ContentPurposePassword: + return "Password" + case ContentPurposePin: + return "Pin" + case ContentPurposeDate: + return "Date" + case ContentPurposeTime: + return "Time" + case ContentPurposeDateTime: + return "DateTime" + case ContentPurposeTerminal: + return "Terminal" + case ContentPurposeChat: + return "Chat" + default: + return "Unknown" + } +} + +// ContentHint is a bitmask of input-method hints. Hints are advisory: a host +// may ignore a hint when its platform cannot express it. ContentHintNone is +// the zero value; the other ten 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 +) + +// ContentHintAutoCapitalize is the common spelling used by browser APIs. It +// is an alias of ContentHintAutoCapitalization. +const ContentHintAutoCapitalize = ContentHintAutoCapitalization + +// 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, 10) + known := ContentHintNone + for _, item := range []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"}, + } { + if h.Has(item.hint) { + parts = append(parts, item.name) + known |= item.hint + } + } + if unknown := h &^ known; unknown != 0 { + parts = append(parts, "Unknown") + } + return strings.Join(parts, "|") +} + +// 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 should apply +// the deletion atomically and then publish 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 +} + +// IMEEventSourceV2 is the optional event extension for the richer IME +// contract. It intentionally does not modify EventSource, so every existing +// EventSource implementation remains source-compatible. Hosts should continue +// to expose EventSource's start/update/end callbacks and implement this +// interface when the platform can report these additional events. +type IMEEventSourceV2 interface { + // OnIMECompositionUpdateV2 registers a callback for a full-fidelity + // composition update. It is delivered in the same lifecycle as the + // original EventSource callback, but carries a cursor range rather than a + // single cursor position. + OnIMECompositionUpdateV2(func(IMEComposition)) + + // OnIMECanceled registers a callback delivered when an active composition + // is canceled. Cancellation never commits the preedit text. + OnIMECanceled(func()) + + // OnIMEDisabled registers a callback delivered when the platform disables + // text input (for example, after focus is lost or a password field is + // selected). The callback carries no text and must not commit anything. + OnIMEDisabled(func()) + + // OnIMEDeleteSurrounding registers a callback for an IME request to delete + // text around the current insertion point. + OnIMEDeleteSurrounding(func(IMEDeleteSurroundingEvent)) +} + +// IMEEventSource is the unversioned spelling of IMEEventSourceV2. It is an +// optional capability and is not embedded in EventSource for compatibility. +type IMEEventSource = IMEEventSourceV2 + +// IMEControllerV2 extends the original IMEController without changing it. +// Consumers should use a type assertion before calling these optional +// methods. Implementations must treat IME as disabled until SetIMEEnabled(true) +// is called, and must stop exposing surrounding text after it is disabled. +type IMEControllerV2 interface { + IMEController + + // SetIMECursorArea updates the caret rectangle used for candidate-window + // placement. Coordinates are logical DIP in the window content area. + SetIMECursorArea(area IMECursorArea) + + // SetIMEContentType supplies an advisory purpose and set of content hints. + SetIMEContentType(purpose ContentPurpose, hints ContentHint) + + // SetIMESurroundingText supplies UTF-8 text and cursor/anchor byte offsets + // for context-sensitive candidate generation and delete-surrounding + // requests. Implementations should ignore invalid ranges. + SetIMESurroundingText(text IMESurroundingText) + + // CancelIME cancels an active composition without committing its preedit. + CancelIME() +} + +// IMECapability identifies one optional operation in IMEContractVersion. +type IMECapability uint16 + +const ( + // IMECapabilityComposition indicates composition start/update/end events. + IMECapabilityComposition IMECapability = 1 << iota + // IMECapabilityCommit indicates committed text is reported exactly once. + IMECapabilityCommit + // IMECapabilityCancel indicates cancellation is supported. + IMECapabilityCancel + // IMECapabilityDisabled indicates OnIMEDisabled is supported. + IMECapabilityDisabled + // IMECapabilityDeleteSurrounding indicates delete-surrounding requests. + IMECapabilityDeleteSurrounding + // IMECapabilityCursorArea indicates candidate cursor-area updates. + IMECapabilityCursorArea + // IMECapabilitySurroundingText indicates surrounding-text updates. + IMECapabilitySurroundingText + // IMECapabilityContentPurpose indicates content-purpose support. + IMECapabilityContentPurpose + // IMECapabilityContentHints indicates content-hint support. + IMECapabilityContentHints +) + +// IMECapabilities describes the optional IME operations provided by a host. +// Version is the highest contract version understood by the provider. A zero +// Version means that no versioned contract is available. +type IMECapabilities struct { + // Version is the supported IME contract version. + Version uint + + // Features contains the operations supported at Version. + Features IMECapability +} + +// Supports reports whether all requested capabilities are available. +func (c IMECapabilities) Supports(capability IMECapability) bool { + return capability != 0 && c.Features&capability == capability +} + +// IMECapabilityProviderV2 lets a host advertise the optional IME contract. +// The method is deliberately separate from EventSource and IMEController so a +// host can expose capability discovery on whichever provider owns the window. +type IMECapabilityProviderV2 interface { + IMECapabilities() IMECapabilities +} + +// IMEContractV2 is the complete optional contract. A host may implement the +// smaller interfaces independently; consumers should prefer capability +// assertions so partial platform support remains useful. +type IMEContractV2 interface { + IMEControllerV2 + IMEEventSourceV2 + IMECapabilityProviderV2 +} + +// DiscoverIMECapabilities returns the capabilities advertised by value. It +// returns false for legacy providers and for providers that return Version 0. +// A future Version is returned unchanged so consumers can safely use the bits +// they understand while ignoring newer operations. +func DiscoverIMECapabilities(value interface{}) (IMECapabilities, bool) { + provider, ok := value.(IMECapabilityProviderV2) + if !ok { + return IMECapabilities{}, false + } + caps := provider.IMECapabilities() + return caps, caps.Version != 0 +} diff --git a/ime_test.go b/ime_test.go new file mode 100644 index 0000000..2b41c5d --- /dev/null +++ b/ime_test.go @@ -0,0 +1,232 @@ +// 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}, + {"reversed", IMETextRange{Start: 4, End: 1}, false}, + {"past end", IMETextRange{Start: 0, End: 6}, 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: 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 TestIMECompositionRanges(t *testing.T) { + composition := IMEComposition{ + CompositionText: "にほ", + CursorBegin: 3, + CursorEnd: 6, + SelectionStart: 0, + SelectionEnd: 3, + } + if !composition.IsValid() { + t.Fatal("composition with UTF-8 boundary ranges should be valid") + } + if got := composition.CursorRange(); got != (IMETextRange{Start: 3, End: 6}) { + t.Fatalf("CursorRange() = %+v", got) + } + if got := composition.SelectionRange(); got != (IMETextRange{Start: 0, End: 3}) { + t.Fatalf("SelectionRange() = %+v", got) + } + + composition.CursorEnd = 2 // inside the first three-byte rune. + if composition.IsValid() { + t.Fatal("composition with a non-boundary cursor must be invalid") + } + composition.CursorBegin, composition.CursorEnd = -1, -1 + if composition.HasCursor() || !composition.IsValid() { + t.Fatal("a -1,-1 cursor range should represent a valid hidden cursor") + } +} + +func TestLegacyIMEStateCompositionConversion(t *testing.T) { + legacy := IMEState{CompositionText: "ni", CursorPos: 2} + got := legacy.Composition() + if got.CursorRange() != (IMETextRange{Start: 2, End: 2}) { + t.Fatalf("legacy collapsed cursor conversion = %+v", got.CursorRange()) + } + + ranged := IMEState{CompositionText: "に", CursorBegin: 0, CursorEnd: 3} + if got := ranged.Composition().CursorRange(); got != (IMETextRange{Start: 0, End: 3}) { + t.Fatalf("versioned cursor conversion = %+v", got) + } +} + +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) + } + }) + } +} + +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 ContentHintAutoCapitalize != ContentHintAutoCapitalization { + t.Fatal("browser spelling must remain an alias") + } + 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 capability bit") + } + if got := ContentHint(1 << 15).String(); got != "Unknown" { + t.Fatalf("unknown ContentHint.String() = %q, want Unknown", got) + } +} + +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") + } +} + +// legacyIMEController intentionally implements only the original interface. +// Adding the versioned extension must not make this implementation invalid. +type legacyIMEController struct{} + +func (legacyIMEController) SetIMEPosition(_, _ int) {} +func (legacyIMEController) SetIMEEnabled(bool) {} + +var _ IMEController = legacyIMEController{} + +type richIMEProvider struct{} + +func (richIMEProvider) SetIMEPosition(_, _ int) {} +func (richIMEProvider) SetIMEEnabled(bool) {} +func (richIMEProvider) SetIMECursorArea(IMECursorArea) {} +func (richIMEProvider) SetIMEContentType(ContentPurpose, ContentHint) {} +func (richIMEProvider) SetIMESurroundingText(IMESurroundingText) {} +func (richIMEProvider) CancelIME() {} +func (richIMEProvider) OnIMECompositionUpdateV2(func(IMEComposition)) {} +func (richIMEProvider) OnIMECanceled(func()) {} +func (richIMEProvider) OnIMEDisabled(func()) {} +func (richIMEProvider) OnIMEDeleteSurrounding(func(IMEDeleteSurroundingEvent)) {} +func (richIMEProvider) IMECapabilities() IMECapabilities { + return IMECapabilities{ + Version: IMEContractVersion, + Features: IMECapabilityComposition | + IMECapabilityCommit | + IMECapabilityCancel | + IMECapabilityDisabled | + IMECapabilityDeleteSurrounding | + IMECapabilityCursorArea | + IMECapabilitySurroundingText | + IMECapabilityContentPurpose | + IMECapabilityContentHints, + } +} + +var _ IMEControllerV2 = richIMEProvider{} +var _ IMEEventSourceV2 = richIMEProvider{} +var _ IMECapabilityProviderV2 = richIMEProvider{} +var _ IMEContractV2 = richIMEProvider{} + +func TestDiscoverIMECapabilities(t *testing.T) { + caps, ok := DiscoverIMECapabilities(richIMEProvider{}) + if !ok { + t.Fatal("rich provider should advertise capabilities") + } + if caps.Version != IMEContractVersion { + t.Fatalf("capability version = %d, want %d", caps.Version, IMEContractVersion) + } + if !caps.Supports(IMECapabilityCursorArea | IMECapabilityContentHints) { + t.Fatal("provider should advertise cursor-area and content-hint support") + } + if _, ok := DiscoverIMECapabilities(legacyIMEController{}); ok { + t.Fatal("legacy provider must not be reported as versioned") + } +} + +func TestZeroCapabilitiesAreNotAdvertised(t *testing.T) { + provider := zeroCapabilitiesProvider{} + if _, ok := DiscoverIMECapabilities(provider); ok { + t.Fatal("version zero must not be advertised") + } +} + +type zeroCapabilitiesProvider struct{} + +func (zeroCapabilitiesProvider) IMECapabilities() IMECapabilities { return IMECapabilities{} }