mirror of
https://github.com/cappuccino/cappuccino.git
synced 2026-11-03 13:49:45 +00:00
Keyed archiving preserves view hierarchies created in code. Decoded views retain their content, subviews and state, and each view class is verified against its own description before archiving. * CPAccordionView: Add CPCoding to CPAccordionView and CPAccordionViewItem. * CPBox: * Encode the content, title and box views unconditionally. * Clear the superviews of the box and title views during decoding. * CPButton: Initialize the value to CPOffState. * CPMapTable: Add CPCoding. * CPPopUpButton: Clear the inherited button value. * CPSearchField: Update the cancel button when the object value changes. * CPSplitView: Return nil from dividerColor when the theme defines no divider image. * CPStepper: Discard the decoded buttons before recreating them. * CPTabView: * Encode the current tab view items. * Exclude the tabs from the encoded subviews. * CPTokenField: * Discard the decoded scroll view before recreating it. * Add the decoded tokens to the new document view. * CPVisualEffectView: Resolve the backdrop filter property only when a DOM is available. * CPBoxTest: Verify preservation of the content view, hierarchy and title, and decoding of an untitled box without warnings. * CPButtonTest: Verify the initial value of a button. * CPMapTableTest: Verify preservation of entries through archiving. * DropInArchiveFixtures: Add populated view fixtures, tree comparison, and round trip and saved archive helpers. * DropInArchiveGenerator: Save cib descriptions and view archives, and write no archive data if any view fails its round trip. * DropInArchiveTest: Verify new archives, saved archives and reference cibs, and treat decoding warnings as failures. * DropInSnapshot: * Describe the view hierarchy through private views, with state and content. * Add DropInWithFixedScrollerStyle. * DropInArchives, DropInCibs: Add saved view archives, cib descriptions and reference cibs.
506 lines
14 KiB
Plaintext
506 lines
14 KiB
Plaintext
/*
|
|
* DropInSnapshot.j
|
|
* AppKit
|
|
*
|
|
* Created by David Richardson on 31/10/2026.
|
|
* 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 <AppKit/AppKit.j>
|
|
|
|
/*
|
|
An application that updates its frameworks relies on more than documented
|
|
behavior. It relies on the defaults a class supplies when no theme overrides
|
|
them, on the public methods it calls and overrides, and on the state a control
|
|
has when it is created. Together these form the drop-in contract.
|
|
|
|
This file records that contract as plain data. DropInBaselineGenerator.j saves
|
|
a snapshot as the baseline, and DropInBaselineTest.j compares the running
|
|
frameworks with it. Additions are allowed. Changes and removals are not.
|
|
*/
|
|
|
|
var DropInConstructionGetters = [
|
|
@"acceptsFirstResponder",
|
|
@"alignment",
|
|
@"autoresizingMask",
|
|
@"backgroundColor",
|
|
@"bezelStyle",
|
|
@"controlSize",
|
|
@"drawsBackground",
|
|
@"isBordered",
|
|
@"isEditable",
|
|
@"isEnabled",
|
|
@"isHidden",
|
|
@"isSelectable",
|
|
@"lineBreakMode",
|
|
@"verticalAlignment"
|
|
],
|
|
|
|
DropInWindowStyles = {
|
|
@"borderless": CPBorderlessWindowMask,
|
|
@"titled": CPTitledWindowMask,
|
|
@"closable": CPClosableWindowMask,
|
|
@"resizable": CPClosableWindowMask | CPResizableWindowMask,
|
|
@"miniaturizable": CPClosableWindowMask | CPMiniaturizableWindowMask,
|
|
@"full": CPClosableWindowMask | CPMiniaturizableWindowMask | CPResizableWindowMask,
|
|
@"hud": CPHUDBackgroundWindowMask,
|
|
@"hudClosable": CPHUDBackgroundWindowMask | CPClosableWindowMask
|
|
};
|
|
|
|
/*
|
|
The getters that report a BOOL, and the getters that report a view's content.
|
|
*/
|
|
var DropInBooleanGetters = [
|
|
@"acceptsFirstResponder",
|
|
@"drawsBackground",
|
|
@"isBordered",
|
|
@"isEditable",
|
|
@"isEnabled",
|
|
@"isHidden",
|
|
@"isSelectable"
|
|
],
|
|
|
|
DropInContentGetters = [
|
|
@"color",
|
|
@"doubleValue",
|
|
@"indexOfSelectedItem",
|
|
@"maxValue",
|
|
@"minValue",
|
|
@"numberOfColumns",
|
|
@"numberOfItems",
|
|
@"numberOfTabViewItems",
|
|
@"objectValue",
|
|
@"segmentCount",
|
|
@"state",
|
|
@"string",
|
|
@"stringValue",
|
|
@"tag",
|
|
@"title"
|
|
];
|
|
|
|
function DropInIsPublicName(aName)
|
|
{
|
|
return aName.charAt(0) !== "_" && aName.charAt(0) !== "$";
|
|
}
|
|
|
|
function DropInSerializeObject(val, seen)
|
|
{
|
|
if (val === null || val === undefined)
|
|
return nil;
|
|
|
|
if (typeof val === "function")
|
|
return "[Function]";
|
|
|
|
if (typeof val !== "object")
|
|
return val;
|
|
|
|
if (seen.indexOf(val) !== -1)
|
|
return "[Circular]";
|
|
|
|
seen.push(val);
|
|
|
|
var result;
|
|
|
|
if (Array.isArray(val))
|
|
{
|
|
result = [];
|
|
for (var i = 0; i < val.length; i++)
|
|
{
|
|
result[i] = DropInSerializeObject(val[i], seen);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
result = {};
|
|
var keys = Object.keys(val).sort();
|
|
for (var i = 0; i < keys.length; i++)
|
|
{
|
|
var k = keys[i];
|
|
result[k] = DropInSerializeObject(val[k], seen);
|
|
}
|
|
}
|
|
|
|
seen.pop();
|
|
|
|
return result;
|
|
}
|
|
|
|
function DropInSafeJSONStringify(aValue)
|
|
{
|
|
return JSON.stringify(DropInSerializeObject(aValue, []));
|
|
}
|
|
|
|
/*
|
|
Reduces a value to a string that does not depend on object identity, so that
|
|
two runs of the same framework produce the same snapshot.
|
|
*/
|
|
function DropInDescribeValue(aValue)
|
|
{
|
|
if (aValue === nil || aValue === undefined)
|
|
return "nil";
|
|
|
|
/*
|
|
Booleans, numbers and strings answer to isa as well, so they must be handled
|
|
first, or they are recorded by class and their values are never compared.
|
|
*/
|
|
if (typeof aValue === "boolean" || typeof aValue === "number" || typeof aValue === "string")
|
|
return String(aValue);
|
|
|
|
if (aValue === [CPNull null])
|
|
return "CPNull";
|
|
|
|
if (aValue.isa)
|
|
{
|
|
if ([aValue isKindOfClass:[CPColor class]])
|
|
return "CPColor:" + [aValue cssString];
|
|
|
|
/*
|
|
The system font is a global setting that applications and tests change,
|
|
so a default that uses it records only that fact.
|
|
*/
|
|
if ([aValue isKindOfClass:[CPFont class]])
|
|
return [aValue isSystem] ? "CPFont:system" + ([aValue isBold] ? " bold" : "") : "CPFont:" + [aValue cssString];
|
|
|
|
if ([aValue isKindOfClass:[CPArray class]])
|
|
return "[" + aValue.map(DropInDescribeValue).join(", ") + "]";
|
|
|
|
return aValue.isa.name;
|
|
}
|
|
|
|
return DropInSafeJSONStringify(aValue);
|
|
}
|
|
|
|
function DropInMethodNames(aClass)
|
|
{
|
|
return class_copyMethodList(aClass).map(function(aMethod)
|
|
{
|
|
return method_getName(aMethod);
|
|
}).filter(DropInIsPublicName).sort();
|
|
}
|
|
|
|
function DropInThemeAttributes(aClass)
|
|
{
|
|
if (class_copyMethodList(aClass.isa).map(method_getName).indexOf("themeAttributes") === -1)
|
|
return nil;
|
|
|
|
var attributes = [aClass themeAttributes];
|
|
|
|
if (!attributes)
|
|
return nil;
|
|
|
|
var result = {},
|
|
keys = [[attributes allKeys] sortedArrayUsingSelector:@selector(compare:)];
|
|
|
|
keys.forEach(function(aKey)
|
|
{
|
|
result[aKey] = DropInDescribeValue([attributes objectForKey:aKey]);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function DropInSubviewClasses(aView)
|
|
{
|
|
return [aView subviews].map(function(aSubview)
|
|
{
|
|
return aSubview.isa.name;
|
|
}).filter(DropInIsPublicName).sort();
|
|
}
|
|
|
|
function DropInViewState(aView, throwIfError)
|
|
{
|
|
var result = {
|
|
@"frameSize": DropInDescribeValue([aView frameSize]),
|
|
@"subviews": DropInSubviewClasses(aView)
|
|
};
|
|
|
|
DropInConstructionGetters.forEach(function(aGetter)
|
|
{
|
|
var selector = CPSelectorFromString(aGetter);
|
|
|
|
if ([aView respondsToSelector:selector])
|
|
{
|
|
try
|
|
{
|
|
result[aGetter] = DropInDescribeValue([aView performSelector:selector]);
|
|
}
|
|
catch (anException)
|
|
{
|
|
if (throwIfError)
|
|
throw anException;
|
|
|
|
result[aGetter] = "[Exception: " + String(anException) + "]";
|
|
}
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function DropInDefaultConstruction(aClass, throwIfError)
|
|
{
|
|
return DropInViewState([[aClass alloc] initWithFrame:CGRectMake(0.0, 0.0, 100.0, 100.0)], throwIfError);
|
|
}
|
|
|
|
/*
|
|
Lists the public views below a view, in order. A private view is the class's own
|
|
concern and is left out, but the public views inside it are the view's content.
|
|
An observed view has a generated isa, so its name comes from its class method.
|
|
*/
|
|
function DropInPublicSubviews(aView)
|
|
{
|
|
return [aView subviews].reduce(function(aList, aSubview)
|
|
{
|
|
return aList.concat(DropInIsPublicName([aSubview className]) ? [aSubview] : DropInPublicSubviews(aSubview));
|
|
}, []);
|
|
}
|
|
|
|
/*
|
|
Describes the state of one view. An unset BOOL is nil and means NO, so a BOOL is
|
|
recorded by its truth value.
|
|
*/
|
|
function DropInViewTreeState(aView)
|
|
{
|
|
var state = {};
|
|
|
|
DropInConstructionGetters.concat(DropInContentGetters).forEach(function(aGetter)
|
|
{
|
|
var selector = CPSelectorFromString(aGetter);
|
|
|
|
if (![aView respondsToSelector:selector])
|
|
return;
|
|
|
|
var value = [aView performSelector:selector];
|
|
|
|
state[aGetter] = DropInDescribeValue(DropInBooleanGetters.indexOf(aGetter) !== -1 ? !!value : value);
|
|
});
|
|
|
|
return state;
|
|
}
|
|
|
|
/*
|
|
Describes a view and everything public below it.
|
|
*/
|
|
function DropInViewTree(aView)
|
|
{
|
|
return {
|
|
@"class": [aView className],
|
|
@"frame": DropInDescribeValue([aView frame]),
|
|
@"state": DropInViewTreeState(aView),
|
|
@"subviews": DropInPublicSubviews(aView).map(function(aSubview)
|
|
{
|
|
return DropInViewTree(aSubview);
|
|
})
|
|
};
|
|
}
|
|
|
|
function DropInMenuTree(aMenu)
|
|
{
|
|
return [aMenu itemArray].map(function(anItem)
|
|
{
|
|
return {
|
|
@"title": [anItem title],
|
|
@"keyEquivalent": [anItem keyEquivalent],
|
|
@"submenu": [anItem hasSubmenu] ? DropInMenuTree([anItem submenu]) : nil
|
|
};
|
|
});
|
|
}
|
|
|
|
/*
|
|
Describes what a cib produced. Objects other than windows, views and menus are
|
|
recorded by class alone.
|
|
*/
|
|
function DropInObjectTree(anObject)
|
|
{
|
|
if ([anObject isKindOfClass:[CPWindow class]])
|
|
return { @"class": anObject.isa.name, @"title": [anObject title], @"contentView": DropInViewTree([anObject contentView]) };
|
|
|
|
if ([anObject isKindOfClass:[CPView class]])
|
|
return DropInViewTree(anObject);
|
|
|
|
if ([anObject isKindOfClass:[CPMenu class]])
|
|
return { @"class": anObject.isa.name, @"items": DropInMenuTree(anObject) };
|
|
|
|
return { @"class": anObject.isa.name };
|
|
}
|
|
|
|
/*
|
|
The owner of the reference cibs. It has the outlets and actions that those cibs
|
|
connect to it, so that loading them gives no warnings.
|
|
*/
|
|
@implementation _DropInCibOwner : CPObject
|
|
{
|
|
id folderNameField;
|
|
id predicateEditor;
|
|
id predicateSheet;
|
|
id textView;
|
|
id window;
|
|
}
|
|
|
|
- (void)closeSheet:(id)aSender
|
|
{
|
|
}
|
|
|
|
@end
|
|
|
|
/*
|
|
A scroll view takes its scroller style from a global setting, and that style changes
|
|
the frames and views inside it. Applications and tests change the setting, so a
|
|
description that is saved or compared is always made with the same style.
|
|
*/
|
|
function DropInWithFixedScrollerStyle(aFunction)
|
|
{
|
|
var scrollerStyle = [CPScrollView globalScrollerStyle];
|
|
|
|
[CPScrollView setGlobalScrollerStyle:CPScrollerStyleLegacy];
|
|
|
|
try
|
|
{
|
|
return aFunction();
|
|
}
|
|
finally
|
|
{
|
|
[CPScrollView setGlobalScrollerStyle:scrollerStyle];
|
|
}
|
|
}
|
|
|
|
/*
|
|
Loads a cib and describes its top level objects, sorted so that the order in
|
|
which the cib lists them does not matter. Layout changes frames and adds subviews,
|
|
so it is complete before anything is described.
|
|
*/
|
|
function DropInCibTree(aCibName, aRawString)
|
|
{
|
|
var topLevelObjects = [],
|
|
cib = [[CPCib alloc] _initWithData:[CPData dataWithRawString:aRawString] bundle:nil cibName:aCibName];
|
|
|
|
return DropInWithFixedScrollerStyle(function()
|
|
{
|
|
[cib instantiateCibWithExternalNameTable:@{ CPCibOwner: [[_DropInCibOwner alloc] init], CPCibTopLevelObjects: topLevelObjects }];
|
|
[_CPDisplayServer run];
|
|
|
|
return topLevelObjects.map(DropInObjectTree).map(function(aTree)
|
|
{
|
|
return JSON.stringify(aTree);
|
|
}).sort();
|
|
});
|
|
}
|
|
|
|
function DropInClassIsView(aClass)
|
|
{
|
|
var viewClass = [CPView class];
|
|
|
|
for (; aClass; aClass = class_getSuperclass(aClass))
|
|
if (aClass === viewClass)
|
|
return YES;
|
|
|
|
return NO;
|
|
}
|
|
|
|
function DropInSortedClasses()
|
|
{
|
|
var classes = [],
|
|
count = objj_getClassList(classes, 1 << 16);
|
|
|
|
return classes.slice(0, count).filter(function(aClass)
|
|
{
|
|
return aClass && aClass.name.indexOf("$") !== 0;
|
|
}).sort(function(lhs, rhs)
|
|
{
|
|
return lhs.name < rhs.name ? -1 : (lhs.name > rhs.name ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
/*
|
|
Some classes cannot be created without a surrounding object, or without a DOM.
|
|
A class that cannot be created is left out of the construction section, and the
|
|
comparison does not report it. This reduces coverage, but it cannot report a
|
|
false difference.
|
|
|
|
A generator passes throwIfError, so that a getter that throws is not saved as
|
|
expected state, and passes skipped, to list each class or window style that is
|
|
left out.
|
|
*/
|
|
function DropInCreateSnapshot(throwIfError, skipped)
|
|
{
|
|
var snapshot = {
|
|
@"publicClasses": [],
|
|
@"themeAttributes": {},
|
|
@"instanceMethods": {},
|
|
@"classMethods": {},
|
|
@"construction": {},
|
|
@"windows": {}
|
|
};
|
|
|
|
DropInSortedClasses().forEach(function(aClass)
|
|
{
|
|
var name = aClass.name,
|
|
isPublic = DropInIsPublicName(name);
|
|
|
|
try
|
|
{
|
|
var attributes = DropInThemeAttributes(aClass);
|
|
|
|
if (attributes)
|
|
snapshot.themeAttributes[name] = attributes;
|
|
}
|
|
catch (anException)
|
|
{
|
|
if (skipped)
|
|
skipped.push(name + " theme attributes: " + String(anException).slice(0, 80));
|
|
}
|
|
|
|
if (!isPublic)
|
|
return;
|
|
|
|
snapshot.publicClasses.push(name);
|
|
snapshot.instanceMethods[name] = DropInMethodNames(aClass);
|
|
snapshot.classMethods[name] = DropInMethodNames(aClass.isa);
|
|
|
|
if (!DropInClassIsView(aClass))
|
|
return;
|
|
|
|
try
|
|
{
|
|
snapshot.construction[name] = DropInDefaultConstruction(aClass, throwIfError);
|
|
}
|
|
catch (anException)
|
|
{
|
|
if (skipped)
|
|
skipped.push(name + " construction: " + String(anException).slice(0, 80));
|
|
}
|
|
});
|
|
|
|
for (var style in DropInWindowStyles)
|
|
{
|
|
try
|
|
{
|
|
var aWindow = [[CPWindow alloc] initWithContentRect:CGRectMake(0.0, 0.0, 400.0, 400.0)
|
|
styleMask:DropInWindowStyles[style]];
|
|
|
|
snapshot.windows[style] = DropInSubviewClasses([aWindow _windowView]);
|
|
}
|
|
catch (anException)
|
|
{
|
|
if (skipped)
|
|
skipped.push(style + " window style: " + String(anException).slice(0, 80));
|
|
}
|
|
}
|
|
|
|
return snapshot;
|
|
}
|