mirror of
https://gitlab.com/freepascal.org/lazarus/lazarus.git
synced 2025-05-01 20:43:42 +02:00

macOS will try to get the first character(localtion=0). previously, FullControlEdit returned the first character of the first line. so if the first line is empty, an empty string will be returned, and macOS will think that there are no characters after it.
497 lines
16 KiB
ObjectPascal
497 lines
16 KiB
ObjectPascal
unit CocoaFullControlEdit;
|
|
|
|
{$mode objfpc}{$H+}
|
|
{$modeswitch objectivec2}
|
|
{$interfaces corba}
|
|
|
|
interface
|
|
|
|
uses
|
|
Classes, SysUtils,
|
|
LazUTF8, Graphics, CocoaGDIObjects,
|
|
CocoaAll, CocoaPrivate, CocoaCustomControl, CocoaUtils;
|
|
|
|
const
|
|
IM_MESSAGE_WPARAM_GET_IME_HANDLER = 0;
|
|
IM_MESSAGE_WPARAM_GET_LW_HANDLER = 1;
|
|
|
|
type
|
|
{ ICocoaIMEControl }
|
|
|
|
// IME Parameters for Cocoa Interface internal and LCL Full Control Edit
|
|
// intentionally keep the Record type, emphasizing that it is only a simple type,
|
|
// only used as parameters, don't put into logical functions
|
|
TCocoaIMEParameters = record
|
|
text: ShortString; // Marked Text
|
|
textCharLength: Integer; // length in code point
|
|
textByteLength: Integer; // length in bytes
|
|
textNSLength: Integer; // length in code unit (NSString)
|
|
selectedStart: Integer; // selected range start in code point
|
|
selectedLength: Integer; // selected range length in code point
|
|
eatAmount: Integer; // delete char out of Marked Text
|
|
isFirstCall: Boolean; // if first in the IME session
|
|
end;
|
|
|
|
// the LCL Component that need Cocoa IME support need to
|
|
// implement this simple interface
|
|
// class LazSynCocoaIMM in SynEdit Component for reference
|
|
// class ATSynEdit_Adapter_CocoaIME in ATSynEdit Component for reference
|
|
ICocoaIMEControl = interface ['{AAD5C3AD-C8E0-20A4-E779-6F5D4F8380BD}']
|
|
procedure IMESessionBegin;
|
|
procedure IMESessionEnd;
|
|
procedure IMEUpdateIntermediateText( var params: TCocoaIMEParameters );
|
|
procedure IMEInsertFinalText( var params: TCocoaIMEParameters );
|
|
function IMEGetTextBound( var params: TCocoaIMEParameters ): TRect;
|
|
end;
|
|
|
|
{ ICocoaLookupWord }
|
|
|
|
// Lookup Word Parameters for Cocoa Interface internal and LCL Full Control Edit
|
|
// intentionally keep the Record type, emphasizing that it is only a simple type,
|
|
// only used as parameters, don't put into logical functions
|
|
TCocoaLWParameters = record
|
|
text: String; // return line text from LCL Full Control Edit
|
|
row: Integer; // the row being looked up
|
|
col: Integer; // the column being looked up
|
|
length: Integer; // the length the text we want to obtain
|
|
end;
|
|
|
|
// the LCL Component that need Cocoa Lookup Word support need to
|
|
// implement this simple interface
|
|
ICocoaLookupWord = interface ['{F5B0D020-1F29-9E8C-33DD-AA122597E6A2}']
|
|
procedure LWRowColForScreenPoint( var params: TCocoaLWParameters;
|
|
const screenPoint: TPoint );
|
|
procedure LWLineForRow( var params: TCocoaLWParameters );
|
|
function LWGetTextBound( var params: TCocoaLWParameters ): TRect;
|
|
function LWGetFont( var params: TCocoaLWParameters ): TFont;
|
|
end;
|
|
|
|
{ TCocoaFullControlEdit }
|
|
|
|
// backend of LCL Full Control Edit Component (such as SynEdit/ATSynEdit)
|
|
// Key Class for Cocoa IME support
|
|
// 1. obtain IME capability from Cocoa by implementing NSTextInputClientProtocol
|
|
// 2. synchronize IME data with LCL via ICocoaIMEControl
|
|
TCocoaFullControlEdit = objcclass(TCocoaCustomControl, NSTextInputClientProtocol)
|
|
private
|
|
_currentParams: TCocoaIMEParameters;
|
|
_currentMarkedText: NSString;
|
|
public
|
|
imeHandler: ICocoaIMEControl;
|
|
lwHandler: ICocoaLookupWord;
|
|
public
|
|
function initWithFrame(frameRect: NSRect): id; override;
|
|
|
|
procedure keyDown(theEvent: NSEvent); override;
|
|
procedure mouseDown(event: NSEvent); override;
|
|
procedure mouseUp(event: NSEvent); override;
|
|
function resignFirstResponder: ObjCBOOL; override;
|
|
|
|
procedure insertText_replacementRange (aString: id; replacementRange: NSRange);
|
|
procedure setMarkedText_selectedRange_replacementRange (aString: id; newRange: NSRange; replacementRange: NSRange);
|
|
procedure unmarkText;
|
|
function selectedRange: NSRange;
|
|
function markedRange: NSRange;
|
|
function hasMarkedText: LCLObjCBoolean;
|
|
function firstRectForCharacterRange_actualRange ({%H-}aRange: NSRange; {%H-}actualRange: NSRangePointer): NSRect;
|
|
|
|
function attributedSubstringForProposedRange_actualRange (aRange: NSRange; actualRange: NSRangePointer): NSAttributedString;
|
|
function validAttributesForMarkedText: NSArray;
|
|
function characterIndexForPoint (aPoint: NSPoint): NSUInteger;
|
|
end;
|
|
|
|
implementation
|
|
|
|
{ TCocoaIMEParameters }
|
|
|
|
// set text and length in params
|
|
procedure setIMEParamsText( var params: TCocoaIMEParameters; const nsText: NSString );
|
|
begin
|
|
params.text := NSStringToString( nsText );
|
|
params.textCharLength := UTF8Length( params.text );
|
|
params.textByteLength := Length( params.text );
|
|
params.textNSLength := nsText.length;
|
|
end;
|
|
|
|
// set selected range in code point
|
|
procedure setIMESelectedRange( var params: TCocoaIMEParameters; const nsText: NSString; range: NSRange );
|
|
begin
|
|
if range.location<>NSNotFound then
|
|
begin
|
|
if range.location>nsText.length then
|
|
range.location:= 0;
|
|
if range.location+range.length>nsText.length then
|
|
range.length:= nsText.length-range.location;
|
|
end;
|
|
|
|
if range.location=NSNotFound then
|
|
params.selectedStart:= 0
|
|
else
|
|
params.selectedStart:= UTF8Length( nsText.substringToIndex(range.location).UTF8String );
|
|
|
|
if range.length=0 then
|
|
params.selectedLength:= 0
|
|
else
|
|
params.selectedLength:= UTF8Length( nsText.substringWithRange(range).UTF8String );
|
|
end;
|
|
|
|
{ TCocoaFullControlEdit }
|
|
|
|
function TCocoaFullControlEdit.initWithFrame(frameRect: NSRect): id;
|
|
begin
|
|
Result:=inherited initWithFrame(frameRect);
|
|
self.unmarkText;
|
|
end;
|
|
|
|
{
|
|
for IME Key Down:
|
|
Key step for IME (such as Chinese/Japanese/Korean and DeadKeys)
|
|
1. forward key event to NSInputContext
|
|
2. NSInputContext will call TCocoaFullControlEdit(NSTextControlClient)
|
|
and then call LCL via imeHandler
|
|
}
|
|
procedure TCocoaFullControlEdit.keyDown(theEvent: NSEvent);
|
|
begin
|
|
inputContext.handleEvent(theEvent);
|
|
end;
|
|
|
|
{
|
|
for IME Close:
|
|
1. mouseDown() will not be called when click in the IME Popup Window,
|
|
so it must be clicking outside the IME Popup Windows,
|
|
which should end the IME input
|
|
2. Cocoa had called setMarkedText_selectedRange_replacementRange()
|
|
or insertText_replacementRange() first, then mouseDown() here
|
|
3. NSInputContext.handleEvent() just close IME window here
|
|
4. LCL actually handle mouse event
|
|
}
|
|
procedure TCocoaFullControlEdit.mouseDown(event: NSEvent);
|
|
begin
|
|
inputContext.handleEvent(event);
|
|
Inherited;
|
|
end;
|
|
|
|
procedure TCocoaFullControlEdit.mouseUp(event: NSEvent);
|
|
begin
|
|
inputContext.handleEvent(event);
|
|
Inherited;
|
|
end;
|
|
|
|
// prevent switch to other control when in IME input state
|
|
function TCocoaFullControlEdit.resignFirstResponder: ObjCBOOL;
|
|
begin
|
|
Result := not hasMarkedText();
|
|
end;
|
|
|
|
function isIMEDuplicateCall( const newParams, currentParams: TCocoaIMEParameters ) : Boolean;
|
|
begin
|
|
Result:= false;
|
|
if newParams.isFirstCall then exit;
|
|
if newParams.text <> currentParams.text then exit;
|
|
if newParams.selectedStart<>currentParams.selectedStart then exit;
|
|
if newParams.selectedLength<>currentParams.selectedLength then exit;
|
|
Result:= true;
|
|
end;
|
|
|
|
// send Marked/Intermediate Text to LCL Edit Control which has IME Handler
|
|
// Key step for IME (such as Chinese/Japanese/Korean and DeadKeys)
|
|
procedure TCocoaFullControlEdit.setMarkedText_selectedRange_replacementRange(
|
|
aString: id; newRange: NSRange; replacementRange: NSRange);
|
|
var
|
|
params : TCocoaIMEParameters;
|
|
nsText : NSString;
|
|
begin
|
|
params.isFirstCall:= not hasMarkedText();
|
|
|
|
// no markedText before, the first call
|
|
if params.isFirstCall then imeHandler.IMESessionBegin;
|
|
|
|
// get IME Intermediate Text
|
|
nsText:= getNSStringObject( aString );
|
|
setIMEParamsText( params, nsText );
|
|
|
|
// some IME want to select subRange of Intermediate Text
|
|
// such as Japanese
|
|
setIMESelectedRange( params, nsText, newRange );
|
|
|
|
// some IME incorrectly call setMarkedText() twice with the same parameters
|
|
if isIMEDuplicateCall( params, _currentParams ) then
|
|
exit;
|
|
|
|
// some IME want to eat some chars
|
|
// such as inputting DeadKeys
|
|
if replacementRange.location<>NSNotFound then
|
|
params.eatAmount:= 1 - replacementRange.location
|
|
else
|
|
params.eatAmount:= 0;
|
|
|
|
// Key Step to update(display) Marked/Intermediate Text
|
|
imeHandler.IMEUpdateIntermediateText( params );
|
|
|
|
if params.textNSLength=0 then
|
|
begin
|
|
// cancel Marked/Intermediate Text
|
|
imeHandler.IMESessionEnd;
|
|
unmarkText;
|
|
end
|
|
else
|
|
begin
|
|
// update Marked/Intermediate Text internal status
|
|
_currentParams:= params;
|
|
_currentMarkedText.release;
|
|
_currentMarkedText:= nsText;
|
|
_currentMarkedText.retain;
|
|
end;
|
|
end;
|
|
|
|
{
|
|
send final Text to LCL Edit Control which has IME Handler
|
|
Key step for IME (such as Chinese/Japanese/Korean and DeadKeys)
|
|
1. if in IME input state, handle text via imeHandler.IMEInsertFinalText()
|
|
2. otherwise via lclGetCallback.InputClientInsertText,
|
|
mainly for maximum forward compatibility with TCocoaCustomControl
|
|
}
|
|
procedure TCocoaFullControlEdit.insertText_replacementRange(aString: id;
|
|
replacementRange: NSRange);
|
|
var
|
|
params: TCocoaIMEParameters;
|
|
nsText : NSString;
|
|
begin
|
|
params.isFirstCall:= not hasMarkedText();
|
|
|
|
// IME final text
|
|
nsText:= getNSStringObject( aString );
|
|
setIMEParamsText( params, nsText );
|
|
|
|
// some IME want to eat some chars, such as inputting DeadKeys
|
|
if replacementRange.location<>NSNotFound then
|
|
params.eatAmount:= 1 - replacementRange.location
|
|
else
|
|
params.eatAmount:= 0;
|
|
|
|
if (not params.isFirstCall) or (params.eatAmount<>0) then
|
|
// insert IME final text
|
|
imeHandler.IMEInsertFinalText( params )
|
|
else
|
|
// insert normal text (without IME) by LCLControl.IntfUTF8KeyPress()
|
|
lclGetCallback.InputClientInsertText( params.text );
|
|
|
|
if not params.isFirstCall then
|
|
begin
|
|
imeHandler.IMESessionEnd;
|
|
unmarkText;
|
|
end;
|
|
end;
|
|
|
|
|
|
{
|
|
the Cocoa API uses a continuous positive integer to locate the text position,
|
|
mainly in:
|
|
1. get the text index corresponding to the current mouse cursor through
|
|
the return value of characterIndexForPoint()
|
|
2. the text is obtained through the parameter aRange.location passed in by
|
|
attributedSubstringForProposedRange_actualRange().
|
|
note that Cocoa assumes that the index are continuous, with -1 corresponding
|
|
to the previous character and +1 corresponding to the next character,
|
|
which is where the trouble comes in.
|
|
|
|
however, in the controls such as SynEdit, there is no corresponding Index.
|
|
although the index can be calculated by traversing each line, it increases
|
|
the workload and complexity.
|
|
|
|
in Lookup Word, we only need to ensure that the index is continuous in a line,
|
|
so a simplified method is used:
|
|
1. controls such as SynEdit are indexed by row + column
|
|
2. the index required by Cooca is a 64-bit integer
|
|
3. so the rows and columns are encoded into 64-bit Index, with
|
|
the high 32 bits corresponding to the rows and
|
|
the lower 32 bits corresponding to the columns.
|
|
4. the operation on the Index cannot cross line. if it does,
|
|
a simple correction is required. see rangeToLWParams().
|
|
}
|
|
|
|
const
|
|
LW_LOCATION_BASE = $1000000000000000;
|
|
|
|
function rangeToLWParams( const aRange: NSRange ): TCocoaLWParameters;
|
|
var
|
|
location: NSUInteger;
|
|
begin
|
|
location:= aRange.location;
|
|
if location >= (LW_LOCATION_BASE/2) then
|
|
location:= location - LW_LOCATION_BASE;
|
|
Result.row:= location shr 32;
|
|
Result.col:= location and $FFFFFFFF;
|
|
Result.length:= aRange.length;
|
|
if Result.col < 0 then begin
|
|
Result.col:= 0;
|
|
Result.row:= Result.row + 1;
|
|
end;
|
|
end;
|
|
|
|
function LWParamsToRange( const params: TCocoaLWParameters ): NSRange;
|
|
var
|
|
location: NSUInteger;
|
|
begin
|
|
location:= (QWord(params.row) shl 32) + params.col;
|
|
Result.location:= location + LW_LOCATION_BASE;
|
|
Result.length:= params.length;
|
|
end;
|
|
|
|
// cursor tracking
|
|
function TCocoaFullControlEdit.firstRectForCharacterRange_actualRange(
|
|
aRange: NSRange; actualRange: NSRangePointer): NSRect;
|
|
|
|
function getImeTextBound: TRect;
|
|
var
|
|
params: TCocoaIMEParameters;
|
|
begin
|
|
params:= _currentParams;
|
|
setIMESelectedRange( params, _currentMarkedText, aRange );
|
|
params.isFirstCall:= not hasMarkedText();
|
|
Result:= imeHandler.IMEGetTextBound( params );
|
|
end;
|
|
|
|
function getLookupWordBound: TRect;
|
|
var
|
|
params: TCocoaLWParameters;
|
|
begin
|
|
params:= rangeToLWParams( aRange );
|
|
Result:= lwHandler.LWGetTextBound( params );
|
|
end;
|
|
|
|
var
|
|
rect : TRect;
|
|
begin
|
|
if aRange.location < LW_LOCATION_BASE then begin
|
|
rect:= getImeTextBound;
|
|
end else begin
|
|
rect:= getLookupWordBound;
|
|
end;
|
|
LCLToNSRect( rect, NSGlobalScreenBottom, Result );
|
|
end;
|
|
|
|
procedure TCocoaFullControlEdit.unmarkText;
|
|
begin
|
|
setIMEParamsText( _currentParams, nil );
|
|
_currentParams.selectedStart:= 0;
|
|
_currentParams.selectedLength:= 0;
|
|
_currentParams.eatAmount:= 0;
|
|
_currentParams.isFirstCall:= true;
|
|
_currentMarkedText.release;
|
|
_currentMarkedText:= nil;
|
|
end;
|
|
|
|
function TCocoaFullControlEdit.markedRange: NSRange;
|
|
begin
|
|
if _currentParams.textNSLength=0 then
|
|
Result:= NSMakeRange( NSNotFound, 0 )
|
|
else
|
|
Result:= NSMakeRange( 0, _currentParams.textNSLength );
|
|
end;
|
|
|
|
function TCocoaFullControlEdit.selectedRange: NSRange;
|
|
begin
|
|
if _currentParams.textNSLength=0 then
|
|
Result:= NSMakeRange( 0, 0 )
|
|
else
|
|
Result:= NSMakeRange( _currentParams.selectedStart, _currentParams.selectedLength );
|
|
end;
|
|
|
|
function TCocoaFullControlEdit.hasMarkedText: LCLObjCBoolean;
|
|
begin
|
|
Result:= ( _currentParams.textNSLength > 0 );
|
|
end;
|
|
|
|
{
|
|
1. given the previous description of not crossing lines,
|
|
at most one line of text is returned.
|
|
2. LW_LOCATION_BASE has been added to location to distinguish
|
|
between IME and Lookup Word.
|
|
}
|
|
function TCocoaFullControlEdit.attributedSubstringForProposedRange_actualRange(
|
|
aRange: NSRange; actualRange: NSRangePointer): NSAttributedString;
|
|
var
|
|
params: TCocoaLWParameters;
|
|
textWord: NSString;
|
|
|
|
procedure initParams;
|
|
begin
|
|
params:= rangeToLWParams( aRange );
|
|
self.lwHandler.LWLineForRow( params );
|
|
end;
|
|
|
|
procedure initTextWord;
|
|
var
|
|
lineText: NSString;
|
|
subRange: NSRange;
|
|
begin
|
|
if (aRange.location=0) and (params.text='') then begin
|
|
params.text:= ' ';
|
|
textWord:= NSSTR( ' ' );
|
|
Exit;
|
|
end;
|
|
|
|
lineText:= StrToNSString( params.text );
|
|
subRange.location:= params.col;
|
|
subRange.length:= aRange.length;
|
|
if subRange.location + subRange.length > lineText.length then begin
|
|
if lineText.length > subRange.location then
|
|
subRange.length:= lineText.length - subRange.location
|
|
else
|
|
subRange.length:= 0;
|
|
end;
|
|
textWord:= lineText.substringWithRange( subRange );
|
|
end;
|
|
|
|
function getAttributeWord: NSAttributedString;
|
|
var
|
|
attribs: NSDictionary;
|
|
lclFont: TFont;
|
|
cocoaFont: NSFont;
|
|
begin
|
|
lclFont:= self.lwHandler.LWGetFont( params );
|
|
cocoaFont:= TCocoaFont(lclFont.Reference.Handle).Font;
|
|
attribs:= NSMutableDictionary.alloc.initWithCapacity(1);
|
|
attribs.setValue_forKey( cocoaFont, NSFontAttributeName );
|
|
Result:= NSAttributedString.alloc.initWithString_attributes(
|
|
textWord, attribs );
|
|
Result.autorelease;
|
|
attribs.release;
|
|
end;
|
|
|
|
begin
|
|
Result:= nil;
|
|
|
|
if NOT Assigned(self.lwHandler) then
|
|
Exit;
|
|
|
|
initParams;
|
|
initTextWord;
|
|
actualRange^:= LWParamsToRange( params );
|
|
Result:= getAttributeWord;
|
|
end;
|
|
|
|
function TCocoaFullControlEdit.characterIndexForPoint(aPoint: NSPoint
|
|
): NSUInteger;
|
|
var
|
|
params: TCocoaLWParameters;
|
|
lclPoint: TPoint;
|
|
begin
|
|
Result:= NSNotFound;
|
|
if NOT Assigned(self.lwHandler) then
|
|
Exit;
|
|
lclPoint:= ScreenPointFromNSToLCL( aPoint );
|
|
self.lwHandler.LWRowColForScreenPoint( params, lclPoint );
|
|
if params.col >= 0 then
|
|
Result:= LWParamsToRange(params).location;
|
|
end;
|
|
|
|
function TCocoaFullControlEdit.validAttributesForMarkedText: NSArray;
|
|
begin
|
|
Result := nil;
|
|
end;
|
|
|
|
end.
|
|
|