Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions lively.ide/studio/controls/layout.cp.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class LayoutPreview extends Morph {
align: autoLayout.align,
axisAlign: autoLayout.axisAlign,
axis: autoLayout.axis,
reverseOrder: autoLayout.orderByIndex && autoLayout.reverseOrder,
justifySubmorphs: autoLayout.justifySubmorphs
});
['mini bar 1', 'mini bar 2', 'mini bar 3'].forEach(bar => {
Expand Down Expand Up @@ -61,6 +62,7 @@ export class AutoLayoutControlModel extends PropertySectionModel {
{ target: 'vertical', signal: 'onMouseDown', handler: 'setVerticalFlow' },
{ target: 'horizontal', signal: 'onMouseDown', handler: 'setHorizontalFlow' },
{ target: 'wrap submorphs checkbox', signal: 'checked', handler: 'toggleWrapping' },
{ target: 'reverse order checkbox', signal: 'checked', handler: 'toggleReverseOrder' },
{ target: 'spacing input', signal: 'number', handler: 'confirm' },
{ target: 'total padding input', signal: 'number', handler: 'confirm' }
];
Expand Down Expand Up @@ -88,7 +90,7 @@ export class AutoLayoutControlModel extends PropertySectionModel {
this.withoutBindingsDo(() => {
const {
miniLayoutPreview, vertical, horizontal,
spacingInput, totalPaddingInput, wrapSubmorphsCheckbox
spacingInput, totalPaddingInput, wrapSubmorphsCheckbox, reverseOrderCheckbox
} = this.ui;
miniLayoutPreview.setActive(!!this.popup);
if (!this.targetMorph) return;
Expand All @@ -104,6 +106,8 @@ export class AutoLayoutControlModel extends PropertySectionModel {
totalPaddingInput.number = layout.padding.top();
}
wrapSubmorphsCheckbox.checked = layout.wrapSubmorphs;
reverseOrderCheckbox.checked = layout.reverseOrder;
layout.orderByIndex ? reverseOrderCheckbox.enable() : reverseOrderCheckbox.disable();
});
}

Expand All @@ -130,6 +134,15 @@ export class AutoLayoutControlModel extends PropertySectionModel {
this.update('wrapping');
}

toggleReverseOrder () {
const layout = this.targetMorph.layout;
if (!layout.orderByIndex) return;
this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
this.targetMorph.layout = layout.with({ reverseOrder: !layout.reverseOrder });
});
this.update();
}

setVerticalFlow () {
this.targetMorph.withMetaDo({ reconcileChanges: true }, () => {
this.targetMorph.layout = this.targetMorph.layout.with({ axis: 'column' });
Expand Down Expand Up @@ -536,7 +549,8 @@ const LayoutControl = component(PropertySection, {
fill: Color.transparent,
layout: new TilingLayout({
axisAlign: 'center',
orderByIndex: true
orderByIndex: true,
spacing: 8
}),
submorphs: [{
name: 'spacer',
Expand All @@ -552,6 +566,11 @@ const LayoutControl = component(PropertySection, {
name: 'label',
master: PropLabel
}]
}), part(LabeledCheckbox, {
name: 'reverse order checkbox',
tooltip: 'Reverse submorph-index order. Unavailable for position ordering.',
viewModel: { label: 'Reverse Order' },
submorphs: [{ name: 'label', master: PropLabel }]
})
]
})]
Expand Down
66 changes: 66 additions & 0 deletions lively.ide/tests/layout-control-test.cp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* global describe, it */
import { expect } from 'mocha-es6';
import { Morph, TilingLayout, part } from 'lively.morphic';
import { LayoutControl } from '../studio/controls/layout.cp.js';

describe('layout sidebar ordering', () => {
it('toggles reverseOrder through the checkbox in index mode', () => {
const target = new Morph({ layout: new TilingLayout({ orderByIndex: true }) });
const control = part(LayoutControl);
try {
const checkbox = control.getSubmorphNamed('reverse order checkbox');
expect(checkbox).not.equals(null, 'sidebar exposes a reverse-order checkbox');
control.viewModel.focusOn(target);
const metadata = [];
const withMetaDo = target.withMetaDo;
target.withMetaDo = function (meta, fn) {
metadata.push(meta);
return withMetaDo.call(this, meta, fn);
};
checkbox.viewModel.trigger();
expect(target.layout.reverseOrder).equals(true, 'checking the control enables reversal');
expect(target.layout.orderByIndex).equals(true, 'reversal preserves index ordering mode');
expect(metadata.some(meta => meta.reconcileChanges === true)).equals(true, 'sidebar changes retain reconciliation metadata');
checkbox.viewModel.trigger();
expect(target.layout.reverseOrder).equals(false, 'unchecking the control disables reversal');
} finally { control.remove(); }
});

it('reflects the focused layout in the checkbox and preview', () => {
const target = new Morph({ layout: new TilingLayout({ reverseOrder: true }) });
const control = part(LayoutControl);
try {
const checkbox = control.getSubmorphNamed('reverse order checkbox');
expect(checkbox).not.equals(null, 'sidebar exposes a reverse-order checkbox');
control.viewModel.focusOn(target);
expect(checkbox.checked).equals(true, 'focus reflects the stored reversal');
const preview = control.getSubmorphNamed('mini layout preview').getSubmorphNamed('outer border');
expect(preview.layout.reverseOrder).equals(true, 'the layout preview reflects direction');
} finally { control.remove(); }
});

it('disables reverse ordering in position mode and re-enables it in index mode', () => {
const layout = new TilingLayout({ orderByIndex: false, reverseOrder: true });
const target = new Morph({ layout });
const control = part(LayoutControl);
try {
control.viewModel.focusOn(target);
const checkbox = control.getSubmorphNamed('reverse order checkbox');
expect(checkbox.active).equals(false, 'reverse-order control is disabled in position mode');
expect(checkbox.checked).equals(true, 'disabling the control preserves the stored flag');
checkbox.viewModel.trigger();
control.viewModel.toggleReverseOrder();
expect(target.layout).equals(layout, 'disabled interaction does not replace the layout');
expect(target.layout.reverseOrder).equals(true, 'disabled interaction does not change the stored flag');
const preview = control.getSubmorphNamed('mini layout preview').getSubmorphNamed('outer border');
expect(preview.layout.reverseOrder).equals(false, 'position-mode preview does not reverse');

target.layout = layout.with({ orderByIndex: true });
control.viewModel.update();
expect(checkbox.active).equals(true, 'index mode enables the reverse-order control');
expect(preview.layout.reverseOrder).equals(true, 'index-mode preview uses the stored reversal');
checkbox.viewModel.trigger();
expect(target.layout.reverseOrder).equals(false, 're-enabled control can change reversal');
} finally { control.remove(); }
});
});
37 changes: 25 additions & 12 deletions lively.morphic/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ export class TilingLayout extends Layout {
this._justifySubmorphs = props.justifySubmorphs || 'packed';
this._hugContentsVertically = props.hugContentsVertically || false;
this._hugContentsHorizontally = props.hugContentsHorizontally || false;
this._orderByIndex = props.orderByIndex || true;
this._orderByIndex = props.orderByIndex === undefined ? true : !!props.orderByIndex;
this._reverseOrder = !!props.reverseOrder;
this._wrapSubmorphs = false;
if (typeof props.wrapSubmorphs !== 'undefined') {
this._wrapSubmorphs = props.wrapSubmorphs;
Expand Down Expand Up @@ -514,7 +515,10 @@ export class TilingLayout extends Layout {
if (config.hugContentsHorizontally !== false) spec.hugContentsHorizontally = true;
break;
case 'orderByIndex':
if (config.orderByIndex !== false && config.renderViaCSS === false) spec.orderByIndex = true;
if (config.orderByIndex !== undefined && !config.orderByIndex) spec.orderByIndex = false;
break;
case 'reverseOrder':
if (config.reverseOrder) spec.reverseOrder = true;
break;
case 'wrapSubmorphs':
if (config.wrapSubmorphs !== false) spec.wrapSubmorphs = true;
Expand All @@ -540,12 +544,12 @@ export class TilingLayout extends Layout {
* @returns {TileLayoutSpec}
*/
getSpec () {
if (!this.container) return this.sanitizeConfig(this.config);
if (!this.container) return this.sanitizeConfig({ ...this.config, orderByIndex: this.orderByIndex, reverseOrder: this.reverseOrder });
if (Array.isArray(this._resizePolicies)) {
this.initializeResizePolicies();
}
let {
axis, align, axisAlign, spacing, orderByIndex, resizePolicies,
axis, align, axisAlign, spacing, orderByIndex, reverseOrder, resizePolicies,
reactToSubmorphAnimations, renderViaCSS, padding, wrapSubmorphs,
justifySubmorphs,
_hugContentsVertically: hugContentsVertically,
Expand All @@ -556,7 +560,8 @@ export class TilingLayout extends Layout {
align,
axisAlign,
spacing,
orderByIndex: !renderViaCSS && orderByIndex,
orderByIndex,
reverseOrder,
reactToSubmorphAnimations,
renderViaCSS,
padding,
Expand Down Expand Up @@ -585,12 +590,19 @@ export class TilingLayout extends Layout {
/**
* Defines how the positioned submorphs are to be ordered.
* If set to false, the layoutable submorphs are ordered by their relative offset to the container.
* If set to true, the layoutable submorphs are ordered by the index they are found at from their owner.
* Warning: This property is not supported when the layout is rendered via CSS and defaults to true in this case.
* If set to true, the layoutable submorphs are ordered by their owner index.
* @type {Boolean}
*/
get orderByIndex () { return this._orderByIndex; }
set orderByIndex (active) { this._orderByIndex = !!active; this.onConfigUpdate(); }

/**
* Reverses submorph-index order without changing the owner's submorphs.
* Has no effect when orderByIndex is false: that mode stays purely positional.
* @type {Boolean}
*/
get orderByIndex () { return this._orderByIndex || this.renderViaCSS; }
set orderByIndex (active) { this._orderByIndex = active; this.onConfigUpdate(); }
get reverseOrder () { return this._reverseOrder; }
set reverseOrder (active) { this._reverseOrder = !!active; this.onConfigUpdate(); }

/**
* Defines the flow direction of the positioned morphs.
Expand Down Expand Up @@ -991,7 +1003,8 @@ export class TilingLayout extends Layout {
}
style.top = 'unset';
style.left = 'unset';
style.order = order.layoutableIndices.get(morph) ?? -1; // already handled by the node ordering
const index = order.layoutableIndices.get(morph) ?? -1;
style.order = this.orderByIndex && this.reverseOrder ? layoutableSubmorphs.length - 1 - index : index;
const hasNextSibling = layoutableSubmorphs.length > style.order + 1;
const hasPrevSibling = style.order > 0;

Expand Down Expand Up @@ -1217,6 +1230,7 @@ export class TilingLayout extends Layout {
ensureYogaNodesInSync () {
let { layoutableSubmorphs } = this;
layoutableSubmorphs = layoutableSubmorphs.filter(m => m.visible);
if (this.orderByIndex && this.reverseOrder) layoutableSubmorphs.reverse();
const containerNode = this.ensureYogaNodeFor(this.container);
let i = 0; let resetNodes = layoutableSubmorphs.length !== containerNode.getChildCount();
for (let m of layoutableSubmorphs) {
Expand Down Expand Up @@ -1554,8 +1568,7 @@ export class TilingLayout extends Layout {
}

layoutOrder (morph) {
// the following creates a drop zone that is 15 pixels tall.
// allows for horizontal reordering.
// Keep the 15 pixel drop zones used for position-based reordering.
if (this.orderByIndex) return this.container.getProperty('submorphs').indexOf(morph);
return this.axis === 'row' ? (morph.top - morph.top % 15) * 1000000 + morph.left : (morph.left - morph.left % 15) * 1000000 + morph.top;
}
Expand Down
1 change: 1 addition & 0 deletions lively.morphic/tests/components-test.cp.js
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ describe('breakpoints', () => {
expect(m.get('helen').width).to.equal(150);
m.width = 199;
m.master.applyIfNeeded(true);
m.applyLayoutIfNeeded();
expect(m.get('helen').width).to.equal(199);
m.width = 250;
m.master.applyIfNeeded(true);
Expand Down
133 changes: 133 additions & 0 deletions lively.morphic/tests/reverse-layout-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* global describe, it */
import { expect } from 'mocha-es6';
import { Morph, TilingLayout } from '../index.js';
import { pt } from 'lively.graphics';

describe('reversed tiling layouts', () => {
for (const axis of ['row', 'column']) {
for (const renderViaCSS of [false, true]) {
it(`toggles ${axis} order without rebuilding unchanged Yoga children (CSS: ${renderViaCSS})`, async () => {
const children = [new Morph({ extent: pt(30, 30) }), new Morph({ extent: pt(40, 40) })];
const layout = new TilingLayout({ axis, renderViaCSS });
const container = new Morph({ extent: pt(200, 200), submorphs: children, layout }).openInWorld();
try {
await container.whenRendered();
container.env.forceUpdate();
const position = axis === 'row' ? 'left' : 'top';
const offset = axis === 'row' ? 'offsetLeft' : 'offsetTop';
expect(children[0][position]).below(children[1][position], 'default order follows owner indices');
layout.reverseOrder = true;
container.env.forceUpdate();
expect(children[1][position]).below(children[0][position], 'reverseOrder reverses the chosen order');
expect(container.submorphs).deep.equals(children, 'reversal preserves owner indices');
for (const child of children) {
expect(container.env.renderer.getNodeForMorph(child)[offset]).closeTo(child[position], 0.1);
}
expect(layout.copy().reverseOrder).equals(true, 'copy retains reversal');
const yogaNode = container._yogaNode;
const removeChild = yogaNode.removeChild;
let removed = 0;
yogaNode.removeChild = function (node) { removed++; return removeChild.call(this, node); };
try { layout.computeLayout(); } finally { yogaNode.removeChild = removeChild; }
expect(removed).equals(0, 'unchanged Yoga children are reused');
layout.reverseOrder = false;
container.env.forceUpdate();
expect(children[0][position]).below(children[1][position], 'disabling reversal restores forward order');
} finally { container.remove(); }
});

it(`orders ${axis} by position independently of reverseOrder (CSS: ${renderViaCSS})`, async () => {
const position = axis === 'row' ? 'left' : 'top';
const children = [80, 0, 40].map(offset => new Morph({
extent: pt(20, 20), position: axis === 'row' ? pt(offset, 0) : pt(0, offset)
}));
const layout = new TilingLayout({ axis, renderViaCSS, orderByIndex: false });
const container = new Morph({ extent: pt(200, 200), submorphs: children });
layout.container = container;
expect(layout.layoutableSubmorphs).deep.equals([children[1], children[2], children[0]], 'false selects spatial order, not reversed owner indices');
container.layout = layout;
container.openInWorld();
try {
await container.whenRendered();
container.env.forceUpdate();
expect(children[1][position]).below(children[2][position], 'spatially first child remains first');
expect(children[2][position]).below(children[0][position], 'spatially last child remains last');
expect(layout.orderByIndex).equals(false, 'position mode stays enabled');
expect(layout.reverseOrder).equals(false, 'position mode does not enable reversal');
} finally { container.remove(); }
});

it(`ignores reverseOrder in ${axis} position mode across relayouts (CSS: ${renderViaCSS})`, async () => {
let forwardPositions;
for (const reverseOrder of [false, true]) {
const children = [80, 0, 40].map(offset => new Morph({
extent: pt(20, 20), position: axis === 'row' ? pt(offset, 0) : pt(0, offset)
}));
const layout = new TilingLayout({ axis, renderViaCSS, orderByIndex: false, reverseOrder });
const container = new Morph({ extent: pt(200, 200), submorphs: children, layout }).openInWorld();
const expectedOrder = [children[1], children[2], children[0]];
const position = axis === 'row' ? 'left' : 'top';
const positions = () => children.map(child => child.position.toTuple());
try {
await container.whenRendered();
container.env.forceUpdate();
expect([...children].sort((a, b) => a[position] - b[position])).deep.equals(expectedOrder, 'position mode keeps spatial order even with reverseOrder');
if (!reverseOrder) forwardPositions = positions();
expect(positions()).deep.equals(forwardPositions, 'reverseOrder has no effect on positional endpoints');
for (let iteration = 0; iteration < 3; iteration++) {
container.extent = container.extent.addPt(pt(1, 1));
layout.computeLayout();
container.env.forceUpdate();
expect(positions()).deep.equals(forwardPositions, 'relayout and extent changes preserve purely positional order');
if (renderViaCSS) {
for (const child of children) {
const style = {};
layout.addSubmorphCSS(child, style);
expect(style.order).equals(expectedOrder.indexOf(child), 'CSS ignores reverseOrder in position mode');
}
}
}
expect(container.submorphs).deep.equals(children, 'position mode preserves owner indices');
expect(layout.reverseOrder).equals(reverseOrder, 'position mode preserves the inactive flag');
} finally { container.remove(); }
}
});
}
}

it('normalizes truthy and falsy ordering options consistently in constructors and setters', () => {
for (const flag of ['orderByIndex', 'reverseOrder']) {
for (const value of [false, 0, null, '', true, 1, 'yes']) {
const layout = new TilingLayout({ [flag]: value });
expect(layout[flag]).equals(!!value, `${flag} constructor normalizes ${String(value)}`);
expect(layout.copy()[flag]).equals(!!value, `${flag} constructor survives copy`);
layout[flag] = value ? 0 : 1;
expect(layout[flag]).equals(!value, `${flag} setter normalizes ${String(value)}`);
expect(new TilingLayout(layout.getSpec())[flag]).equals(!value, `${flag} setter survives spec round-trip`);
}
}
});

it('preserves both ordering options through detached and attached copy, spec and equality', () => {
for (const renderViaCSS of [false, true]) {
for (const orderByIndex of [false, true]) {
for (const reverseOrder of [false, true]) {
const layout = new TilingLayout({ renderViaCSS, orderByIndex, reverseOrder });
for (const attached of [false, true]) {
if (attached) new Morph({ layout });
const copy = layout.copy();
const restored = new TilingLayout(layout.getSpec());
expect(copy.orderByIndex).equals(orderByIndex, 'copy retains index/position mode');
expect(copy.reverseOrder).equals(reverseOrder, 'copy retains direction');
expect(restored.orderByIndex).equals(orderByIndex, 'spec retains index/position mode');
expect(restored.reverseOrder).equals(reverseOrder, 'spec retains direction');
expect(layout.equals(copy)).equals(true, 'copy compares equal');
expect(layout.equals(restored)).equals(true, 'spec round-trip compares equal');
expect(layout.equals(layout.with({ orderByIndex: !orderByIndex }))).equals(false, 'equality distinguishes the order mode');
expect(layout.equals(layout.with({ reverseOrder: !reverseOrder }))).equals(false, 'equality distinguishes direction');
}
}
}
}
});
});
Loading