View Issue Details

IDProjectCategoryView StatusLast Update
0036073LazarusWidgetsetpublic2019-09-13 00:23
ReporterZoë PetersonAssigned To 
PrioritynormalSeverityminorReproducibilityalways
Status newResolutionopen 
Product Version2.0.4Product Build 
Target VersionFixed in Version 
Summary0036073: Cocoa: Fix TEdit/TMemo Undo/Redo
DescriptionThis fixes various issues with Undo/Redo in memos and edits and replaces some of the work from the patch in issue 35421. It's split into two because the second patch is riskier.

Patch 1:

* Changes the way TCocoaTextView (TMemo) returns its undoManager from overriding the message to using the delegate method. This doesn't affect stock LCL much, since NSResponderHotKeys works either way, but the old code doesn't work with NSMenu's validation protocol, so a auto-enabled menu item with an undo:/redo: action (instead of actionButtonClick:) won't enable properly or update it's caption (visible in the sample project).

* Undo stack is now cleared whenever we set the textview contents programmatically. This fixes two issues:
  1) Setting the initial value for a memo was treated as an undoable edit.
  2) If you make an edit, modify the text programmatically, then try to undo, the app crashes with a Cocoa assertion failure. removeAllActionsWithTarget(text) doesn't clear it properly, which is why removeAllActions is used.


Patch 2:

With only patch 1 applied, TEdit/TMemo undo/redo is still essentially unusable with the stock LCL. NSUndoManager has a property, groupsByEvent, that makes it so multiple edits within a single NSEvent are combined into a single undoable action. Making the same type of edit multiple times (e.g., typing multiple characters) is also combined into a single edit. Making different types of edits (type, cut, paste) should remain distinct undoable actions. With the current LCL, that doesn't work; every edit is combined into a single undo group, so trying to undo one thing clears the edit back to its initial state.

The problem is that the way the runloop is handled when COCOALOOPHIJACK is defined breaks the groupsByEvent behavior. Changing from COCOALOOPHIJACK to COCOALOOPNATIVE solves the problem, but at least in our app introduces various other issues.

This patch fixes the issue by returning a custom undo manager that manages the groupsByEvent behavior manually. Whenever a new undoable item is registered we start a new undo group, and close it out whenever undo is triggered.

Since we have to capture all undo entries, this also needs to override registerUndoWithTarget:handler which was introduced in macOS 10.11. It is susceptible to breaking if other register* functions are added in the future, so making COCOALOOPNATIVE work properly is probably the best long-term fix.

Patch 2 also has the side effect that all NSTextFields (TEdit) have a private undoManager now too, so they no longer clear the undo stack when gaining/losing focus.

If COCOALOOPNATIVE isn't defined, all of the new behavior is $IFDEFed out.
Steps To ReproduceThe attached sample project has 2 edits, 2 memos, and a raw NSTextView in the lower left corner for comparison purposes.

The "Edit" menu includes all of the standard actions (Undo/Redo/Cut/Copy/Paste/Select All) and uses NSMenu's auto-enable support to update itself rather than relying on the LCL layer. As you make edits and change focus you can see the menu items enabling automatically. When the raw NSTextView has focus, Undo/Redo are enabled appropriately and their captions reflect the state of the edit ("Undo Typing", "Redo Paste", etc). When one of the TMemos has focus, Undo/Redo stay permanently disabled.

The "Clear" button sets all 5 edits to empty. If you make an edit in one, click that button, then go back in and press Cmd+Z, the app will crash.
TagsNo tags attached.
Fixed in Revision
LazTarget
WidgetsetCocoa
Attached Files
  • cocoa-undo-1.patch (3,574 bytes)
    diff --git a/lcl/interfaces/cocoa/cocoatextedits.pas b/lcl/interfaces/cocoa/cocoatextedits.pas
    index bc97f74cf4..f85c9ece23 100644
    --- a/lcl/interfaces/cocoa/cocoatextedits.pas
    +++ b/lcl/interfaces/cocoa/cocoatextedits.pas
    @@ -112,7 +112,6 @@ type
     
         procedure dealloc; override;
         function acceptsFirstResponder: LCLObjCBoolean; override;
    -    function undoManager: NSUndoManager; override;
         function lclGetCallback: ICommonCallback; override;
         procedure lclClearCallback; override;
         procedure resetCursorRects; override;
    @@ -141,6 +140,7 @@ type
         // delegate methods
         procedure textDidChange(notification: NSNotification); message 'textDidChange:';
         procedure lclExpectedKeys(var wantTabs, wantArrows, wantReturn, wantAll: Boolean); override;
    +    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
       end;
     
       { TCococaFieldEditorExt }
    @@ -1000,18 +1000,6 @@ begin
       Result := NSViewCanFocus(Self);
     end;
     
    -function TCocoaTextView.undoManager: NSUndoManager;
    -begin
    -  if allowsUndo then
    -  begin
    -    if not Assigned(FUndoManager) then
    -      FUndoManager := NSUndoManager.alloc.init;
    -    Result := FUndoManager;
    -  end
    -  else
    -    Result := nil;
    -end;
    -
     function TCocoaTextView.lclGetCallback: ICommonCallback;
     begin
       Result := callback;
    @@ -1150,6 +1138,13 @@ begin
       wantAll := true;
     end;
     
    +function TCocoaTextView.undoManagerForTextView(view: NSTextView): NSUndoManager;
    +begin
    +  if not Assigned(FUndoManager) then
    +    FUndoManager := NSUndoManager.alloc.init;
    +  Result := FUndoManager;
    +end;
    +
     { TCocoaSecureTextField }
     
     function TCocoaSecureTextField.acceptsFirstResponder: LCLObjCBoolean;
    diff --git a/lcl/interfaces/cocoa/cocoautils.pas b/lcl/interfaces/cocoa/cocoautils.pas
    index 26fb699f2f..9c27e1a78b 100644
    --- a/lcl/interfaces/cocoa/cocoautils.pas
    +++ b/lcl/interfaces/cocoa/cocoautils.pas
    @@ -611,6 +611,7 @@ begin
         ns := NSStringUTF8(s);
         text.setString(ns);
         ns.release;
    +    text.undoManager.removeAllActions;
       end;
     end;
     
    diff --git a/lcl/interfaces/cocoa/cocoawsstdctrls.pas b/lcl/interfaces/cocoa/cocoawsstdctrls.pas
    index 5a706fb52b..67dbfa7fb8 100644
    --- a/lcl/interfaces/cocoa/cocoawsstdctrls.pas
    +++ b/lcl/interfaces/cocoa/cocoawsstdctrls.pas
    @@ -1165,12 +1165,8 @@ begin
     end;
     
     procedure TCocoaMemoStrings.SetTextStr(const Value: string);
    -var
    -  ns: NSString;
     begin
    -  ns := NSStringUtf8(LineBreaksToUnix(Value));
    -  FTextView.setString(ns);
    -  ns.release;
    +  SetNSText(FTextView, LineBreaksToUnix(Value));
     
       FTextView.textDidChange(nil);
     end;
    @@ -1293,6 +1289,8 @@ begin
       FTextView.insertText( NSString.stringWithUTF8String( LFSTR ));
     
       if not ro then FTextView.setEditable(ro);
    +
    +  FTextView.undoManager.removeAllActions;
     end;
     
     procedure TCocoaMemoStrings.LoadFromFile(const FileName: string);
    @@ -1463,9 +1461,7 @@ begin
       txt.callback := lcl;
       txt.setDelegate(txt);
     
    -  ns := NSStringUtf8(AParams.Caption);
    -  txt.setString(ns);
    -  ns.release;
    +  SetNSText(txt, AParams.Caption);
     
       scr.callback := txt.callback;
     
    @@ -1676,13 +1672,10 @@ end;
     class procedure TCocoaWSCustomMemo.SetText(const AWinControl:TWinControl;const AText:String);
     var
       txt: TCocoaTextView;
    -  ns: NSString;
     begin
       txt := GetTextView(AWinControl);
       if not Assigned(txt) then Exit;
    -  ns := NSStringUtf8(LineBreaksToUnix(AText));
    -  txt.setString(ns);
    -  ns.release;
    +  SetNSText(txt, LineBreaksToUnix(AText));
     end;
     
     class function TCocoaWSCustomMemo.GetText(const AWinControl: TWinControl; var AText: String): Boolean;
    
    cocoa-undo-1.patch (3,574 bytes)
  • cocoa-undo-2.patch (6,731 bytes)
    --- a/lcl/interfaces/cocoa/cocoa_extra.pas
    +++ b/lcl/interfaces/cocoa/cocoa_extra.pas
    @@ -15,6 +15,7 @@
     unit Cocoa_Extra;
     
     {$mode objfpc}{$H+}
    +{$modeswitch cblocks}
     {$modeswitch objectivec1}
     {$include cocoadefines.inc}
     
    @@ -218,6 +219,14 @@ type
         procedure setAppleMenu(AMenu: NSMenu); message 'setAppleMenu:';
       end;}
     
    +  NSUndoManagerUndoWithTargetCBlock = reference to procedure(target: id); cdecl;
    +
    +  NSUndoManagerFix = objccategory external (NSUndoManager)
    +    procedure registerUndoWithTarget_handler(target: id;
    +      handler: NSUndoManagerUndoWithTargetCBlock);
    +      message 'registerUndoWithTarget:handler:';
    +  end;
    +
       NSOperatingSystemVersion = record
         majorVersion: NSInteger;
         minorVersion: NSInteger;
    diff --git a/lcl/interfaces/cocoa/cocoatextedits.pas b/lcl/interfaces/cocoa/cocoatextedits.pas
    index f85c9ece23..f57352823f 100644
    --- a/lcl/interfaces/cocoa/cocoatextedits.pas
    +++ b/lcl/interfaces/cocoa/cocoatextedits.pas
    @@ -23,6 +23,9 @@ unit CocoaTextEdits;
     {.$DEFINE COCOA_DEBUG_SETBOUNDS}
     {.$DEFINE COCOA_SPIN_DEBUG}
     {.$DEFINE COCOA_SPINEDIT_INSIDE_CONTAINER}
    +{$IFDEF COCOALOOPHIJACK}
    +  {$DEFINE COCOA_OVERRIDE_UNDOMANAGER}
    +{$ENDIF}
     
     interface
     
    @@ -60,6 +63,10 @@ type
       TCocoaTextField = objcclass(NSTextField)
         callback: ICommonCallback;
         maxLength: Integer;
    +    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +    FUndoManager: NSUndoManager;
    +    procedure dealloc; override;
    +    {$ENDIF}
         function acceptsFirstResponder: LCLObjCBoolean; override;
         function lclGetCallback: ICommonCallback; override;
         procedure lclClearCallback; override;
    @@ -75,6 +82,10 @@ type
         procedure otherMouseUp(event: NSEvent); override;
         procedure mouseDragged(event: NSEvent); override;
         procedure mouseMoved(event: NSEvent); override;
    +    // delegate methods
    +    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
    +    {$ENDIF}
       end;
     
       { TCocoaSecureTextField }
    @@ -82,6 +93,10 @@ type
       TCocoaSecureTextField = objcclass(NSSecureTextField)
       public
         callback: ICommonCallback;
    +    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +    FUndoManager: NSUndoManager;
    +    procedure dealloc; override;
    +    {$ENDIF}
         function acceptsFirstResponder: LCLObjCBoolean; override;
         procedure resetCursorRects; override;
         function lclGetCallback: ICommonCallback; override;
    @@ -96,6 +111,10 @@ type
         procedure otherMouseUp(event: NSEvent); override;
         procedure mouseDragged(event: NSEvent); override;
         procedure mouseMoved(event: NSEvent); override;
    +    // delegate methods
    +    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
    +    {$ENDIF}
       end;
     
       { TCocoaTextView }
    @@ -422,6 +441,20 @@ type
       end;
     {$ENDIF}
     
    +  { TCocoaUndoManager }
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +  TCocoaUndoManager = objcclass(NSUndoManager)
    +    lastEvent: NSEvent; // weak reference
    +    function init: id; override;
    +    procedure undo; override;
    +    procedure registerUndoWithTarget_selector_object(target: id; selector: SEL;
    +      anObject: id); override;
    +    procedure registerUndoWithTarget_handler(target: id;
    +      handler: NSUndoManagerUndoWithTargetCBlock); override;
    +    procedure lclCheckGrouping; message 'lclCheckGrouping';
    +  end;
    +{$ENDIF}
    +
     // these constants are missing from CocoaAll for some reason
     const
       NSTextAlignmentLeft      = 0;
    @@ -893,6 +926,15 @@ end;
     
     { TCocoaTextField }
     
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +procedure TCocoaTextField.dealloc;
    +begin
    +  if Assigned(FUndoManager) then
    +    FUndoManager.release;
    +  inherited dealloc;
    +end;
    +{$ENDIF}
    +
     function TCocoaTextField.acceptsFirstResponder: LCLObjCBoolean;
     begin
       Result := NSViewCanFocus(Self);
    @@ -980,6 +1022,15 @@ begin
         inherited mouseMoved(event);
     end;
     
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +function TCocoaTextField.undoManagerForTextView(view: NSTextView): NSUndoManager;
    +begin
    +  if not Assigned(FUndoManager) then
    +    FUndoManager := TCocoaUndoManager.alloc.init;
    +  Result := FUndoManager;
    +end;
    +{$ENDIF}
    +
     { TCocoaTextView }
     
     procedure TCocoaTextView.changeColor(sender: id);
    @@ -1141,12 +1192,25 @@ end;
     function TCocoaTextView.undoManagerForTextView(view: NSTextView): NSUndoManager;
     begin
       if not Assigned(FUndoManager) then
    +    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +    FUndoManager := TCocoaUndoManager.alloc.init;
    +    {$ELSE}
         FUndoManager := NSUndoManager.alloc.init;
    +    {$ENDIF}
       Result := FUndoManager;
     end;
     
     { TCocoaSecureTextField }
     
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +procedure TCocoaSecureTextField.dealloc;
    +begin
    +  if Assigned(FUndoManager) then
    +    FUndoManager.release;
    +  inherited dealloc;
    +end;
    +{$ENDIF}
    +
     function TCocoaSecureTextField.acceptsFirstResponder: LCLObjCBoolean;
     begin
       Result := NSViewCanFocus(Self);
    @@ -1221,6 +1285,15 @@ begin
         inherited mouseMoved(event);
     end;
     
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +function TCocoaSecureTextField.undoManagerForTextView(view: NSTextView): NSUndoManager;
    +begin
    +  if not Assigned(FUndoManager) then
    +    FUndoManager := TCocoaUndoManager.alloc.init;
    +  Result := FUndoManager;
    +end;
    +{$ENDIF}
    +
     { TCocoaEditComboBoxList }
     
     procedure TCocoaEditComboBoxList.InsertItem(Index: Integer; const S: string;
    @@ -2126,5 +2199,54 @@ end;
     
     {$ENDIF}
     
    +{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
    +
    +{ TCocoaUndoManager }
    +
    +function TCocoaUndoManager.init: id;
    +begin
    +  // This manages top-level undo groups automatically to work around an issue
    +  // where, if we hijack the run loop, all undoable actions are combined into a
    +  // single undo group.  It isn't necessary for correct behavior in the other
    +  // modes.
    +  Result := inherited init;
    +  Result.setGroupsByEvent(False);
    +end;
    +
    +procedure TCocoaUndoManager.undo;
    +begin
    +  if not groupsByEvent and (groupingLevel = 1) then
    +    endUndoGrouping;
    +  inherited;
    +end;
    +
    +procedure TCocoaUndoManager.registerUndoWithTarget_selector_object(target: id;
    +  selector: SEL; anObject: id);
    +begin
    +  lclCheckGrouping;
    +  inherited;
    +end;
    +
    +procedure TCocoaUndoManager.registerUndoWithTarget_handler(target: id;
    +  handler: NSUndoManagerUndoWithTargetCBlock);
    +begin
    +  lclCheckGrouping;
    +  inherited registerUndoWithTarget_handler(target, handler);
    +end;
    +
    +procedure TCocoaUndoManager.lclCheckGrouping;
    +begin
    +  if groupsByEvent or isUndoing or isRedoing then
    +    Exit;
    +  if (groupingLevel = 1) and (lastEvent <> NSApp.currentEvent) then
    +    endUndoGrouping;
    +  if groupingLevel = 0 then begin
    +    lastEvent := NSApp.currentEvent;
    +    beginUndoGrouping;
    +  end;
    +end;
    +
    +{$ENDIF}
    +
     end.
     
    
    cocoa-undo-2.patch (6,731 bytes)
  • cocoa-undo-test.zip (113,188 bytes)

Activities

Zoë Peterson

2019-09-13 00:23

reporter  

cocoa-undo-1.patch (3,574 bytes)
diff --git a/lcl/interfaces/cocoa/cocoatextedits.pas b/lcl/interfaces/cocoa/cocoatextedits.pas
index bc97f74cf4..f85c9ece23 100644
--- a/lcl/interfaces/cocoa/cocoatextedits.pas
+++ b/lcl/interfaces/cocoa/cocoatextedits.pas
@@ -112,7 +112,6 @@ type
 
     procedure dealloc; override;
     function acceptsFirstResponder: LCLObjCBoolean; override;
-    function undoManager: NSUndoManager; override;
     function lclGetCallback: ICommonCallback; override;
     procedure lclClearCallback; override;
     procedure resetCursorRects; override;
@@ -141,6 +140,7 @@ type
     // delegate methods
     procedure textDidChange(notification: NSNotification); message 'textDidChange:';
     procedure lclExpectedKeys(var wantTabs, wantArrows, wantReturn, wantAll: Boolean); override;
+    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
   end;
 
   { TCococaFieldEditorExt }
@@ -1000,18 +1000,6 @@ begin
   Result := NSViewCanFocus(Self);
 end;
 
-function TCocoaTextView.undoManager: NSUndoManager;
-begin
-  if allowsUndo then
-  begin
-    if not Assigned(FUndoManager) then
-      FUndoManager := NSUndoManager.alloc.init;
-    Result := FUndoManager;
-  end
-  else
-    Result := nil;
-end;
-
 function TCocoaTextView.lclGetCallback: ICommonCallback;
 begin
   Result := callback;
@@ -1150,6 +1138,13 @@ begin
   wantAll := true;
 end;
 
+function TCocoaTextView.undoManagerForTextView(view: NSTextView): NSUndoManager;
+begin
+  if not Assigned(FUndoManager) then
+    FUndoManager := NSUndoManager.alloc.init;
+  Result := FUndoManager;
+end;
+
 { TCocoaSecureTextField }
 
 function TCocoaSecureTextField.acceptsFirstResponder: LCLObjCBoolean;
diff --git a/lcl/interfaces/cocoa/cocoautils.pas b/lcl/interfaces/cocoa/cocoautils.pas
index 26fb699f2f..9c27e1a78b 100644
--- a/lcl/interfaces/cocoa/cocoautils.pas
+++ b/lcl/interfaces/cocoa/cocoautils.pas
@@ -611,6 +611,7 @@ begin
     ns := NSStringUTF8(s);
     text.setString(ns);
     ns.release;
+    text.undoManager.removeAllActions;
   end;
 end;
 
diff --git a/lcl/interfaces/cocoa/cocoawsstdctrls.pas b/lcl/interfaces/cocoa/cocoawsstdctrls.pas
index 5a706fb52b..67dbfa7fb8 100644
--- a/lcl/interfaces/cocoa/cocoawsstdctrls.pas
+++ b/lcl/interfaces/cocoa/cocoawsstdctrls.pas
@@ -1165,12 +1165,8 @@ begin
 end;
 
 procedure TCocoaMemoStrings.SetTextStr(const Value: string);
-var
-  ns: NSString;
 begin
-  ns := NSStringUtf8(LineBreaksToUnix(Value));
-  FTextView.setString(ns);
-  ns.release;
+  SetNSText(FTextView, LineBreaksToUnix(Value));
 
   FTextView.textDidChange(nil);
 end;
@@ -1293,6 +1289,8 @@ begin
   FTextView.insertText( NSString.stringWithUTF8String( LFSTR ));
 
   if not ro then FTextView.setEditable(ro);
+
+  FTextView.undoManager.removeAllActions;
 end;
 
 procedure TCocoaMemoStrings.LoadFromFile(const FileName: string);
@@ -1463,9 +1461,7 @@ begin
   txt.callback := lcl;
   txt.setDelegate(txt);
 
-  ns := NSStringUtf8(AParams.Caption);
-  txt.setString(ns);
-  ns.release;
+  SetNSText(txt, AParams.Caption);
 
   scr.callback := txt.callback;
 
@@ -1676,13 +1672,10 @@ end;
 class procedure TCocoaWSCustomMemo.SetText(const AWinControl:TWinControl;const AText:String);
 var
   txt: TCocoaTextView;
-  ns: NSString;
 begin
   txt := GetTextView(AWinControl);
   if not Assigned(txt) then Exit;
-  ns := NSStringUtf8(LineBreaksToUnix(AText));
-  txt.setString(ns);
-  ns.release;
+  SetNSText(txt, LineBreaksToUnix(AText));
 end;
 
 class function TCocoaWSCustomMemo.GetText(const AWinControl: TWinControl; var AText: String): Boolean;
cocoa-undo-1.patch (3,574 bytes)
cocoa-undo-2.patch (6,731 bytes)
--- a/lcl/interfaces/cocoa/cocoa_extra.pas
+++ b/lcl/interfaces/cocoa/cocoa_extra.pas
@@ -15,6 +15,7 @@
 unit Cocoa_Extra;
 
 {$mode objfpc}{$H+}
+{$modeswitch cblocks}
 {$modeswitch objectivec1}
 {$include cocoadefines.inc}
 
@@ -218,6 +219,14 @@ type
     procedure setAppleMenu(AMenu: NSMenu); message 'setAppleMenu:';
   end;}
 
+  NSUndoManagerUndoWithTargetCBlock = reference to procedure(target: id); cdecl;
+
+  NSUndoManagerFix = objccategory external (NSUndoManager)
+    procedure registerUndoWithTarget_handler(target: id;
+      handler: NSUndoManagerUndoWithTargetCBlock);
+      message 'registerUndoWithTarget:handler:';
+  end;
+
   NSOperatingSystemVersion = record
     majorVersion: NSInteger;
     minorVersion: NSInteger;
diff --git a/lcl/interfaces/cocoa/cocoatextedits.pas b/lcl/interfaces/cocoa/cocoatextedits.pas
index f85c9ece23..f57352823f 100644
--- a/lcl/interfaces/cocoa/cocoatextedits.pas
+++ b/lcl/interfaces/cocoa/cocoatextedits.pas
@@ -23,6 +23,9 @@ unit CocoaTextEdits;
 {.$DEFINE COCOA_DEBUG_SETBOUNDS}
 {.$DEFINE COCOA_SPIN_DEBUG}
 {.$DEFINE COCOA_SPINEDIT_INSIDE_CONTAINER}
+{$IFDEF COCOALOOPHIJACK}
+  {$DEFINE COCOA_OVERRIDE_UNDOMANAGER}
+{$ENDIF}
 
 interface
 
@@ -60,6 +63,10 @@ type
   TCocoaTextField = objcclass(NSTextField)
     callback: ICommonCallback;
     maxLength: Integer;
+    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+    FUndoManager: NSUndoManager;
+    procedure dealloc; override;
+    {$ENDIF}
     function acceptsFirstResponder: LCLObjCBoolean; override;
     function lclGetCallback: ICommonCallback; override;
     procedure lclClearCallback; override;
@@ -75,6 +82,10 @@ type
     procedure otherMouseUp(event: NSEvent); override;
     procedure mouseDragged(event: NSEvent); override;
     procedure mouseMoved(event: NSEvent); override;
+    // delegate methods
+    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
+    {$ENDIF}
   end;
 
   { TCocoaSecureTextField }
@@ -82,6 +93,10 @@ type
   TCocoaSecureTextField = objcclass(NSSecureTextField)
   public
     callback: ICommonCallback;
+    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+    FUndoManager: NSUndoManager;
+    procedure dealloc; override;
+    {$ENDIF}
     function acceptsFirstResponder: LCLObjCBoolean; override;
     procedure resetCursorRects; override;
     function lclGetCallback: ICommonCallback; override;
@@ -96,6 +111,10 @@ type
     procedure otherMouseUp(event: NSEvent); override;
     procedure mouseDragged(event: NSEvent); override;
     procedure mouseMoved(event: NSEvent); override;
+    // delegate methods
+    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+    function undoManagerForTextView(view: NSTextView): NSUndoManager; message 'undoManagerForTextView:';
+    {$ENDIF}
   end;
 
   { TCocoaTextView }
@@ -422,6 +441,20 @@ type
   end;
 {$ENDIF}
 
+  { TCocoaUndoManager }
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+  TCocoaUndoManager = objcclass(NSUndoManager)
+    lastEvent: NSEvent; // weak reference
+    function init: id; override;
+    procedure undo; override;
+    procedure registerUndoWithTarget_selector_object(target: id; selector: SEL;
+      anObject: id); override;
+    procedure registerUndoWithTarget_handler(target: id;
+      handler: NSUndoManagerUndoWithTargetCBlock); override;
+    procedure lclCheckGrouping; message 'lclCheckGrouping';
+  end;
+{$ENDIF}
+
 // these constants are missing from CocoaAll for some reason
 const
   NSTextAlignmentLeft      = 0;
@@ -893,6 +926,15 @@ end;
 
 { TCocoaTextField }
 
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+procedure TCocoaTextField.dealloc;
+begin
+  if Assigned(FUndoManager) then
+    FUndoManager.release;
+  inherited dealloc;
+end;
+{$ENDIF}
+
 function TCocoaTextField.acceptsFirstResponder: LCLObjCBoolean;
 begin
   Result := NSViewCanFocus(Self);
@@ -980,6 +1022,15 @@ begin
     inherited mouseMoved(event);
 end;
 
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+function TCocoaTextField.undoManagerForTextView(view: NSTextView): NSUndoManager;
+begin
+  if not Assigned(FUndoManager) then
+    FUndoManager := TCocoaUndoManager.alloc.init;
+  Result := FUndoManager;
+end;
+{$ENDIF}
+
 { TCocoaTextView }
 
 procedure TCocoaTextView.changeColor(sender: id);
@@ -1141,12 +1192,25 @@ end;
 function TCocoaTextView.undoManagerForTextView(view: NSTextView): NSUndoManager;
 begin
   if not Assigned(FUndoManager) then
+    {$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+    FUndoManager := TCocoaUndoManager.alloc.init;
+    {$ELSE}
     FUndoManager := NSUndoManager.alloc.init;
+    {$ENDIF}
   Result := FUndoManager;
 end;
 
 { TCocoaSecureTextField }
 
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+procedure TCocoaSecureTextField.dealloc;
+begin
+  if Assigned(FUndoManager) then
+    FUndoManager.release;
+  inherited dealloc;
+end;
+{$ENDIF}
+
 function TCocoaSecureTextField.acceptsFirstResponder: LCLObjCBoolean;
 begin
   Result := NSViewCanFocus(Self);
@@ -1221,6 +1285,15 @@ begin
     inherited mouseMoved(event);
 end;
 
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+function TCocoaSecureTextField.undoManagerForTextView(view: NSTextView): NSUndoManager;
+begin
+  if not Assigned(FUndoManager) then
+    FUndoManager := TCocoaUndoManager.alloc.init;
+  Result := FUndoManager;
+end;
+{$ENDIF}
+
 { TCocoaEditComboBoxList }
 
 procedure TCocoaEditComboBoxList.InsertItem(Index: Integer; const S: string;
@@ -2126,5 +2199,54 @@ end;
 
 {$ENDIF}
 
+{$IFDEF COCOA_OVERRIDE_UNDOMANAGER}
+
+{ TCocoaUndoManager }
+
+function TCocoaUndoManager.init: id;
+begin
+  // This manages top-level undo groups automatically to work around an issue
+  // where, if we hijack the run loop, all undoable actions are combined into a
+  // single undo group.  It isn't necessary for correct behavior in the other
+  // modes.
+  Result := inherited init;
+  Result.setGroupsByEvent(False);
+end;
+
+procedure TCocoaUndoManager.undo;
+begin
+  if not groupsByEvent and (groupingLevel = 1) then
+    endUndoGrouping;
+  inherited;
+end;
+
+procedure TCocoaUndoManager.registerUndoWithTarget_selector_object(target: id;
+  selector: SEL; anObject: id);
+begin
+  lclCheckGrouping;
+  inherited;
+end;
+
+procedure TCocoaUndoManager.registerUndoWithTarget_handler(target: id;
+  handler: NSUndoManagerUndoWithTargetCBlock);
+begin
+  lclCheckGrouping;
+  inherited registerUndoWithTarget_handler(target, handler);
+end;
+
+procedure TCocoaUndoManager.lclCheckGrouping;
+begin
+  if groupsByEvent or isUndoing or isRedoing then
+    Exit;
+  if (groupingLevel = 1) and (lastEvent <> NSApp.currentEvent) then
+    endUndoGrouping;
+  if groupingLevel = 0 then begin
+    lastEvent := NSApp.currentEvent;
+    beginUndoGrouping;
+  end;
+end;
+
+{$ENDIF}
+
 end.
 
cocoa-undo-2.patch (6,731 bytes)
cocoa-undo-test.zip (113,188 bytes)

Issue History

Date Modified Username Field Change
2019-09-13 00:23 Zoë Peterson New Issue
2019-09-13 00:23 Zoë Peterson File Added: cocoa-undo-1.patch
2019-09-13 00:23 Zoë Peterson File Added: cocoa-undo-2.patch
2019-09-13 00:23 Zoë Peterson File Added: cocoa-undo-test.zip