Foundation: Add CPProgress and cancellable item provider loading

Partial implementation, gaps and impossibilities noted internally as TODO comments.

* CPProgress.j: Introduce CPProgress, modelled on NSProgress.
  - Count units of work, with fraction completed, finished and indeterminate states.
  - Compose progress trees through explicit children and the current progress object.
  - Cancel, pause and resume, with handlers, propagating to children.
  - Announce unit-count changes through key-value observing across the tree.

* CPError.j: Add CPUserCancelledError.

* CPItemProvider.j:
  - Return a CPProgress from each load, counting bytes read for files and aborting the read on cancellation.
  - Pass file contents to CPData as a binary string rather than a byte array.
  - Implement -canLoadObjectOfClass: and -loadObjectOfClass:completionHandler:.
  - Add the CPString (CPItemProvider) reading category.

* CPString+Encoding.j: Decode data held as a binary string through a Uint8Array, without building a byte array.

* Foundation.j: Import CPProgress.j.

* CPProgressTest.j: Add unit tests covering counts, children, the current progress object, cancellation, pausing, observation and user information.

* CPItemProviderTest.j: Add unit tests covering read progress, cancellation, completion and string loading.
This commit is contained in:
David Richardson committed 2026-11-02 15:29:21 -06:00
1 parent 40c5c466c1
commit 021e9935aa
7 files changed
+1275 -34

No files matched your search

+2
View File
@@ -40,6 +40,8 @@ CPStringEncodingErrorKey = @"CPStringEncodingErrorKey";
CPURLErrorKey = kCFErrorURLKey;
CPFilePathErrorKey = kCFErrorFilePathKey;
CPUserCancelledError = 3072;
/*!
@class CPError
@ingroup foundation
+221 -29
View File
@@ -25,8 +25,10 @@
@import "CPData.j"
@import "CPDictionary.j"
@import "CPError.j"
@import "CPProgress.j"
@import "CPRunLoop.j"
@import "CPString.j"
@import "CPString+Encoding.j"
@global FileReader
@@ -36,18 +38,35 @@ CPItemProviderUnknownError = -1;
CPItemProviderItemUnavailableError = -1000;
CPItemProviderUnexpectedValueClassError = -1100;
var CPItemProviderChunkSize = 32768;
/*
Returns a binary string for an array buffer: one character for each byte, of the same value.
The buffer is converted in chunks, because a very long argument list exceeds the JavaScript
engine's limit.
CPData holds a binary string compactly, one byte for each character in current JavaScript
engines. Its byte array would hold one JavaScript number for each byte, many times the size,
because CFData stores bytes that way. File contents are given to CPData as a binary string.
*/
var CPItemProviderBinaryStringFromArrayBuffer = function(anArrayBuffer)
{
var bytes = new Uint8Array(anArrayBuffer),
chunks = [];
for (var i = 0, count = bytes.length; i < count; i += CPItemProviderChunkSize)
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CPItemProviderChunkSize)));
return chunks.join("");
};
/*
TODO: Gaps against NSItemProvider that can be filled:
- -loadDataRepresentationForTypeIdentifier:completionHandler: returns an NSProgress in Cocoa,
which reports reading progress and cancels the load. Foundation has no CPProgress yet.
Cancelling a file read would call FileReader's abort().
- Object loading: -canLoadObjectOfClass:, -loadObjectOfClass:completionHandler:,
-registerObject:visibility:, -initWithObject: and -initWithItem:typeIdentifier:, with the
reading and writing protocols. Loading a CPString this way could decode the browser's
buffer directly with TextDecoder.
- Files are read into a plain JavaScript array with one number per byte, because CFData
stores bytes that way. A large file needs many times its own size in memory.
- Writing: -registerObject:visibility:, -registerObjectOfClass:visibility:loadHandler:,
-initWithObject: and -initWithItem:typeIdentifier:, with a writing protocol.
- Loading a large file still holds it several times over at its peak: the browser's
buffer, the binary string in CPData, and the decoded text.
*/
/*!
@@ -73,15 +92,21 @@ CPItemProviderUnexpectedValueClassError = -1100;
- Representations have no visibility. A page is one process, so Cocoa's visibility levels
have no meaning.
Each load returns a CPProgress. For a file, it counts the bytes read. Cancelling it stops
the read, and the completion handler then receives a CPUserCancelledError.
A completion handler receives the data, or nil and an error:
<pre>
[anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
var progress = [anItemProvider loadDataRepresentationForTypeIdentifier:@"image/*" completionHandler:function(data, error)
{
if (data)
[self useData:data];
}];
</pre>
An object of a class that adopts the item provider reading methods, such as CPString, can
be loaded with -loadObjectOfClass:completionHandler:.
*/
@implementation CPItemProvider : CPObject
{
@@ -128,7 +153,7 @@ CPItemProviderUnexpectedValueClassError = -1100;
[itemProvider setSuggestedName:aFile.webkitRelativePath || aFile.name];
[itemProvider registerDataRepresentationForTypeIdentifier:typeIdentifier loadHandler:function(completionHandler)
{
[CPItemProvider _readFile:aFile completionHandler:completionHandler];
return [CPItemProvider _readFile:aFile completionHandler:completionHandler];
}];
return itemProvider;
@@ -151,22 +176,37 @@ CPItemProviderUnexpectedValueClassError = -1100;
/*!
Reads a browser File object with FileReader, and calls \c aCompletionHandler with its
contents as data, or with nil and an error.
contents as data, or with nil and an error. Returns a progress object that counts the bytes
read. Cancelling it aborts the read.
@ignore
*/
+ (void)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler
+ (CPProgress)_readFile:(File)aFile completionHandler:(Function)aCompletionHandler
{
var progress = [CPProgress discreteProgressWithTotalUnitCount:MAX(aFile.size || 0, 1)];
[progress setKind:CPProgressKindFile];
[progress setFileOperationKind:CPProgressFileOperationKindReceiving];
if (typeof FileReader === "undefined")
{
aCompletionHandler(nil, [self _errorWithCode:CPItemProviderItemUnavailableError description:@"Files cannot be read in this environment."]);
return;
return progress;
}
var reader = new FileReader();
reader.onprogress = function(anEvent)
{
if (anEvent.lengthComputable)
[progress setCompletedUnitCount:MIN(anEvent.loaded, [progress totalUnitCount] - 1)];
};
reader.onload = function()
{
aCompletionHandler([CPData dataWithBytes:Array.from(new Uint8Array(reader.result))], nil);
var data = [CPData dataWithRawString:CPItemProviderBinaryStringFromArrayBuffer(reader.result)];
[progress setCompletedUnitCount:[progress totalUnitCount]];
aCompletionHandler(data, nil);
};
reader.onerror = function()
@@ -179,7 +219,20 @@ CPItemProviderUnexpectedValueClassError = -1100;
aCompletionHandler(nil, [self _errorWithCode:CPItemProviderUnknownError description:description]);
};
reader.onabort = function()
{
aCompletionHandler(nil, [self _cancelledError]);
};
[progress setCancellationHandler:function()
{
if (reader.readyState === 1)
reader.abort();
}];
reader.readAsArrayBuffer(aFile);
return progress;
}
/*!
@@ -192,6 +245,16 @@ CPItemProviderUnexpectedValueClassError = -1100;
userInfo:@{ CPLocalizedDescriptionKey: aDescription }];
}
/*!
@ignore
*/
+ (CPError)_cancelledError
{
return [CPError errorWithDomain:CPCocoaErrorDomain
code:CPUserCancelledError
userInfo:@{ CPLocalizedDescriptionKey: @"The operation was cancelled." }];
}
/*!
Initializes an item provider with no representations.
*/
@@ -215,10 +278,12 @@ CPItemProviderUnexpectedValueClassError = -1100;
The load handler is called with one argument, a completion handler. It must call that
completion handler once, with the data, or with nil and an error. It may do so at once, or
later.
later. It may return a CPProgress for its work; the progress object returned by
-loadDataRepresentationForTypeIdentifier:completionHandler: then follows it, and cancelling
that progress object cancels it.
@param aTypeIdentifier a MIME type, such as "text/csv"
@param aLoadHandler a function that takes a completion handler
@param aLoadHandler a function that takes a completion handler, and may return a CPProgress
*/
- (void)registerDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier loadHandler:(Function /*(Function completionHandler)*/)aLoadHandler
{
@@ -276,14 +341,17 @@ CPItemProviderUnexpectedValueClassError = -1100;
/*!
Loads the first registered representation that matches \c aTypeIdentifier, which may be a
wildcard of the form "image/*". The completion handler is called once, inside a run loop
pass, with the data and nil, or with nil and an error.
pass, with the data and nil, or with nil and an error. When the returned progress object is
cancelled first, the error is a CPUserCancelledError.
@param aTypeIdentifier the type identifier to load
@param aCompletionHandler a function that takes the data and an error
@return a progress object for the load
*/
- (void)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler
- (CPProgress)loadDataRepresentationForTypeIdentifier:(CPString)aTypeIdentifier completionHandler:(Function /*(CPData data, CPError error)*/)aCompletionHandler
{
var registeredTypeIdentifier = [self _registeredTypeIdentifierConformingToTypeIdentifier:aTypeIdentifier],
progress = [CPProgress discreteProgressWithTotalUnitCount:1],
didComplete = NO,
complete = function(data, error)
{
@@ -292,8 +360,19 @@ CPItemProviderUnexpectedValueClassError = -1100;
didComplete = YES;
if ([progress isCancelled])
{
data = nil;
error = [CPItemProvider _cancelledError];
}
else if (!data && !error)
error = [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."];
if (data && ![progress isFinished])
[progress setCompletedUnitCount:1];
if (aCompletionHandler)
aCompletionHandler(data || nil, data ? nil : error || [CPItemProvider _errorWithCode:CPItemProviderUnknownError description:@"The item could not be loaded."]);
aCompletionHandler(data || nil, data ? nil : error);
[[CPRunLoop currentRunLoop] limitDateForMode:CPDefaultRunLoopMode];
};
@@ -301,21 +380,94 @@ CPItemProviderUnexpectedValueClassError = -1100;
if (!registeredTypeIdentifier)
{
complete(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation of type " + aTypeIdentifier + @"."]);
return;
return progress;
}
var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier];
loadHandler(function(data, error)
{
if (data && ![data isKindOfClass:[CPData class]])
var loadHandler = [_loadHandlers objectForKey:registeredTypeIdentifier],
loadProgress = loadHandler(function(data, error)
{
complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]);
if (data && ![data isKindOfClass:[CPData class]])
{
complete(nil, [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item's load handler returned an object that is not data."]);
return;
}
complete(data, error);
});
if (loadProgress && [loadProgress isKindOfClass:[CPProgress class]] && !didComplete)
[progress addChild:loadProgress withPendingUnitCount:1];
return progress;
}
// MARK: Loading Objects
/*!
Returns YES if an object of \c aClass can be loaded from the item. \c aClass must implement
+readableTypeIdentifiersForItemProvider, and the item must have a representation of one of
those types.
*/
- (BOOL)canLoadObjectOfClass:(Class)aClass
{
return [self _typeIdentifierReadableByClass:aClass] !== nil;
}
/*!
Loads an object of \c aClass from the item. The first representation of a type that the
class can read is loaded, and given to +objectWithItemProviderData:typeIdentifier:error:.
The completion handler is called once, inside a run loop pass, with the object and nil, or
with nil and an error.
@param aClass a class that implements the item provider reading methods, such as CPString
@param aCompletionHandler a function that takes the object and an error
@return a progress object for the load
*/
- (CPProgress)loadObjectOfClass:(Class)aClass completionHandler:(Function /*(id object, CPError error)*/)aCompletionHandler
{
var typeIdentifier = [self _typeIdentifierReadableByClass:aClass];
if (!typeIdentifier)
return [self loadDataRepresentationForTypeIdentifier:@"" completionHandler:function(data, error)
{
aCompletionHandler(nil, [CPItemProvider _errorWithCode:CPItemProviderItemUnavailableError description:@"The item has no representation that " + [aClass className] + @" can read."]);
}];
return [self loadDataRepresentationForTypeIdentifier:typeIdentifier completionHandler:function(data, error)
{
if (!data)
{
aCompletionHandler(nil, error);
return;
}
complete(data, error);
});
var readError = nil,
object = [aClass objectWithItemProviderData:data typeIdentifier:typeIdentifier error:@ref(readError)];
if (object === nil || object === undefined)
aCompletionHandler(nil, readError || [CPItemProvider _errorWithCode:CPItemProviderUnexpectedValueClassError description:@"The item could not be read as " + [aClass className] + @"."]);
else
aCompletionHandler(object, nil);
}];
}
/*!
Returns the first registered type identifier that \c aClass can read, or nil.
@ignore
*/
- (CPString)_typeIdentifierReadableByClass:(Class)aClass
{
if (![aClass respondsToSelector:@selector(readableTypeIdentifiersForItemProvider)])
return nil;
var readableTypeIdentifiers = [aClass readableTypeIdentifiersForItemProvider];
for (var i = 0, count = [_registeredTypeIdentifiers count]; i < count; i++)
for (var j = 0, readableCount = [readableTypeIdentifiers count]; j < readableCount; j++)
if ([CPItemProvider _typeIdentifier:_registeredTypeIdentifiers[i] conformsToTypeIdentifier:readableTypeIdentifiers[j]])
return _registeredTypeIdentifiers[i];
return nil;
}
/*!
@@ -327,3 +479,43 @@ CPItemProviderUnexpectedValueClassError = -1100;
}
@end
/*!
CPString reads text from an item provider. The encoding is detected, as by
+stringEncodingForData:encodingOptions:convertedString:usedLossyConversion:.
Browsers report some text formats under other MIME types; for example, a CSV file may be
reported as "application/vnd.ms-excel" on Windows. Such an item is not readable as a string
by this method, but its data can be loaded and decoded directly.
*/
@implementation CPString (CPItemProvider)
/*!
Returns the MIME types a string can be read from: every text type, JSON, XML and JavaScript.
*/
+ (CPArray)readableTypeIdentifiersForItemProvider
{
return [@"text/*", @"application/json", @"application/xml", @"application/javascript"];
}
/*!
Returns a string decoded from an item provider's data, or nil with an error when no
encoding fits the data.
*/
+ (id)objectWithItemProviderData:(CPData)someData typeIdentifier:(CPString)aTypeIdentifier error:(CPErrorRef)anErrorRef
{
var string = nil,
encoding = [CPString stringEncodingForData:someData encodingOptions:nil convertedString:@ref(string) usedLossyConversion:nil];
if (encoding)
return string;
if (anErrorRef)
@deref(anErrorRef) = [CPError errorWithDomain:CPItemProviderErrorDomain
code:CPItemProviderUnexpectedValueClassError
userInfo:@{ CPLocalizedDescriptionKey: @"The item's data is not text in a known encoding." }];
return nil;
}
@end
+683
View File
@@ -0,0 +1,683 @@
/*
* CPProgress.j
* Foundation
*
* Created by David Richardson on 2026-11-02.
* Copyright David Richardson 2026.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import "CPArray.j"
@import "CPDictionary.j"
@import "CPException.j"
@import "CPKeyValueObserving.j"
@import "CPObject.j"
@import "CPSet.j"
@import "CPString.j"
CPProgressKindFile = @"CPProgressKindFile";
CPProgressEstimatedTimeRemainingKey = @"CPProgressEstimatedTimeRemainingKey";
CPProgressThroughputKey = @"CPProgressThroughputKey";
CPProgressFileOperationKindKey = @"CPProgressFileOperationKindKey";
CPProgressFileURLKey = @"CPProgressFileURLKey";
CPProgressFileTotalCountKey = @"CPProgressFileTotalCountKey";
CPProgressFileCompletedCountKey = @"CPProgressFileCompletedCountKey";
CPProgressFileOperationKindDownloading = @"CPProgressFileOperationKindDownloading";
CPProgressFileOperationKindDecompressingAfterDownloading = @"CPProgressFileOperationKindDecompressingAfterDownloading";
CPProgressFileOperationKindReceiving = @"CPProgressFileOperationKindReceiving";
CPProgressFileOperationKindCopying = @"CPProgressFileOperationKindCopying";
CPProgressFileOperationKindUploading = @"CPProgressFileOperationKindUploading";
CPProgressFileOperationKindDuplicating = @"CPProgressFileOperationKindDuplicating";
/*
The keys whose values follow from the unit counts of a progress object and of its children.
They change together, so they are announced together. See -_performCountChange:.
*/
var CPProgressCountKeys = [@"totalUnitCount", @"completedUnitCount", @"fractionCompleted", @"finished", @"indeterminate", @"localizedDescription", @"localizedAdditionalDescription"],
CPProgressManuallyNotifiedKeys = [CPSet setWithArray:CPProgressCountKeys.concat([@"cancelled", @"paused", @"cancellable", @"pausable", @"kind", @"userInfo"])],
CPProgressCurrentStack = [];
/*
TODO: Gaps against NSProgress that can be filled:
- The fileURL, fileTotalCount and fileCompletedCount properties. Only their user
information keys exist.
- The NSProgressReporting protocol, with its progress property.
- The default descriptions. Cocoa's depend on the kind, and its additional description
includes the estimated time remaining and the throughput. These are the percentage and
the unit counts only.
- The file icon and file animation user information keys.
- Setting a cancellation or pausing handler on a progress object that is already cancelled
or paused. Check whether Cocoa calls the handler at once; this class does not.
- Key-value observing. Cocoa announces only the keys that change; this class announces
every unit-count key of each progress object in the chain.
*/
/*!
@class CPProgress
@ingroup foundation
@brief Reports the progress of a task, and lets the user cancel or pause it.
This class is modelled on NSProgress. A progress object counts units of work: a total, and
the units completed. Its fraction completed can be observed with key-value observing, and
shown in a progress indicator.
Progress objects form a tree. A child progress object stands for a number of its parent's
units, its pending unit count. As the child advances, the parent's fraction completed
advances in proportion. When the child finishes, its pending units are added to the
parent's completed units. A child is attached explicitly, with
-addChild:withPendingUnitCount:, or implicitly: a progress object made while its parent is
the current progress object, between -becomeCurrentWithPendingUnitCount: and
-resignCurrent, becomes its child.
Cancelling or pausing a progress object also cancels or pauses its children, and calls its
handler. A task checks -isCancelled, or sets a cancellation handler, to stop its work.
In a browser there is one thread, so handlers are called at once, on that thread. The
unit-count keys are announced together whenever a count changes in the tree, so an
observer may receive a notification for a key whose value did not change.
Publishing and subscribing (-publish, -unpublish, the subscriber methods and -isOld) are
not provided. They share file progress between processes, and a page is one process.
*/
@implementation CPProgress : CPObject
{
CPProgress _parent;
CPArray _children;
int _totalUnitCount;
int _completedUnitCount;
BOOL _cancellable;
BOOL _pausable;
BOOL _cancelled;
BOOL _paused;
Function _cancellationHandler @accessors(property=cancellationHandler);
Function _pausingHandler @accessors(property=pausingHandler);
Function _resumingHandler @accessors(property=resumingHandler);
CPString _localizedDescription;
CPString _localizedAdditionalDescription;
CPString _kind;
CPMutableDictionary _userInfo;
}
+ (BOOL)automaticallyNotifiesObserversForKey:(CPString)aKey
{
if ([CPProgressManuallyNotifiedKeys containsObject:aKey])
return NO;
return [super automaticallyNotifiesObserversForKey:aKey];
}
// MARK: Creating Progress Objects
/*!
Returns the progress object that is current, or nil. See -becomeCurrentWithPendingUnitCount:.
*/
+ (CPProgress)currentProgress
{
var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
return entry ? entry.progress : nil;
}
/*!
Returns a progress object with a total unit count. If a progress object is current, the new
one becomes its child.
*/
+ (CPProgress)progressWithTotalUnitCount:(int)aCount
{
var progress = [[self alloc] initWithParent:[self currentProgress] userInfo:nil];
[progress setTotalUnitCount:aCount];
return progress;
}
/*!
Returns a progress object with a total unit count and no parent, even if a progress object
is current.
*/
+ (CPProgress)discreteProgressWithTotalUnitCount:(int)aCount
{
var progress = [[self alloc] initWithParent:nil userInfo:nil];
[progress setTotalUnitCount:aCount];
return progress;
}
/*!
Returns a progress object with a total unit count, attached to \c aParent as a child that
stands for \c aPendingUnitCount of its units.
*/
+ (CPProgress)progressWithTotalUnitCount:(int)aCount parent:(CPProgress)aParent pendingUnitCount:(int)aPendingUnitCount
{
var progress = [self discreteProgressWithTotalUnitCount:aCount];
[aParent addChild:progress withPendingUnitCount:aPendingUnitCount];
return progress;
}
/*!
Initializes a progress object. If a progress object is current, the new one becomes its
child.
*/
- (id)init
{
return [self initWithParent:[CPProgress currentProgress] userInfo:nil];
}
/*!
Initializes a progress object. \c aParent must be nil or the current progress object; the
new progress object becomes its child, standing for the pending unit count given to
-becomeCurrentWithPendingUnitCount:. Only the first progress object made while a parent is
current becomes its child.
@throws CPInvalidArgumentException if \c aParent is not nil and not current
*/
- (id)initWithParent:(CPProgress)aParent userInfo:(CPDictionary)aUserInfo
{
if (self = [super init])
{
_children = [];
_totalUnitCount = 0;
_completedUnitCount = 0;
_cancellable = YES;
_pausable = NO;
_cancelled = NO;
_paused = NO;
_userInfo = aUserInfo ? [aUserInfo mutableCopy] : [CPMutableDictionary dictionary];
if (aParent)
{
var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
if (!entry || entry.progress !== aParent)
[CPException raise:CPInvalidArgumentException reason:@"The parent of a progress object must be nil or the current progress object."];
if (!entry.hasAttachedChild)
{
entry.hasAttachedChild = YES;
[aParent addChild:self withPendingUnitCount:entry.pendingUnitCount];
}
}
}
return self;
}
// MARK: The Current Progress Object
/*!
Makes the receiver the current progress object. The first progress object made before
-resignCurrent becomes its child, standing for \c aPendingUnitCount of its units.
*/
- (void)becomeCurrentWithPendingUnitCount:(int)aPendingUnitCount
{
CPProgressCurrentStack.push({ progress: self, pendingUnitCount: aPendingUnitCount, hasAttachedChild: NO });
}
/*!
Ends the receiver's time as the current progress object. If no child was made meanwhile,
the pending units are completed.
@throws CPInternalInconsistencyException if the receiver is not current
*/
- (void)resignCurrent
{
var entry = CPProgressCurrentStack[CPProgressCurrentStack.length - 1];
if (!entry || entry.progress !== self)
[CPException raise:CPInternalInconsistencyException reason:@"-resignCurrent was sent to a progress object that is not current."];
CPProgressCurrentStack.pop();
if (!entry.hasAttachedChild)
[self setCompletedUnitCount:_completedUnitCount + entry.pendingUnitCount];
}
/*!
Makes the receiver current, calls \c aBlock, and resigns.
*/
- (void)performAsCurrentWithPendingUnitCount:(int)aPendingUnitCount usingBlock:(Function)aBlock
{
[self becomeCurrentWithPendingUnitCount:aPendingUnitCount];
try
{
aBlock();
}
finally
{
[self resignCurrent];
}
}
// MARK: Children
/*!
Attaches \c aChild, which stands for \c aPendingUnitCount of the receiver's units. A child
attached to a cancelled or paused progress object is cancelled or paused at once.
@throws CPInvalidArgumentException if \c aChild already has a parent
*/
- (void)addChild:(CPProgress)aChild withPendingUnitCount:(int)aPendingUnitCount
{
if (aChild._parent)
[CPException raise:CPInvalidArgumentException reason:@"A progress object can have only one parent."];
[self _performCountChange:function()
{
aChild._parent = self;
_children.push({ progress: aChild, pendingUnitCount: aPendingUnitCount });
[aChild _finishIfComplete];
}];
if (_cancelled)
[aChild cancel];
if (_paused)
[aChild pause];
}
// MARK: Unit Counts
/*!
Returns the total number of units of work.
*/
- (int)totalUnitCount
{
return _totalUnitCount;
}
/*!
Sets the total number of units of work. A negative total makes the progress indeterminate.
*/
- (void)setTotalUnitCount:(int)aCount
{
if (aCount === _totalUnitCount)
return;
[self _performCountChange:function()
{
_totalUnitCount = aCount;
[self _finishIfComplete];
}];
}
/*!
Returns the number of units of work completed.
*/
- (int)completedUnitCount
{
return _completedUnitCount;
}
/*!
Sets the number of units of work completed. When it reaches the total, a parent completes
the units this progress object stands for.
*/
- (void)setCompletedUnitCount:(int)aCount
{
if (aCount === _completedUnitCount)
return;
[self _performCountChange:function()
{
_completedUnitCount = aCount;
[self _finishIfComplete];
}];
}
/*!
Returns the fraction of the work completed, including the progress of children that have
not finished, from 0.0. It is 0.0 while the progress is indeterminate.
*/
- (double)fractionCompleted
{
if (_totalUnitCount <= 0)
return 0.0;
var units = _completedUnitCount;
for (var i = 0; i < _children.length; i++)
units += [_children[i].progress fractionCompleted] * _children[i].pendingUnitCount;
return units / _totalUnitCount;
}
/*!
Returns YES when the work is complete: the total is positive, and the completed units have
reached it.
*/
- (BOOL)isFinished
{
return _totalUnitCount > 0 && _completedUnitCount >= _totalUnitCount;
}
/*!
Returns YES when the amount of work is unknown: either count is negative, or both are 0.
*/
- (BOOL)isIndeterminate
{
return _totalUnitCount < 0 || _completedUnitCount < 0 || (_totalUnitCount === 0 && _completedUnitCount === 0);
}
/*
Changes unit counts in the tree. Every count key of the receiver and of each of its
ancestors is announced before and after the change, so that an observer of any of them sees
a consistent value. A parent's counts and fraction depend on its children, so a change
anywhere below reaches every ancestor.
*/
- (void)_performCountChange:(Function)aChange
{
var chain = [];
for (var progress = self; progress; progress = progress._parent)
chain.push(progress);
for (var i = 0; i < chain.length; i++)
for (var j = 0; j < CPProgressCountKeys.length; j++)
[chain[i] willChangeValueForKey:CPProgressCountKeys[j]];
aChange();
for (var i = chain.length - 1; i >= 0; i--)
for (var j = CPProgressCountKeys.length - 1; j >= 0; j--)
[chain[i] didChangeValueForKey:CPProgressCountKeys[j]];
}
/*
When the receiver has finished, its parent removes it and completes the units it stood for.
That may finish the parent in turn. Called inside -_performCountChange:, which has already
announced the change for every ancestor.
*/
- (void)_finishIfComplete
{
var parent = _parent;
if (![self isFinished] || !parent)
return;
for (var i = 0; i < parent._children.length; i++)
{
var entry = parent._children[i];
if (entry.progress !== self)
continue;
parent._children.splice(i, 1);
parent._completedUnitCount += entry.pendingUnitCount;
_parent = nil;
[parent _finishIfComplete];
return;
}
}
// MARK: Cancelling and Pausing
/*!
Returns YES if the user may cancel the work. This is a hint for a user interface: -cancel
works either way. The default is YES.
*/
- (BOOL)isCancellable
{
return _cancellable;
}
- (void)setCancellable:(BOOL)isCancellable
{
[self _setValue:!!isCancellable forIvar:@"_cancellable" key:@"cancellable"];
}
/*!
Returns YES if the user may pause the work. This is a hint for a user interface. The
default is NO.
*/
- (BOOL)isPausable
{
return _pausable;
}
- (void)setPausable:(BOOL)isPausable
{
[self _setValue:!!isPausable forIvar:@"_pausable" key:@"pausable"];
}
/*!
Returns YES once the work has been cancelled.
*/
- (BOOL)isCancelled
{
return _cancelled;
}
/*!
Returns YES while the work is paused.
*/
- (BOOL)isPaused
{
return _paused;
}
/*!
Cancels the work: marks the receiver cancelled, calls its cancellation handler, and cancels
its children. Cancelling again does nothing.
*/
- (void)cancel
{
if (_cancelled)
return;
[self _setValue:YES forIvar:@"_cancelled" key:@"cancelled"];
if (_cancellationHandler)
_cancellationHandler();
var children = _children.slice();
for (var i = 0; i < children.length; i++)
[children[i].progress cancel];
}
/*!
Pauses the work: marks the receiver paused, calls its pausing handler, and pauses its
children.
*/
- (void)pause
{
if (_paused)
return;
[self _setValue:YES forIvar:@"_paused" key:@"paused"];
if (_pausingHandler)
_pausingHandler();
var children = _children.slice();
for (var i = 0; i < children.length; i++)
[children[i].progress pause];
}
/*!
Resumes paused work: marks the receiver not paused, calls its resuming handler, and resumes
its children.
*/
- (void)resume
{
if (!_paused)
return;
[self _setValue:NO forIvar:@"_paused" key:@"paused"];
if (_resumingHandler)
_resumingHandler();
var children = _children.slice();
for (var i = 0; i < children.length; i++)
[children[i].progress resume];
}
// MARK: Descriptions
/*!
Returns a description of the progress. Unless one has been set, it is the percentage
completed, such as "42% completed".
*/
- (CPString)localizedDescription
{
if (_localizedDescription !== nil && _localizedDescription !== undefined)
return _localizedDescription;
return ROUND([self fractionCompleted] * 100) + @"% completed";
}
- (void)setLocalizedDescription:(CPString)aDescription
{
[self _setValue:aDescription forIvar:@"_localizedDescription" key:@"localizedDescription"];
}
/*!
Returns more detail about the progress. Unless one has been set, it is the units completed
and the total, such as "42 of 100".
*/
- (CPString)localizedAdditionalDescription
{
if (_localizedAdditionalDescription !== nil && _localizedAdditionalDescription !== undefined)
return _localizedAdditionalDescription;
if ([self isIndeterminate])
return @"";
return _completedUnitCount + @" of " + _totalUnitCount;
}
- (void)setLocalizedAdditionalDescription:(CPString)aDescription
{
[self _setValue:aDescription forIvar:@"_localizedAdditionalDescription" key:@"localizedAdditionalDescription"];
}
/*!
Returns the kind of progress, such as CPProgressKindFile, or nil.
*/
- (CPString)kind
{
return _kind;
}
- (void)setKind:(CPString)aKind
{
[self _setValue:aKind forIvar:@"_kind" key:@"kind"];
}
// MARK: User Information
/*!
Returns a copy of the receiver's user information.
*/
- (CPDictionary)userInfo
{
return [_userInfo copy];
}
/*!
Sets a value in the receiver's user information, or removes it when \c anObject is nil.
*/
- (void)setUserInfoObject:(id)anObject forKey:(CPString)aKey
{
[self willChangeValueForKey:@"userInfo"];
if (anObject === nil || anObject === undefined)
[_userInfo removeObjectForKey:aKey];
else
[_userInfo setObject:anObject forKey:aKey];
[self didChangeValueForKey:@"userInfo"];
}
/*!
Returns the estimated time remaining, in seconds, or nil. It is stored in the user
information under CPProgressEstimatedTimeRemainingKey.
*/
- (CPNumber)estimatedTimeRemaining
{
return [_userInfo objectForKey:CPProgressEstimatedTimeRemainingKey];
}
- (void)setEstimatedTimeRemaining:(CPNumber)aTime
{
[self setUserInfoObject:aTime forKey:CPProgressEstimatedTimeRemainingKey];
}
/*!
Returns the throughput, in units per second, or nil. It is stored in the user information
under CPProgressThroughputKey.
*/
- (CPNumber)throughput
{
return [_userInfo objectForKey:CPProgressThroughputKey];
}
- (void)setThroughput:(CPNumber)aThroughput
{
[self setUserInfoObject:aThroughput forKey:CPProgressThroughputKey];
}
/*!
Returns the kind of file operation, such as CPProgressFileOperationKindDownloading, or nil.
It is stored in the user information under CPProgressFileOperationKindKey.
*/
- (CPString)fileOperationKind
{
return [_userInfo objectForKey:CPProgressFileOperationKindKey];
}
- (void)setFileOperationKind:(CPString)aKind
{
[self setUserInfoObject:aKind forKey:CPProgressFileOperationKindKey];
}
/*
Sets an instance variable and announces the change of its key, when the value changes.
*/
- (void)_setValue:(id)aValue forIvar:(CPString)anIvarName key:(CPString)aKey
{
if (self[anIvarName] === aValue)
return;
[self willChangeValueForKey:aKey];
self[anIvarName] = aValue;
[self didChangeValueForKey:aKey];
}
/*!
Returns a description of the progress object: its counts and fraction completed.
*/
- (CPString)description
{
return [super description] + @" " + _completedUnitCount + @"/" + _totalUnitCount + @" (" + [self fractionCompleted] + @")" + (_cancelled ? @" cancelled" : @"") + (_paused ? @" paused" : @"");
}
@end
+42 -5
View File
@@ -117,7 +117,7 @@ var CPStringEncodingDecodeWithTextDecoder = function(aLabel, bytes, isLossy)
try
{
return new TextDecoder(aLabel, { fatal: !isLossy }).decode(new Uint8Array(bytes));
return new TextDecoder(aLabel, { fatal: !isLossy }).decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
}
catch (anException)
{
@@ -142,7 +142,7 @@ var CPStringEncodingDecodeASCII = function(bytes, isLossy)
if (!isLossy)
return nil;
bytes = bytes.map(function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; });
bytes = Array.prototype.map.call(bytes, function(aByte) { return aByte > 0x7F ? CPStringEncodingReplacementCharacter : aByte; });
break;
}
@@ -705,6 +705,38 @@ var CPStringEncodingIsAvailable = function(aDescriptor)
}
};
/*
Returns the bytes of data as an array of bytes. Data that holds a binary string, such as
file contents from CPItemProvider, is converted to a Uint8Array, which takes one byte for
each byte. Asking CPData for its bytes would build a plain array of JavaScript numbers, many
times the size, because CFData stores bytes that way, and keep it in the data.
*/
var CPStringEncodingBytesOfData = function(someData)
{
if (!someData)
return [];
if (someData._bytes)
return someData._bytes;
var string = someData.rawString(),
count = string.length,
bytes = new Uint8Array(count);
for (var i = 0; i < count; i++)
bytes[i] = string.charCodeAt(i) & 0xFF;
return bytes;
};
/*
Returns the bytes from anIndex to the end, without copying when the bytes are a Uint8Array.
*/
var CPStringEncodingBytesFromIndex = function(bytes, anIndex)
{
return bytes.subarray ? bytes.subarray(anIndex) : bytes.slice(anIndex);
};
var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
{
var descriptor = CPStringEncodingDescriptors[anEncoding];
@@ -807,7 +839,12 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
*/
- (id)initWithData:(CPData)someData encoding:(CPStringEncoding)anEncoding
{
return [self initWithBytes:someData ? [someData bytes] : [] length:someData ? [someData length] : 0 encoding:anEncoding];
var decoded = CPStringEncodingDecode(CPStringEncodingBytesOfData(someData), anEncoding, NO);
if (decoded === nil)
return nil;
return [self initWithString:decoded];
}
/*!
@@ -856,7 +893,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
*/
+ (CPStringEncoding)stringEncodingForData:(CPData)someData encodingOptions:(CPDictionary)someOptions convertedString:(CPStringRef)aStringRef usedLossyConversion:(BOOLRef)aLossyRef
{
var bytes = someData ? [someData bytes] : [],
var bytes = CPStringEncodingBytesOfData(someData),
suggested = [someOptions objectForKey:CPStringEncodingDetectionSuggestedEncodingsKey] || [],
usesOnlySuggested = !![someOptions objectForKey:CPStringEncodingDetectionUseOnlySuggestedEncodingsKey],
allowLossy = [someOptions objectForKey:CPStringEncodingDetectionAllowLossyKey],
@@ -887,7 +924,7 @@ var CPStringEncodingDecode = function(bytes, anEncoding, isLossy)
if (bytes.length >= mark.length && mark.every(function(aByte, anIndex) { return bytes[anIndex] === aByte; }))
{
var decoded = CPStringEncodingDecode(bytes.slice(mark.length), byteOrderMarks[i][1], NO);
var decoded = CPStringEncodingDecode(CPStringEncodingBytesFromIndex(bytes, mark.length), byteOrderMarks[i][1], NO);
if (decoded !== nil)
return result(byteOrderMarks[i][1], decoded, NO);
+1
View File
@@ -65,6 +65,7 @@
@import "CPOperation.j"
@import "CPOperationQueue.j"
@import "CPPredicate.j"
@import "CPProgress.j"
@import "CPPropertyListSerialization.j"
@import "CPRange.j"
@import "CPRunLoop.j"
+91
View File
@@ -216,4 +216,95 @@
[self assert:@"a.png" equals:[providers[1] suggestedName]];
}
/*
Installs a FileReader stand-in that reads nothing until the test drives it, and returns an
object through which the test reaches the reader.
*/
- (JSObject)installPendingFileReader
{
var state = { reader: nil, aborted: NO };
globalThis.FileReader = function() { state.reader = this; this.readyState = 0; };
globalThis.FileReader.prototype.readAsArrayBuffer = function(aFile) { this.readyState = 1; };
globalThis.FileReader.prototype.abort = function()
{
state.aborted = YES;
this.readyState = 2;
this.onabort();
};
return state;
}
- (void)testFileProgressAndCancellation
{
var state = [self installPendingFileReader],
provider = [CPItemProvider _itemProviderWithFile:{ name: "big.csv", type: "text/csv", size: 1000 }],
resultError = nil,
calls = 0,
progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError)
{
calls++;
resultError = anError;
}];
state.reader.onprogress({ lengthComputable: YES, loaded: 250 });
[self assert:0.25 equals:[progress fractionCompleted]];
[progress cancel];
[self assertTrue:state.aborted];
[self assert:1 equals:calls];
[self assert:CPCocoaErrorDomain equals:[resultError domain]];
[self assert:CPUserCancelledError equals:[resultError code]];
}
- (void)testFileProgressFinishes
{
var state = [self installPendingFileReader],
provider = [CPItemProvider _itemProviderWithFile:{ name: "small.csv", type: "text/csv", size: 2 }],
result = nil,
progress = [provider loadDataRepresentationForTypeIdentifier:@"text/csv" completionHandler:function(someData, anError)
{
result = someData;
}];
state.reader.result = new Uint8Array([104, 105]).buffer;
state.reader.onload();
[self assertTrue:[progress isFinished]];
[self assert:@"hi" equals:[result rawString]];
}
- (void)testLoadObjectOfClassString
{
var provider = [self providerWithType:@"text/csv" data:[CPData dataWithRawString:@"a,b"]],
result = nil;
[self assertTrue:[provider canLoadObjectOfClass:[CPString class]]];
[provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError)
{
result = anObject;
}];
[self assert:@"a,b" equals:result];
}
- (void)testCannotLoadStringFromImage
{
var provider = [self providerWithType:@"image/png" data:[CPData dataWithRawString:@"x"]],
resultError = nil;
[self assertFalse:[provider canLoadObjectOfClass:[CPString class]]];
[provider loadObjectOfClass:[CPString class] completionHandler:function(anObject, anError)
{
resultError = anError;
}];
[self assert:CPItemProviderItemUnavailableError equals:[resultError code]];
}
@end
+235
View File
@@ -0,0 +1,235 @@
/*
* CPProgressTest.j
* Foundation
*
* Created by David Richardson on 2026-11-02.
* Copyright David Richardson 2026.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
@import <Foundation/CPKeyValueObserving.j>
@import <Foundation/CPProgress.j>
@implementation CPProgressTestObserver : CPObject
{
CPArray values;
}
- (id)init
{
if (self = [super init])
values = [];
return self;
}
- (CPArray)values
{
return values;
}
- (void)observeValueForKeyPath:(CPString)aKeyPath ofObject:(id)anObject change:(CPDictionary)aChange context:(id)aContext
{
values.push([aChange objectForKey:CPKeyValueChangeNewKey]);
}
@end
@implementation CPProgressTest : OJTestCase
{
}
- (void)testCounts
{
var progress = [CPProgress discreteProgressWithTotalUnitCount:10];
[self assertTrue:[progress isCancellable]];
[self assertFalse:[progress isPausable]];
[self assert:0.0 equals:[progress fractionCompleted]];
[progress setCompletedUnitCount:4];
[self assert:0.4 equals:[progress fractionCompleted]];
[self assertFalse:[progress isFinished]];
[self assert:@"40% completed" equals:[progress localizedDescription]];
[self assert:@"4 of 10" equals:[progress localizedAdditionalDescription]];
[progress setCompletedUnitCount:10];
[self assertTrue:[progress isFinished]];
}
- (void)testIndeterminate
{
var progress = [CPProgress discreteProgressWithTotalUnitCount:0];
[self assertTrue:[progress isIndeterminate]];
[progress setTotalUnitCount:-1];
[progress setCompletedUnitCount:3];
[self assertTrue:[progress isIndeterminate]];
[self assert:0.0 equals:[progress fractionCompleted]];
[self assertFalse:[progress isFinished]];
[progress setTotalUnitCount:6];
[self assertFalse:[progress isIndeterminate]];
[self assert:0.5 equals:[progress fractionCompleted]];
}
- (void)testExplicitChild
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:100],
child = [CPProgress progressWithTotalUnitCount:4 parent:parent pendingUnitCount:50];
[child setCompletedUnitCount:2];
[self assert:0.25 equals:[parent fractionCompleted]];
[self assert:0 equals:[parent completedUnitCount]];
[child setCompletedUnitCount:4];
[self assert:50 equals:[parent completedUnitCount]];
[self assert:0.5 equals:[parent fractionCompleted]];
}
- (void)testFinishedChildFinishesAncestors
{
var root = [CPProgress discreteProgressWithTotalUnitCount:1],
middle = [CPProgress progressWithTotalUnitCount:1 parent:root pendingUnitCount:1],
leaf = [CPProgress progressWithTotalUnitCount:2 parent:middle pendingUnitCount:1];
[leaf setCompletedUnitCount:2];
[self assertTrue:[middle isFinished]];
[self assertTrue:[root isFinished]];
[self assert:1.0 equals:[root fractionCompleted]];
}
- (void)testImplicitChild
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:10];
[parent becomeCurrentWithPendingUnitCount:6];
var child = [CPProgress progressWithTotalUnitCount:2],
second = [CPProgress progressWithTotalUnitCount:2];
[parent resignCurrent];
[self assertNull:[CPProgress currentProgress]];
[child setCompletedUnitCount:1];
[second setCompletedUnitCount:2];
[self assert:0.3 equals:[parent fractionCompleted]];
}
- (void)testResignWithoutChildCompletesPendingUnits
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:10];
[parent performAsCurrentWithPendingUnitCount:3 usingBlock:function() {}];
[self assert:3 equals:[parent completedUnitCount]];
}
- (void)testParentMustBeCurrent
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:10];
[self assertThrows:function()
{
[[CPProgress alloc] initWithParent:parent userInfo:nil];
}];
[self assertThrows:function()
{
[parent resignCurrent];
}];
}
- (void)testCancellationReachesChildren
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:10],
child = [CPProgress progressWithTotalUnitCount:10 parent:parent pendingUnitCount:5],
calls = 0;
[child setCancellationHandler:function() { calls++; }];
[parent cancel];
[parent cancel];
[self assertTrue:[parent isCancelled]];
[self assertTrue:[child isCancelled]];
[self assert:1 equals:calls];
var late = [CPProgress progressWithTotalUnitCount:1 parent:parent pendingUnitCount:1];
[self assertTrue:[late isCancelled]];
}
- (void)testPausingAndResuming
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:10],
child = [CPProgress progressWithTotalUnitCount:10 parent:parent pendingUnitCount:5],
events = [];
[child setPausingHandler:function() { events.push(@"pause"); }];
[child setResumingHandler:function() { events.push(@"resume"); }];
[parent pause];
[self assertTrue:[child isPaused]];
[parent resume];
[self assertFalse:[child isPaused]];
[self assert:[@"pause", @"resume"] equals:events];
}
- (void)testObservingFractionCompletedThroughChild
{
var parent = [CPProgress discreteProgressWithTotalUnitCount:2],
child = [CPProgress progressWithTotalUnitCount:4 parent:parent pendingUnitCount:2],
observer = [[CPProgressTestObserver alloc] init];
[parent addObserver:observer forKeyPath:@"fractionCompleted" options:CPKeyValueObservingOptionNew context:nil];
[child setCompletedUnitCount:1];
[child setCompletedUnitCount:4];
[parent removeObserver:observer forKeyPath:@"fractionCompleted"];
[self assert:[0.25, 1.0] equals:[observer values]];
}
- (void)testUserInfo
{
var progress = [CPProgress discreteProgressWithTotalUnitCount:1];
[progress setThroughput:12];
[progress setFileOperationKind:CPProgressFileOperationKindReceiving];
[self assert:12 equals:[progress throughput]];
[self assert:CPProgressFileOperationKindReceiving equals:[[progress userInfo] objectForKey:CPProgressFileOperationKindKey]];
[progress setThroughput:nil];
[self assertNull:[progress throughput]];
}
@end