diff --git a/Components/CustomRect.qml b/Components/CustomRect.qml index 2f5da64..b163d99 100644 --- a/Components/CustomRect.qml +++ b/Components/CustomRect.qml @@ -6,7 +6,6 @@ Rectangle { color: "transparent" Behavior on color { - CAnim { - } + CAnim {} } } diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 6df6539..a70e9a8 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -12,7 +12,7 @@ ScrollBar { readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight - readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY) + readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY)) readonly property real axisLength: isHorizontal ? root.width : root.height readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) @@ -102,9 +102,9 @@ ScrollBar { var newPos = contentPosFromThumbStart(thumbStart); if (root.isHorizontal) - root.flickable.contentX = newPos + root.flickable.originX; + root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0)); else - root.flickable.contentY = newPos + root.flickable.originY; + root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0)); } function visualThumbStart() { diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..72025b9 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -25,6 +25,8 @@ CustomListView { return [0]; case "variant": return SchemeVariants.query(text); + case "files": + return Files.query(text); default: return Apps.search(text); } @@ -33,7 +35,7 @@ CustomListView { function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { - for (const action of ["calc", "scheme", "variant"]) + for (const action of ["calc", "scheme", "variant", "files"]) if (text.startsWith(`${prefix}${action} `)) return action; @@ -163,6 +165,13 @@ CustomListView { PropertyChanges { root.delegate: variantItem } + }, + State { + name: "files" + + PropertyChanges { + root.delegate: filesItem + } } ] transitions: Transition { @@ -264,6 +273,14 @@ CustomListView { } } + Component { + id: filesItem + + FilesItem { + list: root + } + } + Connections { function onTextChanged() { root.syncDisplayText(); diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..3d86f0a 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -85,6 +85,8 @@ Item { } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); + else if (text.startsWith(`${Config.launcher.actionPrefix}files `)) + currentItem.onClicked(); else currentItem.modelData.onClicked(list.currentList); } else { diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..6ccd88b 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -16,7 +16,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..932f69a 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -19,7 +19,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { Apps.launch(root.modelData); diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..282f361 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -24,7 +24,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.onClicked(); @@ -97,8 +97,7 @@ Item { text: qsTr("Open in calculator") Behavior on opacity { - Anim { - } + Anim {} } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..4c95075 --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules.Launcher.Services +import qs.Services + +Item { + id: root + + required property var list + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: Config.launcher.sizes.itemHeight + + function onClicked(): void { + root.list.visibilities.launcher = false; + if (root.modelData?.path) + Files.launch(["xdg-open", root.modelData.path]); + } + + StateLayer { + id: sLayer + + radius: Tokens.rounding.medium + manualHoverOverride: openLocation.hovered + + onClicked: { + root.onClicked(); + } + } + + IconButton { + id: openLocation + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + icon: "open_in_new" + isRound: true + + scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4 + opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + root.list.visibilities.launcher = false; + if (root.modelData?.path) + Files.showInFolder(root.modelData.path); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + + IconImage { + id: icon + + anchors.verticalCenter: parent.verticalCenter + implicitSize: Tokens.font.size.extraLarge + source: Quickshell.iconPath(root.modelData?.icon) ?? "" + visible: !root.modelData?.isImage + } + + Loader { + active: opacity > 0 + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: LoadingIndicator { + color: Colors.palette.m3primaryContainer + implicitSize: Tokens.font.size.extraLarge + } + } + + FadeImage { + id: image + + property int implicitSize: Tokens.font.size.extraLarge + + width: implicitSize + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + source: root.modelData?.isImage ? root.modelData?.path ?? "" : "" + visible: root.modelData?.isImage + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.verticalCenter: icon.verticalCenter + implicitHeight: name.implicitHeight + desc.implicitHeight + implicitWidth: parent.width - icon.width + + CustomText { + id: name + + font.pointSize: Tokens.font.size.normal + text: root.modelData?.name ?? "" + elide: Text.ElideRight + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + + CustomText { + id: desc + + anchors.top: name.bottom + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: root.modelData?.path ?? "" + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + } + } +} diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..cb37cd3 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -15,7 +15,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Services/Files.qml b/Modules/Launcher/Services/Files.qml new file mode 100644 index 0000000..aebbbb0 --- /dev/null +++ b/Modules/Launcher/Services/Files.qml @@ -0,0 +1,194 @@ +// qs/Services/Files.qml +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import ZShell.Config + +Singleton { + id: root + + property list results: [] + + property string _pendingQuery: "" + property int _generation: 0 + + readonly property var _imageExts: ({ + "png": true, + "jpg": true, + "jpeg": true, + "gif": true, + "bmp": true, + "webp": true, + "svg": true, + "tiff": true, + "tif": true, + "ico": true, + "heic": true, + "avif": true + }) + + // freedesktop mimetype-icon names; extend as needed + readonly property var _extIcons: ({ + // images + "png": "image-x-generic", + "jpg": "image-x-generic", + "jpeg": "image-x-generic", + "gif": "image-x-generic", + "bmp": "image-x-generic", + "webp": "image-x-generic", + "svg": "image-x-generic", + "tiff": "image-x-generic", + "tif": "image-x-generic", + "ico": "image-x-generic", + "heic": "image-x-generic", + "avif": "image-x-generic", + // documents + "pdf": "application-pdf", + "doc": "x-office-document", + "docx": "x-office-document", + "odt": "x-office-document", + "txt": "text-x-generic", + "md": "text-x-generic", + "xls": "x-office-spreadsheet", + "xlsx": "x-office-spreadsheet", + "ods": "x-office-spreadsheet", + "ppt": "x-office-presentation", + "pptx": "x-office-presentation", + "odp": "x-office-presentation", + // archives + "zip": "package-x-generic", + "tar": "package-x-generic", + "gz": "package-x-generic", + "xz": "package-x-generic", + "7z": "package-x-generic", + "rar": "package-x-generic", + // audio/video + "mp3": "audio-x-generic", + "flac": "audio-x-generic", + "wav": "audio-x-generic", + "ogg": "audio-x-generic", + "mp4": "video-x-generic", + "mkv": "video-x-generic", + "webm": "video-x-generic", + "avi": "video-x-generic", + "mov": "video-x-generic", + // code + "js": "text-x-script", + "ts": "text-x-script", + "py": "text-x-script", + "sh": "text-x-script", + "qml": "text-x-script", + "cpp": "text-x-c++src", + "c": "text-x-csrc", + "h": "text-x-chdr", + "json": "application-json", + "html": "text-html", + "css": "text-css" + }) + + function showInFolder(path: string): void { + const explorer = Config.general.apps.explorer; + let args = []; + + if (explorer.includes("dolphin")) + args = [...explorer, "--select", path]; + else + args = [...explorer, path]; + + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...args]); + else + Quickshell.execDetached([...args]); + } + + function launch(command: list): void { + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--"].concat(command)); + else + Quickshell.execDetached(command); + } + + function transformSearch(search: string): string { + return search.slice(Config.launcher.actionPrefix.length + "files ".length); + } + + function query(search: string): list { + search = transformSearch(search); + if (search !== _pendingQuery) { + _pendingQuery = search; + debounceTimer.restart(); + } + return results; + } + + function _extOf(name: string): string { + const i = name.lastIndexOf("."); + // no dot, or dotfile with no real extension (".bashrc" -> treat as no ext) + if (i <= 0) + return ""; + return name.slice(i + 1).toLowerCase(); + } + + function _iconFor(name: string, isDir: bool): string { + if (isDir) + return "folder"; + const ext = _extOf(name); + return _extIcons[ext] ?? "text-x-generic"; + } + + function _buildEntry(path: string): var { + const name = path.split("/").pop(); + const ext = _extOf(name); + // trailing slash from plocate's directory output (if -d/dir results ever appear) + const isDir = path.endsWith("/"); + + return { + path: path, + name: name, + icon: _iconFor(name, isDir), + isImage: !isDir && (_imageExts[ext] ?? false) + }; + } + + Timer { + id: debounceTimer + interval: 120 + onTriggered: root._runQuery(root._pendingQuery) + } + + function _runQuery(search: string): void { + _generation++; + + if (!search) { + proc.running = false; + results = []; + return; + } + + if (proc.running) + proc.running = false; + + proc.buffer = []; + proc.myGeneration = _generation; + proc.command = ["plocate", "-i", "-b", "--limit", "50", search]; + proc.running = true; + } + + Process { + id: proc + + property var buffer: [] + property int myGeneration: 0 + + stdout: SplitParser { + onRead: line => proc.buffer.push(root._buildEntry(line)) + } + + onRunningChanged: { + if (!running && myGeneration === root._generation) + root.results = buffer; + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 7f0c912..a97edd5 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -16,6 +16,7 @@ Item { property bool highlight: false property bool slim: false + signal requestExpand signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest @@ -248,16 +249,16 @@ Item { anchors.margins: Tokens.padding.small padding: 8 font.pointSize: Math.round(18 * 1.2) - icon: "add" + icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded - opacity: root.slim ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } + // opacity: root.slim ? 0 : 1 + // visible: opacity > 0 + // + // Behavior on opacity { + // Anim { + // type: Anim.DefaultEffects + // } + // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 @@ -270,6 +271,11 @@ Item { } onClicked: { + if (root.slim && !ChatState.narrowSidebarExpanded) { + root.requestExpand(); + return; + } + modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 1847b35..df83c8e 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -14,6 +14,10 @@ Singleton { property bool inChat: false property bool fabExpanded: false property bool isWindow: false + property bool isWide: false + property bool settledSlim: false + property bool settledWide: false + property bool narrowSidebarExpanded: false property bool windowIsWide: false property ShellScreen screen property bool sidebarVisible: Visibilities.getForActive().sidebar diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 75666af..3a815af 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services @@ -16,8 +17,6 @@ Item { required property ChatMessage message required property ChatGeneration current - // Widest the bubble may grow to; assistant bubbles always use it so - // the markdown blocks have a deterministic width to wrap against. readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge signal edit(text: string) @@ -27,21 +26,51 @@ Item { CustomClippingRect { id: bubble + property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + property real frozenWidth: 0.0 + property bool layoutFrozen: false + property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + radius: Tokens.rounding.medium color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer - implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + implicitWidth: layoutFrozen ? frozenWidth : layoutWidth implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined + layer.enabled: false + layer.smooth: true - // Behavior on implicitHeight { - // enabled: !root.segment.running - // - // Anim { - // type: Anim.DefaultEffects - // } - // } + transform: Scale { + id: stretchScale + origin.x: root.isUser ? bubble.width : 0 + xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0 + } + + states: [ + State { + name: "toSlim" + when: !ChatState.isWide && !ChatState.settledSlim + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + }, + State { + name: "toWide" + when: ChatState.isWide && !ChatState.settledWide + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + } + ] + + onLayoutFrozenChanged: { + if (layoutFrozen) + frozenWidth = width; + } - // User messages stay a plain editable text field. TextEditBase { id: msgText diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 210d780..c9bbd59 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -36,6 +36,7 @@ Item { } BlobInvertedRect { + id: invertedRect anchors.fill: parent borderBottom: Tokens.padding.small borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium @@ -53,9 +54,10 @@ Item { anchors.left: parent.left anchors.bottom: parent.bottom anchors.margins: Tokens.padding.medium - implicitWidth: root.isWide ? Config.sidebar.sizes.width : root.iconSize + implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize highlight: true - slim: !root.isWide + slim: !root.isWide && !ChatState.narrowSidebarExpanded + z: 1 Behavior on implicitWidth { Anim { @@ -84,6 +86,36 @@ Item { ChatState.currentIdx = 0; ChatState.chatSession = data; } + + onRequestExpand: { + ChatState.narrowSidebarExpanded = true; + } + } + + CustomClippingRect { + anchors.fill: parent + anchors.leftMargin: invertedRect.borderLeft + anchors.topMargin: invertedRect.borderTop + anchors.bottomMargin: invertedRect.borderBottom + anchors.rightMargin: invertedRect.borderRight + radius: invertedRect.radius + + children: [contentArea, dim] + } + + CustomRect { + id: dim + anchors.fill: parent + + color: Colors.palette.m3shadow + opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0 + z: 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } } Item { @@ -91,12 +123,12 @@ Item { property int leftMargin: sidebar.implicitWidth - z: 1 + z: 0 anchors.top: parent.top anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.medium * 2 + anchors.margins: Tokens.padding.medium anchors.right: parent.right - implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2 states: State { name: "wide" @@ -152,6 +184,13 @@ Item { color: Colors.tPalette.m3onSurfaceVariant } } + + MouseArea { + anchors.fill: parent + preventStealing: true + enabled: !root.isWide && ChatState.narrowSidebarExpanded + onClicked: ChatState.narrowSidebarExpanded = false + } } Binding { @@ -160,6 +199,24 @@ Item { value: root.showList } + Binding { + target: ChatState + property: "isWide" + value: root.isWide + } + + Binding { + target: ChatState + value: contentArea.leftMargin === root.iconSize + property: "settledSlim" + } + + Binding { + target: ChatState + value: contentArea.leftMargin === sidebar.implicitWidth + property: "settledWide" + } + Component { id: conversationView diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..cea59cc 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -110,7 +110,10 @@ ColumnLayout { onClicked: { root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]); } } @@ -120,7 +123,10 @@ ColumnLayout { onClicked: { root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]); } } } @@ -135,8 +141,7 @@ ColumnLayout { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: FileSystemModel { nameFilters: ["recording_*.mp4"] @@ -162,8 +167,7 @@ ColumnLayout { opacity: list.count === 0 ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: Tokens.spacing.small @@ -178,16 +182,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } @@ -203,16 +204,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } }