diff --git a/src/extensions/cp/apple/finalcutpro/cmd/CommandDetail.lua b/src/extensions/cp/apple/finalcutpro/cmd/CommandDetail.lua index e059cc303..b99ed495f 100644 --- a/src/extensions/cp/apple/finalcutpro/cmd/CommandDetail.lua +++ b/src/extensions/cp/apple/finalcutpro/cmd/CommandDetail.lua @@ -1,6 +1,8 @@ --- === cp.apple.finalcutpro.cmd.CommandDetail === --- --- This class provides a UI for displaying the details of a command when it is selected on the `CommandList`. +--- +--- Extends: [Group](cp.ui.Group.md) local require = require @@ -12,9 +14,11 @@ local Group = require "cp.ui.Group" local StaticText = require "cp.ui.StaticText" local ScrollArea = require "cp.ui.ScrollArea" local TextArea = require "cp.ui.TextArea" +local has = require "cp.ui.has" local chain = fn.chain local matchesExactItems = fn.table.matchesExactItems +local list, alias = has.list, has.alias local CommandDetail = Group:subclass("cp.apple.finalcutpro.cmd.CommandDetail") @@ -46,39 +50,42 @@ CommandDetail.static.matches = ax.matchesIf( ) ) --- cp.apple.finalcutpro.cmd.CommandDetail._contentGroupUI --- Field --- The [axuielement](cp.prop.axuielement) for the content Group. -function CommandDetail.lazy.prop:_contentGroupUI() - return self.UI:mutate(ax.childMatching(Group.matches)) -end +--- cp.apple.finalcutpro.cmd.CommandDetail.children +--- Constant +--- UI Handler for the children of this class. +CommandDetail.static.children = list { + Group:containing { + alias "label" { StaticText }, + alias "detail" { ScrollArea:containing(TextArea) } + } +} --- cp.apple.finalcutpro.cmd.CommandDetail._labelUI --- Field --- The [axuielement](cp.prop.axuielement) for the label. -function CommandDetail.lazy.prop:_labelUI() - return self._contentGroupUI:mutate(ax.childMatching(StaticText.matches)) +--- cp.apple.finalcutpro.cmd.CommandDetail(parent, uiFinder) -> cp.apple.finalcutpro.cmd.CommandDetail +--- Constructor +--- Creates a new CommandDetail. +--- +--- Parameters: +--- * parent - The parent object. +--- * uiFinder - The uiFinder object. +--- +--- Returns: +--- * The new CommandDetail object. +function CommandDetail:initialize(parent, uiFinder) + Group.initialize(self, parent, uiFinder, CommandDetail.children) end --- cp.apple.finalcutpro.cmd.CommandDetail.label --- Field --- The StaticText that displays the label. function CommandDetail.lazy.value:label() - return StaticText(self, self._labelUI) -end - --- cp.apple.finalcutpro.cmd.CommandDetail._detailUI --- Field --- The [axuielement](cp.prop.axuielement) for the detail `AXScrollArea`. -function CommandDetail.lazy.prop:_detailUI() - return self._contentGroupUI:mutate(ax.childMatching(ScrollArea.matches)) + return self.children[1].label end --- cp.apple.finalcutpro.cmd.CommandDetail.detail --- Field --- The [ScrollArea](cp.ui.ScrollArea.md) that displays the contained [TextArea](cp.ui.TextArea.md). function CommandDetail.lazy.value:detail() - return ScrollArea:containing(TextArea)(self, self._detailUI) + return self.children[1].detail end --- cp.apple.finalcutpro.cmd.CommandDetail.contents diff --git a/src/extensions/cp/apple/finalcutpro/cmd/CommandEditor.lua b/src/extensions/cp/apple/finalcutpro/cmd/CommandEditor.lua index 502ede500..24b5aaede 100644 --- a/src/extensions/cp/apple/finalcutpro/cmd/CommandEditor.lua +++ b/src/extensions/cp/apple/finalcutpro/cmd/CommandEditor.lua @@ -1,6 +1,8 @@ --- === cp.apple.finalcutpro.cmd.CommandEditor === --- --- Command Editor Module. +--- +--- Extends: [Dialog](cp.ui.Dialog.md) local require = require @@ -8,11 +10,13 @@ local log = require "hs.logger" .new "CmdEditor" local just = require "cp.just" +local has = require "cp.ui.has" local Button = require "cp.ui.Button" local CheckBox = require "cp.ui.CheckBox" local Dialog = require "cp.ui.Dialog" local Group = require "cp.ui.Group" local PopUpButton = require "cp.ui.PopUpButton" +local StaticText = require "cp.ui.StaticText" local TextField = require "cp.ui.TextField" local CommandDetail = require "cp.apple.finalcutpro.cmd.CommandDetail" @@ -29,7 +33,9 @@ local WaitUntil = require "cp.rx.go.WaitUntil" local fn = require "cp.fn" local ax = require "cp.fn.ax" local chain = fn.chain -local get, sort = fn.table.get, fn.table.sort +local get = fn.table.get + +local list, alias, oneOf = has.list, has.alias, has.oneOf local CommandEditor = Dialog:subclass("cp.apple.finalcutpro.cmd.CommandEditor") @@ -48,7 +54,7 @@ CommandEditor.static.matches = ax.matchesIf( -- It's modal get "AXModal", -- It has the required children: - chain // ax.children >> sort(ax.topDown) >> fn.all( + chain // ax.childrenTopDown >> fn.all( -- The `commandSet` PopUpButton ... chain // get(5) >> PopUpButton.matches, -- The `keyboard` Group... @@ -58,6 +64,33 @@ CommandEditor.static.matches = ax.matchesIf( ) ) +CommandEditor.static.children = list { + alias "close" { Button }, alias "minimize" { Button }, alias "zoom" { Button }, + alias "windowTitle" { StaticText }, + alias "commandSet" { PopUpButton }, + alias "modifiers" { + Group:containing { + alias "command" { CheckBox }, + alias "shift" { CheckBox }, + alias "option" { CheckBox }, + alias "control" { CheckBox }, + } + }, + alias "keyboardToggle" { CheckBox }, + alias "search" { TextField:forcingFocus(true) }, + alias "keyboard" { Group }, + alias "commandList" { CommandList }, + alias "detail" { + oneOf { + alias "commandDetail" { CommandDetail }, + alias "keyDetail" { KeyDetail }, + }, + }, + Button, -- "Close" + alias "saveButton" { Button }, + has.ended +} + --- cp.apple.finalcutpro.cmd.CommandEditor(app) -> CommandEditor --- Constructor --- Creates a new Command Editor object. @@ -148,7 +181,7 @@ end --- Returns: --- * The `cp.apple.finalcutpro.cmd.CommandEditor` object for method chaining. function CommandEditor:hide() - self.close:press() + self.closeButton:press() return self end @@ -214,114 +247,103 @@ function CommandEditor.lazy.method:doClose() return self.closeButton:doPress() end --- ==== Command Editor UI ==== +----------------------------------------------------------------------- +-- Command Editor UI +----------------------------------------------------------------------- ---- cp.apple.finalcutpro.cmd.CommandEditor.save +--- cp.apple.finalcutpro.cmd.CommandEditor.childrenUI --- Field ---- The "Save" [Button](cp.ui.Button.md). -function CommandEditor.lazy.value:save() - return Button(self, self.UI:mutate( - ax.childMatching(Button.matches, 1, ax.bottomUp) - )) +--- The `axuielement` list for the children of the Command Editor. +function CommandEditor.lazy.prop:childrenUI() + return ax.prop(self.UI, "AXChildren") +end + +--- cp.apple.finalcutpro.cmd.CommandEditor.childrenInNavigationOrderUI +--- Field +--- The `axuielement` list for the children of the Command Editor, in navigation order. +function CommandEditor.lazy.prop:childrenInNavigationOrderUI() + return ax.prop(self.UI, "AXChildrenInNavigationOrder") +end + +--- cp.apple.finalcutpro.cmd.CommandEditor.children +--- Field +--- The [ElementList](cp.ui.ElementList.md) of the children of the Command Editor. +function CommandEditor.lazy.value:children() + return self.class.children:build(self, self.childrenInNavigationOrderUI) end ---- cp.apple.finalcutpro.cmd.CommandEditor.closeButton +--- cp.apple.finalcutpro.cmd.CommandEditor.saveButton --- Field ---- The "Close" [Button](cp.ui.Button.md). -function CommandEditor.lazy.value:closeButton() - return Button(self, self.UI:mutate( - ax.childMatching(Button.matches, 2, ax.bottomUp) - )) +--- The "Save" [Button](cp.ui.Button.md). +function CommandEditor.lazy.value:saveButton() + return self.children.saveButton end --- cp.apple.finalcutpro.cmd.CommandEditor.commandSet --- Field --- The "Command Set" [PopUpButton](cp.ui.PopUpButton.md). -function CommandEditor.lazy.value:commandSet() - return PopUpButton(self, self.UI:mutate( - ax.childMatching(PopUpButton.matches) - )) -end --- cp.apple.finalcutpro.cmd.CommandEditor.modifiers --- Field --- The [Group](cp.ui.Group.md) containing 'modifier' checkboxes (Cmd, Shift, etc). function CommandEditor.lazy.value:modifiers() - return Group(self, self.UI:mutate( - ax.childMatching(Group.matches, 1) - )) + return self.children.modifiers end --- cp.apple.finalcutpro.cmd.CommandEditor.command --- Field --- The "Command" [CheckBox](cp.ui.CheckBox.md). function CommandEditor.lazy.value:command() - return CheckBox(self, self.modifiers.UI:mutate( - ax.childMatching(CheckBox.matches, 1) - )) + return self.modifiers.children.command end --- cp.apple.finalcutpro.cmd.CommandEditor.shift --- Field --- The "Shift" [CheckBox](cp.ui.CheckBox.md). function CommandEditor.lazy.value:shift() - return CheckBox(self, self.modifiers.UI:mutate( - ax.childMatching(CheckBox.matches, 2) - )) + return self.modifiers.children.shift end --- cp.apple.finalcutpro.cmd.CommandEditor.option --- Field --- The "Option" [CheckBox](cp.ui.CheckBox.md). function CommandEditor.lazy.value:option() - return CheckBox(self, self.modifiers.UI:mutate( - ax.childMatching(CheckBox.matches, 3) - )) + return self.modifiers.children.option end --- cp.apple.finalcutpro.cmd.CommandEditor.control --- Field --- The "Control" [CheckBox](cp.ui.CheckBox.md). function CommandEditor.lazy.value:control() - return CheckBox(self, self.modifiers.UI:mutate( - ax.childMatching(CheckBox.matches, 4) - )) + return self.modifiers.children.control end --- cp.apple.finalcutpro.cmd.CommandEditor.keyboardToggle --- Field --- The "Keyboard Toggle" [CheckBox](cp.ui.CheckBox.md) (next to the Search field). function CommandEditor.lazy.value:keyboardToggle() - return CheckBox(self, self.UI:mutate( - ax.childMatching(CheckBox.matches) - )) + return self.children.keyboardToggle end --- cp.apple.finalcutpro.cmd.CommandEditor.search --- Field --- The "Search" [TextField](cp.ui.TextField.md). function CommandEditor.lazy.value:search() - return TextField(self, self.UI:mutate( - ax.childMatching(TextField.matches) - )):forceFocus() + return self.children.search end --- cp.apple.finalcutpro.cmd.CommandEditor.keyboard --- Field --- The [Group](cp.ui.Group.md) containing the keyboard shortcuts. Does not seem to expose the actual key buttons. function CommandEditor.lazy.value:keyboard() - return Group(self, self.UI:mutate( - ax.childMatching(Group.matches, 2) - )) + return self.children.keyboard end --- cp.apple.finalcutpro.cmd.CommandEditor.commandList --- Field --- The [CommandList](cp.apple.finalcutpro.cmd.CommandList.md). function CommandEditor.lazy.value:commandList() - return CommandList(self, self.UI:mutate( - ax.childMatching(CommandList.matches) - )) + return self.children.commandList end --- cp.apple.finalcutpro.cmd.CommandEditor.commands @@ -343,9 +365,7 @@ end --- The [KeyDetail](cp.apple.finalcutpro.cmd.KeyDetail.md) section. --- Either this or [commandDetail](#commandDetail) will be visible at any given time. function CommandEditor.lazy.value:keyDetail() - return KeyDetail(self, self.UI:mutate( - ax.childMatching(KeyDetail.matches) - )) + return self.children.detail.keyDetail end --- cp.apple.finalcutpro.cmd.CommandEditor.commandDetail @@ -353,9 +373,7 @@ end --- The [CommandDetail](cp.apple.finalcutpro.cmd.CommandDetail.md) section. --- Either this or [keyDetail](#keyDetail) will be visible at any given time. function CommandEditor.lazy.value:commandDetail() - return CommandDetail(self, self.UI:mutate( - ax.childMatching(CommandDetail.matches) - )) + return self.children.detail.commandDetail end --- cp.apple.finalcutpro.cmd.CommandEditor:doFindCommandID(commandID, [highlight]) -> cp.rx.go.Statement diff --git a/src/extensions/cp/apple/finalcutpro/cmd/CommandList.lua b/src/extensions/cp/apple/finalcutpro/cmd/CommandList.lua index d8bcc3a5e..6688e03d2 100644 --- a/src/extensions/cp/apple/finalcutpro/cmd/CommandList.lua +++ b/src/extensions/cp/apple/finalcutpro/cmd/CommandList.lua @@ -1,6 +1,9 @@ --- === cp.apple.finalcutpro.cmd.CommandList === --- --- A list of commands available in the [CommandEditor](cp.apple.finalcutpro.cmd.CommandEditor.md). +--- +--- Extends: [cp.ui.Element](cp.ui.Element.md) +--- Delegates To: [contents](#contents) local require = require @@ -10,16 +13,19 @@ local fn = require "cp.fn" local ax = require "cp.fn.ax" local Group = require "cp.ui.Group" local SplitGroup = require "cp.ui.SplitGroup" -local Splitter = require "cp.ui.Splitter" local StaticText = require "cp.ui.StaticText" +local has = require "cp.ui.has" local CommandGroups = require "cp.apple.finalcutpro.cmd.CommandGroups" -local Commands = require "cp.apple.finalcutpro.cmd.Commands" +local Commands = require "cp.apple.finalcutpro.cmd.Commands" local chain = fn.chain local matchesExactItems = fn.table.matchesExactItems +local list, alias = has.list, has.alias + local CommandList = Group:subclass("cp.apple.finalcutpro.cmd.CommandList") + :delegateTo("contents") -- NOTE: Strings in the Command Editor are only found in the following .nib: -- /Contents/Frameworks/LunaKit.framework/Resources/en.lproj/LKCommandCustomizationAquaCongruency.nib @@ -42,43 +48,47 @@ CommandList.static.matches = ax.matchesIf( ) ) +CommandList.static.children = list { + alias "label" { StaticText }, + alias "contents" { + SplitGroup:with( + alias "groups" { CommandGroups }, + alias "commands" { Commands } + ) + }, +} + +--- cp.apple.finalcutpro.cmd.CommandList(parent, uiFinder) -> CommandList +--- Constructor +--- Creates a new `CommandList` instance. +--- +--- Parameters: +--- * parent - The parent object. +--- * uiFinder - A `axuielementObject` or `hs.uielement` to use when searching for the `CommandList`. +--- +--- Returns: +--- * The new `CommandList` instance. +function CommandList:initialize(parent, uiFinder) + Group.initialize(self, parent, uiFinder, CommandList.children) +end + --- cp.apple.finalcutpro.cmd.CommandList.label --- Field --- The StaticText that displays the label. -function CommandList.lazy.value:label() - return StaticText(self, self.UI:mutate( - ax.childMatching(StaticText.matches) - )) -end - --- cp.apple.finalcutpro.cmd.CommandList._commandsSplitGroup() -> cp.ui.SplitGroup --- Field --- The [SplitGroup](cp.ui.SplitGroup.md) containing the commands. -function CommandList.lazy.value:_commandsSplitGroup() - return SplitGroup(self, self.UI:mutate(ax.childMatching(SplitGroup.matches)), { - CommandGroups, Splitter, Commands - }) -end --- cp.apple.finalcutpro.cmd.CommandList.groups --- Field --- The [CommandGroups](cp.apple.finalcutpro.cmd.CommandGroups.md) for this CommandList. -function CommandList.lazy.value:groups() - return self._commandsSplitGroup.children[1] -end + +--- cp.apple.finalcutpro.cmd.CommandList.commands +--- Field +--- The [Commands](cp.apple.finalcutpro.cmd.Commands.md) for this CommandList. --- cp.apple.finalcutpro.cmd.CommandList.splitter --- Field --- The [Splitter](cp.ui.Splitter.md) for this CommandList. function CommandList.lazy.value:splitter() - return self._commandsSplitGroup.children[2] -end - ---- cp.apple.finalcutpro.cmd.CommandList.commands ---- Field ---- The [Commands](cp.apple.finalcutpro.cmd.Commands.md) for this CommandList. -function CommandList.lazy.value:commands() - return self._commandsSplitGroup.children[3] + return self.splitters[1] end return CommandList \ No newline at end of file diff --git a/src/extensions/cp/apple/finalcutpro/inspector/audio/AudioInspector.lua b/src/extensions/cp/apple/finalcutpro/inspector/audio/AudioInspector.lua index 7f4519bfe..c2c2c726b 100644 --- a/src/extensions/cp/apple/finalcutpro/inspector/audio/AudioInspector.lua +++ b/src/extensions/cp/apple/finalcutpro/inspector/audio/AudioInspector.lua @@ -40,12 +40,14 @@ --- ```lua --- audio:stabilization():smoothing():show():value(1.5) --- ``` +--- +--- Extends: [BasePanel](cp.apple.finalcutpro.inspector.BasePanel.md) +--- Delegates To: [content](#content) local require = require -- local log = require("hs.logger").new("AudioInspector") -local fn = require "cp.fn" local ax = require "cp.fn.ax" local axutils = require "cp.ui.axutils" @@ -53,8 +55,8 @@ local axutils = require "cp.ui.axutils" local Group = require "cp.ui.Group" local ScrollArea = require "cp.ui.ScrollArea" local SplitGroup = require "cp.ui.SplitGroup" -local Splitter = require "cp.ui.Splitter" local TextArea = require "cp.ui.TextArea" +local has = require "cp.ui.has" local BasePanel = require "cp.apple.finalcutpro.inspector.BasePanel" local AudioConfiguration = require "cp.apple.finalcutpro.inspector.audio.AudioConfiguration" @@ -63,11 +65,10 @@ local MainProperties = require "cp.apple.finalcut local childMatching = axutils.childMatching -local chain = fn.chain -local get = fn.table.get -local filter = fn.value.filter +local alias = has.alias local AudioInspector = BasePanel:subclass("cp.apple.finalcutpro.inspector.audio.AudioInspector") + :delegateTo("content") --- cp.apple.finalcutpro.inspector.audio.AudioInspector.matches(element) --- Function @@ -84,18 +85,6 @@ function AudioInspector.static.matches(element) return split and #split > 5 or false end -AudioInspector.static.matches2 = ax.matchesIf( - chain // - -- it a BasePanel that is also a Group... - filter(BasePanel.matches, Group.matches) >> - -- with exactly one child... - ax.children >> filter(fn.table.hasExactly(1)) >> - -- which is a SplitGroup... - get(1) >> filter(SplitGroup.matches) >> - -- who has more than 5 children. - ax.children >> fn.table.hasAtLeast(5) -) - --- cp.apple.finalcutpro.inspector.audio.AudioInspector(parent) -> cp.apple.finalcutpro.audio.AudioInspector --- Constructor --- Creates a new `AudioInspector` object @@ -111,22 +100,25 @@ end function AudioInspector.lazy.value:content() local ui = self.UI:mutate(ax.childMatching(SplitGroup.matches)) - return SplitGroup(self, ui, { - TopProperties, - MainProperties, - Group, - Splitter, - TextArea, - ScrollArea - }) + return SplitGroup(self, ui, + { + alias "topProperties" { TopProperties }, + Group, + alias "mainProperties" { MainProperties }, + }, + { + TextArea, + ScrollArea, + } + ) end function AudioInspector.lazy.value:topProperties() - return self.content.children[1] + return self.content.sections[1].topProperties end function AudioInspector.lazy.value:mainProperties() - return self.content.children[2] + return self.content.sections[1].mainProperties end --- cp.apple.finalcutpro.inspector.color.VideoInspector.volume diff --git a/src/extensions/cp/apple/finalcutpro/main/CompoundClipSheet.lua b/src/extensions/cp/apple/finalcutpro/main/CompoundClipSheet.lua new file mode 100644 index 000000000..b728e6b2b --- /dev/null +++ b/src/extensions/cp/apple/finalcutpro/main/CompoundClipSheet.lua @@ -0,0 +1,144 @@ +--- === cp.apple.finalcutpro.main.CompoundClipSheet === +--- +--- Represents the `New Compound Clip` [Sheet](cp.ui.Sheet.md) in Final Cut Pro. +--- +--- Extends: [cp.ui.Sheet](cp.ui.Sheet.md) +--- Delegates To: [children](#children) + +local require = require + +-- local log = require("hs.logger").new("CompoundClipSheet") + +local strings = require "cp.apple.finalcutpro.strings" +local fn = require "cp.fn" +local ax = require "cp.fn.ax" +local Button = require "cp.ui.Button" +local Sheet = require "cp.ui.Sheet" +local StaticText = require "cp.ui.StaticText" +local TextField = require "cp.ui.TextField" +local PopUpButton = require "cp.ui.PopUpButton" + +local has = require "cp.ui.has" + +local go = require "cp.rx.go" + +local If = go.If +local WaitUntil = go.WaitUntil + +local chain = fn.chain +local get = fn.table.get +local filter = fn.value.filter + +local alias, list = has.alias, has.list + +local CompoundClipSheet = Sheet:subclass("cp.apple.finalcutpro.main.CompoundClipSheet") + :delegateTo("children", "method") + +local COMPOUND_CLIP_NAME_KEY = "FFAnchoredSequenceSettingsModule_compoundClipLabel" + +local function isCompoundClipName(value) + return value == strings:find(COMPOUND_CLIP_NAME_KEY) +end + +--- cp.apple.finalcutpro.main.CompoundClipSheet.matches(element) -> boolean +--- Function +--- Checks if the element is a `CompoundClipSheet`. +--- +--- Parameters: +--- * element - An `axuielement` to check. +--- +--- Returns: +--- * `true` if it matches, otherwise `false`. +CompoundClipSheet.static.matches = ax.matchesIf( + Sheet.matches, + chain // ax.childrenTopDown >> get(1) + >> filter(StaticText.matches) + >> get "AXValue" >> isCompoundClipName +) + +--- cp.apple.finalcutpro.main.CompoundClipSheet.children +--- Constant +--- UI Handler for the children of the `CompoundClipSheet`. +CompoundClipSheet.static.children = list { + StaticText, alias "compoundClipName" { TextField }, + StaticText, alias "inEvent" { PopUpButton }, + alias "cancel" { Button }, + alias "ok" { Button }, + has.ended +} + +function CompoundClipSheet:initialize(parent) + local ui = parent.UI:mutate(ax.childMatching(CompoundClipSheet.matches)) + + Sheet.initialize(self, parent, ui, CompoundClipSheet.children) +end + +--- cp.apple.finalcutpro.main.CompoundClipSheet.compoundClipName +--- Field +--- The `TextField` for the Compound Clip Name. + +--- cp.apple.finalcutpro.main.CompoundClipSheet.clipName +--- Field +--- The `TextField` for the Clip Name. +function CompoundClipSheet.lazy.value:clipName() + return self.compoundClipName +end + +--- cp.apple.finalcutpro.main.CompoundClipSheet.inEvent +--- Field +--- The `PopUpButton` for the "In Event" setting. + +-------------------------------------------------------------------------------- +-- Standard buttons +-------------------------------------------------------------------------------- + +-- NOTE: Skipping the "Cancel" button because [Sheet](cp.ui.Sheet.md) already defines one. + +--- cp.apple.finalcutpro.main.CompoundClipSheet.ok +--- Field +--- The `Button` for the "Ok" button. + +-------------------------------------------------------------------------------- +-- Other Functions +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.CompoundClipSheet:doShow() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to show the sheet. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function CompoundClipSheet.lazy.method:doShow() + local app = self:app() + return If(app:doLaunch()) + :Then(app.browser) + :Then( + app.menu:doSelectMenu({"File", "New", "Compound Clip…"}) + ) + :Then( + WaitUntil(self.isShowing):TimeoutAfter(2000) + ) + :Otherwise(false) + :Label("CompoundClipSheet:doShow") +end + +--- cp.apple.finalcutpro.main.CompoundClipSheet:doHide() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to hide the sheet, if it is visible. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function CompoundClipSheet.lazy.method:doHide() + return If(self.isShowing):Is(true):Then( + self.cancel:doPress() + ) + :Label("CompoundClipSheet:doHide") +end + +return CompoundClipSheet \ No newline at end of file diff --git a/src/extensions/cp/apple/finalcutpro/main/EffectsBrowser.lua b/src/extensions/cp/apple/finalcutpro/main/EffectsBrowser.lua index f0e8b9612..a1a14b4a0 100644 --- a/src/extensions/cp/apple/finalcutpro/main/EffectsBrowser.lua +++ b/src/extensions/cp/apple/finalcutpro/main/EffectsBrowser.lua @@ -4,20 +4,27 @@ local require = require --- local log = require "hs.logger".new("EffectsBrowser") +--local log = require "hs.logger".new("EffectsBrowser") local geometry = require "hs.geometry" local fnutils = require "hs.fnutils" -local axutils = require "cp.ui.axutils" -local Group = require "cp.ui.Group" +local fn = require "cp.fn" +local ax = require "cp.fn.ax" local tools = require "cp.tools" local just = require "cp.just" -local Table = require "cp.ui.OldTable" -local ScrollArea = require "cp.ui.ScrollArea" +local has = require "cp.ui.has" local CheckBox = require "cp.ui.CheckBox" +local Grid = require "cp.ui.Grid" +local Group = require "cp.ui.Group" +local Image = require "cp.ui.Image" +local Menu = require "cp.ui.Menu" +local Table = require "cp.ui.OldTable" local PopUpButton = require "cp.ui.PopUpButton" +local ScrollArea = require "cp.ui.ScrollArea" +local SplitGroup = require "cp.ui.SplitGroup" +local StaticText = require "cp.ui.StaticText" local TextField = require "cp.ui.TextField" local Do = require "cp.rx.go.Do" @@ -27,8 +34,19 @@ local WaitUntil = require "cp.rx.go.WaitUntil" local ninjaDoubleClick = tools.ninjaDoubleClick local upper = tools.upper +local chain = fn.chain + +local list, alias, optional = has.list, has.alias, has.optional local EffectsBrowser = Group:subclass("cp.apple.finalcutpro.main.EffectsBrowser") + :delegateTo("area") + +--- === cp.apple.finalcutpro.main.EffectsBrowser.Effect === +--- +--- An `Effect` is a single effect/transition in the Effects Browser. +--- +--- Extends: [cp.ui.Image](cp.ui.Image.md) +EffectsBrowser.static.Effect = Image:subclass("cp.apple.finalcutpro.main.EffectsBrowser.Effect") --- cp.apple.finalcutpro.main.EffectsBrowser.EFFECTS -> string --- Constant @@ -53,32 +71,55 @@ function EffectsBrowser.static.matches(element) return Group.matches(element) and #element == 4 end +-- Describes the structure of the AXChildren list. +EffectsBrowser.static.children = list { + alias "main" { + SplitGroup:with( + alias "categories" { + StaticText, -- Effects + alias "sidebar" { Table }, + }, + alias "effects" { + alias "only4K" { optional { CheckBox } }, + alias "group" { PopUpButton }, + alias "area" { ScrollArea:containing( + Grid:containing(EffectsBrowser.Effect) + ) } + } + ) + }, + alias "options" { Group:containing { + list { alias "sidebarToggle" { CheckBox } } + } }, + alias "search" { TextField }, + alias "status" { StaticText }, + has.ended +} + + --- cp.apple.finalcutpro.main.EffectsBrowser(parent, type) -> EffectsBrowser --- Constructor --- Creates a new `EffectsBrowser` instance. --- --- Parameters: --- * parent - The parent object. ---- * type - A string determining whether the Effects Browser is for Effects (`cp.apple.finalcutpro.main.EffectsBrowser.EFFECTS`) or Transitions (`cp.apple.finalcutpro.main.EffectsBrowser.TRANSITIONS`). +--- * type - A string determining whether the Effects Browser is for Effects (`cp.apple.finalcutpro.main.EffectsBrowser.EFFECTS`) +--- or Transitions (`cp.apple.finalcutpro.main.EffectsBrowser.TRANSITIONS`). --- --- Returns: --- * A new `EffectsBrowser` object. function EffectsBrowser:initialize(parent, type) self._type = type - local UI = parent.mainUI:mutate(function(original) - if self:isShowing() then - return axutils.cache(self, "_ui", function() - return axutils.childMatching(original(), EffectsBrowser.matches) - end, - EffectsBrowser.matches) - end - end) + local UI = parent.mainUI:mutate( + ax.cache(self, "_ui", EffectsBrowser.matches)( + chain // ax.children >> fn.table.firstMatching(EffectsBrowser.matches) + ) + ) - Group.initialize(self, parent, UI) + Group.initialize(self, parent, UI, self.class.children) end - --- cp.apple.finalcutpro.main.EffectsBrowser:type() -> App --- Method --- Type of Effects Browser. @@ -145,7 +186,7 @@ end --- * The `Statement`. function EffectsBrowser.lazy.method:doShow() local button = self.toggleButton - return Given(self:app().timeline.doShow()) + return Given(self:app().timeline:doShow()) :Then(button:doCheck()) :Then(WaitUntil(button.isShowing)) end @@ -181,7 +222,7 @@ function EffectsBrowser.lazy.method:doHide() local app = self:app() return If(app.timeline.isShowing) - :Then(button:doCheck()) + :Then(button:doUncheck()) :Then(WaitUntil(button.isShowing):Is(false)) end @@ -402,7 +443,7 @@ end --- Returns: --- * `axuielementObject` object. function EffectsBrowser:audioCategoryRowsUI() - local audio = self:app():string("FFAudio"):upper() + local audio = upper(self:app():string("FFAudio")) return self:_startEndRowsUI(audio, nil) end @@ -540,7 +581,14 @@ end --- Returns: --- * `axuielementObject` object. function EffectsBrowser:currentItemsUI() - return self.contents:childrenUI() + return self.area.contents:childrenUI() +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.currentItems -> cp.ui.ElementRepeater +--- Field +--- The current items. +function EffectsBrowser.lazy.value:currentItems() + return self.area.contents.children end --- cp.apple.finalcutpro.main.EffectsBrowser:selectedItemsUI() -> axuielementObject @@ -553,7 +601,14 @@ end --- Returns: --- * `axuielementObject` object. function EffectsBrowser.lazy.prop:selectedItemsUI() - return self.contents.selectedChildrenUI + return self.area.contents.selectedChildrenUI +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.selectedItems -> cp.ui.ElementRepeater +--- Field +--- The selected items. +function EffectsBrowser.lazy.value:selectedItems() + return self.area.contents.selectedChildren end --- cp.apple.finalcutpro.main.EffectsBrowser:itemIsSelected(itemUI) -> boolean @@ -588,8 +643,8 @@ end --- * The `EffectsBrowser` object. function EffectsBrowser:applyItem(itemUI) if itemUI then - self.contents:showChild(itemUI) - local uiFrame = itemUI:attributeValue("AXFrame") + self.area:showChildUI(itemUI) + local uiFrame = itemUI.AXFrame if uiFrame then local rect = geometry.rect(uiFrame) local targetPoint = rect and rect.center @@ -611,9 +666,9 @@ end --- Returns: --- * A table function EffectsBrowser:getCurrentTitles() - local contents = self.contents:childrenUI() - if contents ~= nil then - return fnutils.map(contents, function(child) + local area = self.area:childrenUI() + if area ~= nil then + return fnutils.map(area, function(child) return child:attributeValue("AXTitle") end) end @@ -626,65 +681,41 @@ end -- ----------------------------------------------------------------------------- ---- cp.apple.finalcutpro.main.EffectsBrowser:mainGroupUI() -> ---- Field ---- Main Group UI. -function EffectsBrowser.lazy.prop:mainGroupUI() - return self.UI:mutate(function(original) - return axutils.cache(self, "_mainGroup", - function() - local ui = original() - return ui and axutils.childWithRole(ui, "AXSplitGroup") - end) - end) -end - --- cp.apple.finalcutpro.main.EffectsBrowser.sidebar --- Field --- The sidebar `Table` object. function EffectsBrowser.lazy.value:sidebar() - return Table(self, function() - return axutils.childFromLeft(self:mainGroupUI(), 1, ScrollArea.matches) - end) + return self.children.main.categories.sidebar end ---- cp.apple.finalcutpro.main.EffectsBrowser.contents +--- cp.apple.finalcutpro.main.EffectsBrowser.area --- Field --- The Effects Browser Contents. -function EffectsBrowser.lazy.value:contents() - return ScrollArea(self, function() - return axutils.childFromRight(self:mainGroupUI(), 1, function(element) - return element:attributeValue("AXRole") == "AXScrollArea" - end) - end) +function EffectsBrowser.lazy.value:area() + return self.children.main.effects.area +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.group +--- Field +--- The group `PopUpButton`. +function EffectsBrowser.lazy.value:group() + return self.children.main.effects.group end --- cp.apple.finalcutpro.main.EffectsBrowser.sidebarToggle --- Field --- The Sidebar Toggle. function EffectsBrowser.lazy.value:sidebarToggle() - return CheckBox(self, function() - return axutils.childWithRole(self:UI(), "AXCheckBox") - end) + return self.children.options.sidebarToggle end ---- cp.apple.finalcutpro.main.EffectsBrowser.group +--- cp.apple.finalcutpro.main.EffectsBrowser.search --- Field ---- The group `PopUpButton`. -function EffectsBrowser.lazy.value:group() - return PopUpButton(self, function() - return axutils.childWithRole(self:mainGroupUI(), "AXPopUpButton") - end) -end +--- The Search [TextField](cp.ui.TextField.md) object. ---- cp.apple.finalcutpro.main.EffectsBrowser.search +--- cp.apple.finalcutpro.main.EffectsBrowser.status --- Field ---- The Search `PopUpButton` object. -function EffectsBrowser.lazy.value:search() - return TextField(self, function() - return axutils.childWithRole(self:UI(), "AXTextField") - end) -end +--- The Status [StaticText](cp.ui.StaticText.md). --- cp.apple.finalcutpro.main.EffectsBrowser:saveLayout() -> table --- Method @@ -707,7 +738,7 @@ function EffectsBrowser:saveLayout() layout.sidebar = self.sidebar:saveLayout() self.sidebarToggle:loadLayout(layout.sidebarToggle) - layout.contents = self.contents:saveLayout() + layout.area = self.area:saveLayout() layout.group = self.group:saveLayout() layout.search = self.search:saveLayout() end @@ -734,10 +765,115 @@ function EffectsBrowser:loadLayout(layout) self.group:loadLayout(layout.group) self.search:loadLayout(layout.search) - self.contents:loadLayout(layout.contents) + self.area:loadLayout(layout.area) else self:hide() end end +----------------------------------------------------------------------- +-- EffectsBrowser.Effect +----------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect.menu +--- Field +--- The menu for the effect. +function EffectsBrowser.Effect.lazy.value:menu() + return Menu(self, self.UI:mutate( + ax.childMatching(Menu.matches) + )) +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect:doApply() -> cp.rx.go.Statement +--- Method +--- Applies the effect. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A `Statement` object. +function EffectsBrowser.Effect:doApply() + return Given(self.UI):Then(function(itemUI) + if not itemUI then return end + local uiFrame = itemUI.AXFrame + if not uiFrame then return end + + self:parent():showContentsAt(uiFrame) + -- update the frame to the new location + uiFrame = itemUI.AXFrame + local rect = geometry.rect(uiFrame) + local targetPoint = rect and rect.center + if targetPoint then + ninjaDoubleClick(targetPoint) + end + end) + :Label("EffectsBrowser.Effect:doApply()") +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect:doShowMenu() -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) that will show the menu for the effect. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The [Statement](cp.rx.go.Statement.md). +function EffectsBrowser.Effect.lazy.method:doShowMenu() + return Do(self:doPerformAction("AXShowMenu")) + :Label("cp.apple.finalcutpro.main.EffectsBrowser.Effect:doShowMenu") +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect:doSetAsDefaultEffect() -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) that will set the effect as the "default", which can be triggered with a keyboard shortcut. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) +function EffectsBrowser.Effect.lazy.method:doSetAsDefaultEffect() + return Do(self:doShowMenu()) + :Then(self.menu:doSelectItemWhere( + chain // ax.attribute("AXIdentifier") >> fn.value.is("addDefaultVideoEffectSet:") + )) + :Label("cp.apple.finalcutpro.main.EffectsBrowser.Effect:doSetAsDefaultEffect") +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect:doOpenInMotion() -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) that will open the effect in the Apple Motion editor. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) +function EffectsBrowser.Effect.lazy.method:doOpenInMotion() + return Do(self:doShowMenu()) + :Then(self.menu:doSelectItemWhere( + chain // ax.attribute("AXIdentifier") >> fn.value.is("openEffectInEditor:") + )) + :Label("cp.apple.finalcutpro.main.EffectsBrowser.Effect:doOpenInMotion") +end + +--- cp.apple.finalcutpro.main.EffectsBrowser.Effect:doRevealInFinder() -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) that will reveal the effect in Finder. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) +function EffectsBrowser.Effect.lazy.method:doRevealInFinder() + return Do(self:doShowMenu()) + :Then(self.menu:doSelectItemWhere( + chain // ax.attribute("AXIdentifier") >> fn.value.is("revealInFinder:") + )) + :Label("cp.apple.finalcutpro.main.EffectsBrowser.Effect:doRevealInFinder") +end + return EffectsBrowser diff --git a/src/extensions/cp/apple/finalcutpro/main/GeneratorsBrowser.lua b/src/extensions/cp/apple/finalcutpro/main/GeneratorsBrowser.lua index 7dc4aa489..af1bebaef 100644 --- a/src/extensions/cp/apple/finalcutpro/main/GeneratorsBrowser.lua +++ b/src/extensions/cp/apple/finalcutpro/main/GeneratorsBrowser.lua @@ -15,9 +15,14 @@ local tools = require "cp.tools" local geometry = require "hs.geometry" local fnutils = require "hs.fnutils" +local has = require "cp.ui.has" +local CheckBox = require "cp.ui.CheckBox" +local Grid = require "cp.ui.Grid" local Group = require "cp.ui.Group" +local Image = require "cp.ui.Image" local Table = require "cp.ui.OldTable" local ScrollArea = require "cp.ui.ScrollArea" +local SplitGroup = require "cp.ui.SplitGroup" local PopUpButton = require "cp.ui.PopUpButton" local TextField = require "cp.ui.TextField" @@ -28,17 +33,47 @@ local WaitUntil = go.WaitUntil local cache = axutils.cache local childWithRole = axutils.childWithRole -local childMatching = axutils.childMatching local ninjaDoubleClick = tools.ninjaDoubleClick +local list, alias, optional = has.list, has.alias, has.optional + local GeneratorsBrowser = Group:subclass("cp.apple.finalcutpro.main.GeneratorsBrowser") + +--- === cp.apple.finalcutpro.main.GeneratorsBrowser.Item === +--- +--- A single Title/Generator item. +--- +--- Extends: [cp.ui.Image](cp.ui.Image.md) +GeneratorsBrowser.static.Item = Image:subclass("cp.apple.finalcutpro.main.GeneratorsBrowser.Item") + --- cp.apple.finalcutpro.main.GeneratorsBrowser.TITLE -> string --- Constant --- Titles & Generators Title. GeneratorsBrowser.static.TITLE = "Titles and Generators" +-- Describes the structure of the AXChildren list. +GeneratorsBrowser.static.children = list { + alias "librariesToggle" { CheckBox }, + alias "mediaToggle" { CheckBox }, + alias "generatorsToggle" { CheckBox }, + alias "group" { PopUpButton }, + alias "only4K" { optional { CheckBox } }, + alias "split" { SplitGroup:with( + alias "sidebar" { Table }, + alias "right" { + alias "search" { TextField }, + Group:containing( + ScrollArea:containing( + Grid:containing(GeneratorsBrowser.Item) + ) + ) + } + ) }, + has.ended +} + --- cp.apple.finalcutpro.main.GeneratorsBrowser(parent) -> GeneratorsBrowser --- Constructor --- Creates a new `GeneratorsBrowser` instance. @@ -56,7 +91,7 @@ function GeneratorsBrowser:initialize(parent) end) end return nil - end)) + end), self.class.children) end ----------------------------------------------------------------------- @@ -100,6 +135,7 @@ function GeneratorsBrowser.lazy.method:doShow() return Do(menuBar:doSelectMenu({"Window", "Go To", GeneratorsBrowser.TITLE})) :Then(WaitUntil(self.isShowing)) :ThenYield() + :Label("GeneratorsBrowser:doShow()") end --- cp.apple.finalcutpro.main.GeneratorsBrowser:hide() -> GeneratorsBrowser @@ -119,6 +155,7 @@ end function GeneratorsBrowser.lazy.method:doHide() return Do(self:parent():doHide()) + :Label("GeneratorsBrowser:doHide()") end ----------------------------------------------------------------------------- @@ -144,39 +181,30 @@ end --- Field --- The sidebar object. function GeneratorsBrowser.lazy.value:sidebar() - return Table(self, function() - return childWithRole(self:mainGroupUI(), "AXScrollArea") - end) + return self.children.split.sidebar end --- cp.apple.finalcutpro.main.GeneratorsBrowser.contents --- Field --- The Generators Browser Contents. function GeneratorsBrowser.lazy.value:contents() - return ScrollArea(self, function() - local group = childMatching(self:mainGroupUI(), function(child) - return child:attributeValue("AXRole") == "AXGroup" and #child == 1 - end) - return group and group[1] - end) + return self.children.split.right[2].children end --- cp.apple.finalcutpro.main.GeneratorsBrowser.group --- Field --- The group. -function GeneratorsBrowser.lazy.value:group() - return PopUpButton(self, function() - return childMatching(self:UI(), PopUpButton.matches) - end) -end +-- function GeneratorsBrowser.lazy.value:group() +-- return PopUpButton(self, function() +-- return childMatching(self:UI(), PopUpButton.matches) +-- end) +-- end --- cp.apple.finalcutpro.main.GeneratorsBrowser.search --- Field --- Gets the Search TextField object. function GeneratorsBrowser.lazy.value:search() - return TextField(self, function() - return childMatching(self:mainGroupUI(), TextField.matches) - end) + return self.children.split.right.search end --- cp.apple.finalcutpro.main.GeneratorsBrowser:showSidebar() -> GeneratorsBrowser @@ -220,7 +248,11 @@ end --- Returns: --- * The `GeneratorsBrowser` object. function GeneratorsBrowser:showInstalledTitles() - self.group:selectItem(1) + self:showAllTitles() + local group = self.group + if group:value() ~= self:app():string("PEMediaBrowserInstalledTitlesMenuItem") then + group:selectItem(1) + end return self end @@ -234,7 +266,11 @@ end --- Returns: --- * The `GeneratorsBrowser` object. function GeneratorsBrowser:showInstalledGenerators() - self:showInstalledTitles() + self:showAllGenerators() + local group = self.group + if group:value() ~= self:app():string("PEMediaBrowserInstalledGeneratorsMenuItem") then + group:selectItem(1) + end return self end @@ -388,7 +424,7 @@ end --- * The `GeneratorsBrowser` object. function GeneratorsBrowser:applyItem(itemUI) if itemUI then - self.contents:showChild(itemUI) + self.contents:showChildUI(itemUI) local frame = itemUI:attributeValue("AXFrame") local targetPoint = geometry.rect(frame).center ninjaDoubleClick(targetPoint) @@ -441,15 +477,15 @@ function GeneratorsBrowser:saveLayout() return layout end ---- cp.apple.finalcutpro.main.GeneratorsBrowser:loadLayout(layout) -> none +--- cp.apple.finalcutpro.main.GeneratorsBrowser:loadLayout(layout) -> nil --- Method --- Loads a Generators Browser layout. --- --- Parameters: ---- * layout - A table containing the Generators Browser layout settings - created using `cp.apple.finalcutpro.main.GeneratorsBrowser:saveLayout()`. +--- * layout - A table containing the Generators Browser layout settings - created using [saveLayout](#saveLayout). --- --- Returns: ---- * None +--- * `nil` function GeneratorsBrowser:loadLayout(layout) if layout and layout.showing then self:show() @@ -459,4 +495,36 @@ function GeneratorsBrowser:loadLayout(layout) end end +function GeneratorsBrowser.Item:select() + self:parent():selectChild(self) +end + +function GeneratorsBrowser.Item.lazy.method:doSelect() + return self:parent():doSelectChild(self) +end + +function GeneratorsBrowser.Item:apply() + self:select() + self:app():selectMenu({"Edit", "Connect to Primary Storyline"}) +end + +--- cp.apple.finalcutpro.main.GeneratorsBrowser.Item:doApply() -> cp.rx.go.Statement +--- Method +--- Applies the current item. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A `Statement` object. +function GeneratorsBrowser.Item.lazy.method:doApply() + return Do(self:doSelect()) + :Then(self:app():doSelectMenu({"Edit", "Connect to Primary Storyline"})) + :Label("GeneratorsBrowser.Item:doApply()") +end + +function GeneratorsBrowser.Item:__valuestring() + return self:attributeValue("AXTitle") +end + return GeneratorsBrowser diff --git a/src/extensions/cp/apple/finalcutpro/main/LibrariesFilmstrip.lua b/src/extensions/cp/apple/finalcutpro/main/LibrariesFilmstrip.lua index 8a4bcb1da..e2634f9a5 100644 --- a/src/extensions/cp/apple/finalcutpro/main/LibrariesFilmstrip.lua +++ b/src/extensions/cp/apple/finalcutpro/main/LibrariesFilmstrip.lua @@ -122,7 +122,7 @@ end --- --- Returns: --- * `true` if clip A is above clip B, otherwise `false`. -function LibrariesFilmstrip.sortClips(a, b) +function LibrariesFilmstrip.static.sortClips(a, b) local aFrame = a:attributeValue("AXFrame") local bFrame = b:attributeValue("AXFrame") if aFrame.y < bFrame.y then -- a is above b @@ -174,7 +174,7 @@ end --- Returns: --- * A table of `axuielementObject` objects or `nil` if no clip UI could be found. function LibrariesFilmstrip:clipsUI(filterFn) - local ui = self.contentsUI() + local ui = self:contentsUI() if ui then local clips = childrenMatching(ui, function(child) return child:attributeValue("AXRole") == "AXGroup" @@ -188,7 +188,7 @@ function LibrariesFilmstrip:clipsUI(filterFn) return nil end ---- cp.apple.finalcutpro.main.LibrariesFilmstrip:clips(filterFn) -> table | nil +--- cp.apple.finalcutpro.main.LibrariesFilmstrip:clips([filterFn]) -> table | nil --- Function --- Gets clips using a custom filter. --- @@ -218,7 +218,7 @@ end --- Returns: --- * A table of `axuielementObject` objects or `nil` if no clips are selected. function LibrariesFilmstrip:selectedClipsUI() - local ui = self.contentsUI() + local ui = self:contentsUI() if ui then local children = ui:attributeValue("AXSelectedChildren") local clips = {} @@ -271,7 +271,7 @@ function LibrariesFilmstrip:showClip(clip) -------------------------------------------------------------------------------- -- We need to scroll: -------------------------------------------------------------------------------- - local oFrame = self.contentsUI():attributeValue("AXFrame") + local oFrame = self:contentsUI():attributeValue("AXFrame") local scrollHeight = oFrame.h - vFrame.h local vValue @@ -395,7 +395,7 @@ end --- * `true` if successful otherwise `false`. function LibrariesFilmstrip:selectAll(clips) clips = clips or self:clips() - local contents = self.contentsUI() + local contents = self:contentsUI() if clips and contents then local clipsUI = _clipsToUI(clips) contents:setAttributeValue("AXSelectedChildren", clipsUI) @@ -414,7 +414,7 @@ end --- Returns: --- * `true` if successful otherwise `false`. function LibrariesFilmstrip:deselectAll() - local contents = self.contentsUI() + local contents = self:contentsUI() if contents then contents:setAttributeValue("AXSelectedChildren", {}) return true diff --git a/src/extensions/cp/apple/finalcutpro/main/MulticamClipSheet.lua b/src/extensions/cp/apple/finalcutpro/main/MulticamClipSheet.lua new file mode 100644 index 000000000..3565ce8f4 --- /dev/null +++ b/src/extensions/cp/apple/finalcutpro/main/MulticamClipSheet.lua @@ -0,0 +1,333 @@ +--- === cp.apple.finalcutpro.main.MulticamClipSheet === +--- +--- Represents the `New Multicam Clip` [Sheet](cp.ui.Sheet.md) in Final Cut Pro. +--- +--- Extends: [cp.ui.Sheet](cp.ui.Sheet.md) +--- Delegates To: [children](#children) + +local require = require + +-- local log = require("hs.logger").new("MulticamClipSheet") + +local strings = require "cp.apple.finalcutpro.strings" +local fn = require "cp.fn" +local ax = require "cp.fn.ax" +local prop = require "cp.prop" +local Button = require "cp.ui.Button" +local CheckBox = require "cp.ui.CheckBox" +local Sheet = require "cp.ui.Sheet" +local StaticText = require "cp.ui.StaticText" +local TextField = require "cp.ui.TextField" +local PopUpButton = require "cp.ui.PopUpButton" + +local has = require "cp.ui.has" + +local go = require "cp.rx.go" + +local If = go.If +local WaitUntil = go.WaitUntil + +local chain = fn.chain +local get = fn.table.get +local filter = fn.value.filter + +local alias, list, oneOf = has.alias, has.list, has.oneOf +local optional = has.optional + +local MulticamClipSheet = Sheet:subclass("cp.apple.finalcutpro.main.MulticamClipSheet") + :delegateTo("children", "method") + +local MULTICAM_CLIP_NAME_KEY = "FFAnchoredSequenceSettingsModule_multiAngleLabel" + +local function isMulticamClipName(value) + return value == strings:find(MULTICAM_CLIP_NAME_KEY) +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.matches(element) -> boolean +--- Function +--- Checks if the element is a `MulticamClipSheet`. +--- +--- Parameters: +--- * element - An `axuielement` to check. +--- +--- Returns: +--- * `true` if it matches, otherwise `false`. +MulticamClipSheet.static.matches = ax.matchesIf( + Sheet.matches, + chain // ax.childrenTopDown >> get(1) + >> filter(StaticText.matches) + >> get "AXValue" >> isMulticamClipName +) + +--- cp.apple.finalcutpro.main.MulticamClipSheet.children +--- Constant +--- UI Handler for the children of the `MulticamClipSheet`. +MulticamClipSheet.static.children = list { + StaticText, alias "multicamClipName" { TextField }, + StaticText, alias "inEvent" { PopUpButton }, + StaticText, alias "startingTimecode" { TextField }, + alias "useAudioForSynchronization" { CheckBox }, + alias "method" { + oneOf { + alias "automatic" { + StaticText, StaticText, -- "Video and Audio", "Set based on common clip properties" + alias "settings" { StaticText }, + alias "useCustomSettings" { Button }, + }, + alias "custom" { + StaticText, alias "angleAssembly" { PopUpButton }, + StaticText, alias "angleClipOrdering" { PopUpButton }, + StaticText, alias "synchronization" { PopUpButton }, + StaticText, alias "videoFormat" { PopUpButton }, + StaticText, -- "Format" label + alias "videoResolution" { + oneOf { -- can either be a pop-up or width/height + alias "preset" { PopUpButton }, + alias "custom" { + alias "width" { TextField }, + StaticText, -- "X" + alias "height" { TextField }, + }, + } + }, + alias "videoRate" { PopUpButton }, + StaticText, StaticText, -- "Resolution", "Rate" labels + alias "videoProjection" { + optional { + alias "type" { PopUpButton }, + StaticText, -- "Projection Type" label + } + }, + StaticText, alias "renderingCodec" { PopUpButton }, + StaticText, -- "Codec" label + alias "renderingColorSpace" { PopUpButton }, + StaticText, -- "Color Space" label + StaticText, -- "Audio" + alias "audioChannels" { PopUpButton }, + alias "audioSampleRate" { PopUpButton }, + StaticText, StaticText, -- "Audio Channels", "Sample Rate" labels + alias "useAutomaticSettings" { Button }, + }, + } + }, + alias "cancel" { Button }, + alias "ok" { Button }, + has.ended +} + +function MulticamClipSheet:initialize(parent) + local ui = parent.UI:mutate(ax.childMatching(MulticamClipSheet.matches)) + + Sheet.initialize(self, parent, ui, MulticamClipSheet.children) +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.multicamClipName +--- Field +--- The `TextField` for the Multicam Clip Name. + +--- cp.apple.finalcutpro.main.CompoundClipSheet.clipName +--- Field +--- The `TextField` for the Clip Name. +function MulticamClipSheet.lazy.value:clipName() + return self.multicamClipName +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.inEvent +--- Field +--- The `PopUpButton` for the "In Event" setting. + +--- cp.apple.finalcutpro.main.MulticamClipSheet.startingTimecode +--- Field +--- The `TextField` for the Starting Timecode. + +--- cp.apple.finalcutpro.main.MulticamClipSheet.useAudioForSynchronization +--- Field +--- The `CheckBox` for the "Use Audio for Synchronization" setting. + +--- cp.apple.finalcutpro.main.MulticamClipSheet.disableAudioComponentsOnAVClips +--- Field +--- The `CheckBox` for the "Disable Audio Components on AV Clips" setting. + +-------------------------------------------------------------------------------- +-- Automatic Settings +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.MulticamClipSheet.useCustomSettings +--- Field +--- The `Button` for the "Use Custom Settings" button. +function MulticamClipSheet.lazy.value:useCustomSettings() + return self.method.automatic.useCustomSettings +end + +-------------------------------------------------------------------------------- +-- Custom Settings +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.MulticamClipSheet.angleAssembly +--- Field +--- The `PopUpButton` for the "Angle Assembly" setting. +function MulticamClipSheet.lazy.value:angleAssembly() + return self.method.custom.angleAssembly +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.angleClipOrdering +--- Field +--- The `PopUpButton` for the "Angle Clip Ordering" setting. +function MulticamClipSheet.lazy.value:angleClipOrdering() + return self.method.custom.angleClipOrdering +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.synchronization +--- Field +--- The `PopUpButton` for the "Synchronization" setting. +function MulticamClipSheet.lazy.value:synchronization() + return self.method.custom.synchronization +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.videoFormat +--- Field +--- The `PopUpButton` for the "Video Format" setting. +function MulticamClipSheet.lazy.value:videoFormat() + return self.method.custom.videoFormat +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.videoResolutionPreset +--- Field +--- The `PopUpButton` for the "Video Resolution" setting. +function MulticamClipSheet.lazy.value:videoResolutionPreset() + return self.method.custom.videoResolution.preset +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.videoResolutionCustomWidth +--- Field +--- The `TextField` for the "Video Resolution Width" setting width. +function MulticamClipSheet.lazy.value:videoResolutionCustomWidth() + return self.method.custom.videoResolution.custom.width +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.videoResolutionCustomHeight +--- Field +--- The `TextField` for the "Video Resolution" setting. +function MulticamClipSheet.lazy.value:videoResolutionCustomHeight() + return self.method.custom.videoResolution.custom.height +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.videoRate +--- Field +--- The `PopUpButton` for the "Video Rate" setting. +function MulticamClipSheet.lazy.value:videoRate() + return self.method.custom.videoRate +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.renderingCodec +--- Field +--- The `PopUpButton` for the "Rendering Codec" setting. +function MulticamClipSheet.lazy.value:renderingCodec() + return self.method.custom.renderingCodec +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.renderingColorSpace +--- Field +--- The `PopUpButton` for the "Rendering Color Space" setting. +function MulticamClipSheet.lazy.value:renderingColorSpace() + return self.method.custom.renderingColorSpace +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.audioChannels +--- Field +--- The `PopUpButton` for the "Audio Channels" setting. +function MulticamClipSheet.lazy.value:audioChannels() + return self.method.custom.audioChannels +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.audioSampleRate +--- Field +--- The `PopUpButton` for the "Audio Sample Rate" setting. +function MulticamClipSheet.lazy.value:audioSampleRate() + return self.method.custom.audioSampleRate +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet.useAutomaticSettings +--- Field +--- The `Button` for the "Use Automatic Settings" button. +function MulticamClipSheet.lazy.value:useAutomaticSettings() + return self.method.custom.useAutomaticSettings +end + +-------------------------------------------------------------------------------- +-- Standard buttons +-------------------------------------------------------------------------------- + +-- NOTE: Skipping the "Cancel" button because [Sheet](cp.ui.Sheet.md) already defines one. + +--- cp.apple.finalcutpro.main.MulticamClipSheet.ok +--- Field +--- The `Button` for the "Ok" button. + +-------------------------------------------------------------------------------- +-- Functionality +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.MulticamClipSheet.isAutomatic +--- Field +--- A `boolean` property indicating whether the sheet is in automatic or custom mode. +function MulticamClipSheet.lazy.prop:isAutomatic() + return prop( + function() + return self.useCustomSettings:isShowing() + end, + function(value) + if value == true and self.useAutomaticSettings:isShowing() then + self.useAutomaticSettings:doPress():Now() + elseif value == false and self.useCustomSettings:isShowing() then + self.useCustomSettings:doPress():Now() + end + end + ) + :monitor(self.UI) +end + +-------------------------------------------------------------------------------- +-- Other Functions +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.MulticamClipSheet:doShow() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to show the sheet. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function MulticamClipSheet.lazy.method:doShow() + local app = self:app() + return If(app:doLaunch()) + :Then(app.browser) + :Then( + app.menu:doSelectMenu({"File", "New", "Multicam Clip…"}) + ) + :Then( + WaitUntil(self.isShowing):TimeoutAfter(2000) + ) + :Otherwise(false) + :Label("MulticamClipSheet:doShow") +end + +--- cp.apple.finalcutpro.main.MulticamClipSheet:doHide() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to hide the sheet, if it is visible. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function MulticamClipSheet.lazy.method:doHide() + return If(self.isShowing):Is(true):Then( + self.cancel:doPress() + ) + :Label("MulticamClipSheet:doHide") +end + +return MulticamClipSheet \ No newline at end of file diff --git a/src/extensions/cp/apple/finalcutpro/main/PrimaryWindow.lua b/src/extensions/cp/apple/finalcutpro/main/PrimaryWindow.lua index 5e3565f8d..220b726b4 100644 --- a/src/extensions/cp/apple/finalcutpro/main/PrimaryWindow.lua +++ b/src/extensions/cp/apple/finalcutpro/main/PrimaryWindow.lua @@ -2,22 +2,25 @@ --- --- Primary Window Module. -local require = require +local require = require ---local log = require "hs.logger".new "primaryWindow" +--local log = require "hs.logger".new "primaryWindow" -local axutils = require "cp.ui.axutils" +local axutils = require "cp.ui.axutils" -local Window = require "cp.ui.Window" +local Window = require "cp.ui.Window" -local Inspector = require "cp.apple.finalcutpro.inspector.Inspector" -local PrimaryToolbar = require "cp.apple.finalcutpro.main.PrimaryToolbar" +local Inspector = require "cp.apple.finalcutpro.inspector.Inspector" +local CompoundClipSheet = require "cp.apple.finalcutpro.main.CompoundClipSheet" +local PrimaryToolbar = require "cp.apple.finalcutpro.main.PrimaryToolbar" +local MulticamClipSheet = require "cp.apple.finalcutpro.main.MulticamClipSheet" +local SynchronizedClipSheet = require "cp.apple.finalcutpro.main.SynchronizedClipSheet" -local Do = require "cp.rx.go.Do" -local If = require "cp.rx.go.If" +local Do = require "cp.rx.go.Do" +local If = require "cp.rx.go.If" -local class = require "middleclass" -local lazy = require "cp.lazy" +local class = require "middleclass" +local lazy = require "cp.lazy" local PrimaryWindow = class("cp.apple.finalcutpro.main.PrimaryWindow"):include(lazy) @@ -339,6 +342,28 @@ function PrimaryWindow.lazy.value:alert() return self.window.alert end +--- cp.apple.finalcutpro.main.PrimaryWindow.compoundClip +--- Field +--- Provides access to the [Compound Clip Sheet](cp.apple.finalcutpro.main.CompoundClip.md). +function PrimaryWindow.lazy.value:compoundClip() + return CompoundClipSheet(self) +end + + +--- cp.apple.finalcutpro.main.PrimaryWindow.multicamClip +--- Field +--- Provides access to the [Multicam Clip Sheet](cp.apple.finalcutpro.cp.apple.finalcutpro.main.MulticamClipSheet.md). +function PrimaryWindow.lazy.value:multicamClip() + return MulticamClipSheet(self) +end + +--- cp.apple.finalcutpro.main.PrimaryWindow.synchronizedClip +--- Field +--- Provides access to the [Synchronize Clips Sheet](cp.apple.finalcutpro.cp.apple.finalcutpro.main.SynchronizedClipSheet). +function PrimaryWindow.lazy.value:synchronizedClip() + return SynchronizedClipSheet(self) +end + -- This just returns the same element when it is called as a method. (eg. `fcp.viewer == fcp.viewer`) -- This is a bridge while we migrate to using `lazy.value` instead of `lazy.method` (or methods) -- in the FCPX API. diff --git a/src/extensions/cp/apple/finalcutpro/main/SynchronizedClipSheet.lua b/src/extensions/cp/apple/finalcutpro/main/SynchronizedClipSheet.lua new file mode 100644 index 000000000..93d692803 --- /dev/null +++ b/src/extensions/cp/apple/finalcutpro/main/SynchronizedClipSheet.lua @@ -0,0 +1,399 @@ +--- === cp.apple.finalcutpro.main.SynchronizedClipSheet === +--- +--- Represents the `Synchronize Clips` [Sheet](cp.ui.Sheet.md) in Final Cut Pro. +--- +--- Extends: [cp.ui.Sheet](cp.ui.Sheet.md) +--- Delegates To: [children](#children) + +local require = require + +-- local log = require("hs.logger").new("SynchronizedClipSheet") + +local strings = require "cp.apple.finalcutpro.strings" +local fn = require "cp.fn" +local ax = require "cp.fn.ax" +local prop = require "cp.prop" +local Button = require "cp.ui.Button" +local CheckBox = require "cp.ui.CheckBox" +local Sheet = require "cp.ui.Sheet" +local StaticText = require "cp.ui.StaticText" +local TextField = require "cp.ui.TextField" +local PopUpButton = require "cp.ui.PopUpButton" + +local has = require "cp.ui.has" + +local go = require "cp.rx.go" + +local If = go.If +local WaitUntil = go.WaitUntil + +local chain = fn.chain +local get = fn.table.get +local filter = fn.value.filter + +local alias, list, oneOf = has.alias, has.list, has.oneOf +local optional = has.optional + +local SynchronizedClipSheet = Sheet:subclass("cp.apple.finalcutpro.main.SynchronizedClipSheet") + :delegateTo("children", "method") + +local SYNCHRONIZED_CLIP_NAME_KEY = "FFAnchoredSequenceSettingsModule_synchronizedClipLabel" + +local function isSynchronizedClipName(value) + return value == strings:find(SYNCHRONIZED_CLIP_NAME_KEY) +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.matches(element) -> boolean +--- Function +--- Checks if the element is a `SynchronizedClipSheet`. +--- +--- Parameters: +--- * element - An `axuielement` to check. +--- +--- Returns: +--- * `true` if it matches, otherwise `false`. +SynchronizedClipSheet.static.matches = ax.matchesIf( + Sheet.matches, + chain // ax.childrenTopDown >> get(1) + >> filter(StaticText.matches) + >> get "AXValue" >> isSynchronizedClipName +) + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.children +--- Constant +--- UI Handler for the children of the `SynchronizedClipSheet`. +SynchronizedClipSheet.static.children = list { + StaticText, alias "synchronizedClipName" { TextField }, + StaticText, alias "inEvent" { PopUpButton }, + StaticText, alias "startingTimecode" { TextField }, + alias "useAudioForSynchronization" { CheckBox }, + alias "disableAudioComponentsOnAVClips" { CheckBox }, + alias "method" { + oneOf { + alias "automatic" { + StaticText, StaticText, -- "Video and Audio", "Set based on common clip properties" + alias "settings" { StaticText }, + alias "useCustomSettings" { Button }, + }, + alias "custom" { + StaticText, alias "synchronization" { PopUpButton }, + StaticText, alias "videoFormat" { PopUpButton }, + StaticText, -- "Format" label + alias "videoResolution" { + oneOf { -- can either be a pop-up or width/height + alias "preset" { PopUpButton }, + alias "custom" { + alias "width" { TextField }, + StaticText, -- "X" + alias "height" { TextField }, + }, + } + }, + alias "videoRate" { PopUpButton }, + StaticText, StaticText, -- "Resolution", "Rate" labels + alias "videoProjection" { + optional { + alias "type" { PopUpButton }, + StaticText, -- "Projection Type" label + } + }, + StaticText, alias "renderingCodec" { PopUpButton }, + StaticText, -- "Codec" label + alias "renderingColorSpace" { PopUpButton }, + StaticText, -- "Color Space" label + StaticText, -- "Audio" + alias "audioChannels" { PopUpButton }, + alias "audioSampleRate" { PopUpButton }, + StaticText, StaticText, -- "Audio Channels", "Sample Rate" labels + alias "useAutomaticSettings" { Button }, + }, + } + }, + alias "cancel" { Button }, + alias "ok" { Button }, + has.ended +} + +function SynchronizedClipSheet:initialize(parent) + local ui = parent.UI:mutate(ax.childMatching(SynchronizedClipSheet.matches)) + + Sheet.initialize(self, parent, ui, SynchronizedClipSheet.children) +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.synchronizedClipName +--- Field +--- The `TextField` for the Synchronized Clip Name. + + +--- cp.apple.finalcutpro.main.CompoundClipSheet.clipName +--- Field +--- The `TextField` for the Clip Name. +function SynchronizedClipSheet.lazy.value:clipName() + return self.synchronizedClipName +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.inEvent +--- Field +--- The `PopUpButton` for the "In Event" setting. + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.startingTimecode +--- Field +--- The `TextField` for the Starting Timecode. + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.useAudioForSynchronization +--- Field +--- The `CheckBox` for the "Use Audio for Synchronization" setting. + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.disableAudioComponentsOnAVClips +--- Field +--- The `CheckBox` for the "Disable Audio Components on AV Clips" setting. + +-------------------------------------------------------------------------------- +-- Automatic Settings +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.useCustomSettings +--- Field +--- The `Button` for the "Use Custom Settings" button. +function SynchronizedClipSheet.lazy.value:useCustomSettings() + return self.method.automatic.useCustomSettings +end + +-------------------------------------------------------------------------------- +-- Custom Settings +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.synchronization +--- Field +--- The `PopUpButton` for the "Synchronization" setting. +function SynchronizedClipSheet.lazy.value:synchronization() + return self.method.custom.synchronization +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoFormat +--- Field +--- The `PopUpButton` for the "Video Format" setting. +function SynchronizedClipSheet.lazy.value:videoFormat() + return self.method.custom.videoFormat +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoResolutionPreset +--- Field +--- The `PopUpButton` for the "Video Resolution" setting. +function SynchronizedClipSheet.lazy.value:videoResolutionPreset() + return self.method.custom.videoResolution.preset +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoResolutionCustomWidth +--- Field +--- The `TextField` for the "Video Resolution Width" setting width. +function SynchronizedClipSheet.lazy.value:videoResolutionCustomWidth() + return self.method.custom.videoResolution.custom.width +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoResolutionCustomHeight +--- Field +--- The `TextField` for the "Video Resolution" setting. +function SynchronizedClipSheet.lazy.value:videoResolutionCustomHeight() + return self.method.custom.videoResolution.custom.height +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoRate +--- Field +--- The `PopUpButton` for the "Video Rate" setting. +function SynchronizedClipSheet.lazy.value:videoRate() + return self.method.custom.videoRate +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.videoProjectionType +--- Field +--- The `PopUpButton` for the "Video Projection Type" setting. +function SynchronizedClipSheet.lazy.value:videoProjectionType() + return self.method.custom.videoProjection.type +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.renderingCodec +--- Field +--- The `PopUpButton` for the "Rendering Codec" setting. +function SynchronizedClipSheet.lazy.value:renderingCodec() + return self.method.custom.renderingCodec +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.renderingColorSpace +--- Field +--- The `PopUpButton` for the "Rendering Color Space" setting. +function SynchronizedClipSheet.lazy.value:renderingColorSpace() + return self.method.custom.renderingColorSpace +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.audioChannels +--- Field +--- The `PopUpButton` for the "Audio Channels" setting. +function SynchronizedClipSheet.lazy.value:audioChannels() + return self.method.custom.audioChannels +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.audioSampleRate +--- Field +--- The `PopUpButton` for the "Audio Sample Rate" setting. +function SynchronizedClipSheet.lazy.value:audioSampleRate() + return self.method.custom.audioSampleRate +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.useAutomaticSettings +--- Field +--- The `Button` for the "Use Automatic Settings" button. +function SynchronizedClipSheet.lazy.value:useAutomaticSettings() + return self.method.custom.useAutomaticSettings +end + +-------------------------------------------------------------------------------- +-- Standard buttons +-------------------------------------------------------------------------------- + +-- NOTE: Skipping the "Cancel" button because [Sheet](cp.ui.Sheet.md) already defines one. + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.ok +--- Field +--- The `Button` for the "Ok" button. + +-------------------------------------------------------------------------------- +-- Functionality +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet.isAutomatic +--- Field +--- A `boolean` property indicating whether the sheet is in automatic or custom mode. +function SynchronizedClipSheet.lazy.prop:isAutomatic() + return prop( + function() + return self.useCustomSettings:isShowing() + end, + function(value) + if value == true and self.useAutomaticSettings:isShowing() then + self.useAutomaticSettings:doPress():Now() + elseif value == false and self.useCustomSettings:isShowing() then + self.useCustomSettings:doPress():Now() + end + end + ) + :monitor(self.UI) +end + +-------------------------------------------------------------------------------- +-- Other Functions +-------------------------------------------------------------------------------- + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet:doShow() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to show the sheet. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function SynchronizedClipSheet.lazy.method:doShow() + local app = self:app() + return If(app:doLaunch()) + :Then(app.browser) + :Then( + app.menu:doSelectMenu({"Clip", "Synchronize Clips…"}) + ) + :Then( + WaitUntil(self.isShowing):TimeoutAfter(2000) + ) + :Otherwise(false) + :Label("SynchronizedClipSheet:doShow") +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet:doHide() +--- Method +--- A [Statement](cp.rx.go.Statement.md) that attempt to hide the sheet, if it is visible. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` object. +function SynchronizedClipSheet.lazy.method:doHide() + return If(self.isShowing):Is(true):Then( + self.cancel:doPress() + ) + :Label("SynchronizedClipSheet:doHide") +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet:saveLayout() -> table +--- Method +--- Saves the current layout of the sheet. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A table containing the layout of the sheet. +function SynchronizedClipSheet.lazy.method:saveLayout() + if not self:isShowing() then return {} end + + local layout = { + clipName = self.clipName:saveLayout(), + inEvent = self.inEvent:saveLayout(), + startingTimecode = self.startingTimecode:saveLayout(), + useAudioForSynchronization = self.useAudioForSynchronization:saveLayout(), + disableAudioComponentsOnAVClips = self.disableAudioComponentsOnAVClips:saveLayout(), + isAutomatic = self:isAutomatic(), + } + + if not self:isAutomatic() then + layout.synchronization = self.synchronization:saveLayout() + layout.videoFormat = self.videoFormat:saveLayout() + layout.videoResolutionPreset = self.videoResolutionPreset:saveLayout() + layout.videoResolutionCustomWidth = self.videoResolutionCustomWidth:saveLayout() + layout.videoResolutionCustomHeight = self.videoResolutionCustomHeight:saveLayout() + layout.videoRate = self.videoRate:saveLayout() + layout.videoProjectionType = self.videoProjectionType:saveLayout() + layout.renderingCodec = self.renderingCodec:saveLayout() + layout.renderingColorSpace = self.renderingColorSpace:saveLayout() + layout.audioChannels = self.audioChannels:saveLayout() + layout.audioSampleRate = self.audioSampleRate:saveLayout() + end + + return layout +end + +--- cp.apple.finalcutpro.main.SynchronizedClipSheet:loatLayout(layout) -> nil +--- Method +--- Loads the layout of the sheet. +--- +--- Parameters: +--- * layout - The layout of the sheet. +--- +--- Returns: +--- * None +function SynchronizedClipSheet.lazy.method:loadLayout(layout) + if not layout then return end + + self.clipName:loadLayout(layout.clipName) + self.inEvent:loadLayout(layout.inEvent) + self.startingTimecode:loadLayout(layout.startingTimecode) + self.useAudioForSynchronization:loadLayout(layout.useAudioForSynchronization) + self.disableAudioComponentsOnAVClips:loadLayout(layout.disableAudioComponentsOnAVClips) + self.isAutomatic(layout.isAutomatic()) + + if not self:isAutomatic() then + self.synchronization:loadLayout(layout.synchronization) + self.videoFormat:loadLayout(layout.videoFormat) + self.videoResolutionPreset:loadLayout(layout.videoResolutionPreset) + self.videoResolutionCustomWidth:loadLayout(layout.videoResolutionCustomWidth) + self.videoResolutionCustomHeight:loadLayout(layout.videoResolutionCustomHeight) + self.videoRate:loadLayout(layout.videoRate) + self.videoProjectionType:loadLayout(layout.videoProjectionType) + self.renderingCodec:loadLayout(layout.renderingCodec) + self.renderingColorSpace:loadLayout(layout.renderingColorSpace) + self.audioChannels:loadLayout(layout.audioChannels) + self.audioSampleRate:loadLayout(layout.audioSampleRate) + end +end + + +return SynchronizedClipSheet \ No newline at end of file diff --git a/src/extensions/cp/apple/finalcutpro/timeline/Duration.lua b/src/extensions/cp/apple/finalcutpro/timeline/Duration.lua new file mode 100644 index 000000000..da839a08a --- /dev/null +++ b/src/extensions/cp/apple/finalcutpro/timeline/Duration.lua @@ -0,0 +1,57 @@ +--- === cp.apple.finalcutpro.timeline.Duration === +--- +--- Represents the duration field in the Final Cut Pro timeline's toolbar. +--- +--- The `value` will be a `string` with one of the two patterns: +--- +--- * `""` - A single timecode string, with the total duration of the current timeline. +--- * `" / "` - Two timecodes, separated by a forward slash. +--- +--- The timecode pattern will vary, depending on the current timecode format (as specified in FCP Preferences). +--- +--- To make life easier, this element adds two properties: +--- +--- * `total` - The total duration of the current timeline (may be `nil`). +--- * `selection` - The total duration of selected clips. +--- +--- Extends: [cp.ui.StaticText](cp.ui.StaticText.md) + +local require = require + +local StaticText = require "cp.ui.StaticText" + +local Duration = StaticText:subclass("cp.apple.finalcutpro.timeline.Duration") + +local DURATION_PATTERN = "^([^/ ]+)$" +local RANGE_DURATION_PATTERN = "^([^/ ]+) ?/ ?([^/ ]+)$" + +--- cp.apple.finalcutpro.timeline.Duration.total +--- Field +--- The current duration of the timeline, as a `string`. +function Duration.lazy.prop:total() + return self.value:mutate(function(original) + local value = original() + if not value then return nil end + + local _, duration = value:match(RANGE_DURATION_PATTERN) + if not duration then + duration = value:match(DURATION_PATTERN) + end + return duration + end) +end + +--- cp.apple.finalcutpro.timeline.Duration.selection +--- Field +--- The current duration of selected range, as a `string`. +function Duration.lazy.prop:selection() + return self.value:mutate(function(original) + local value = original() + if not value then return nil end + + local duration, _ = value:match(RANGE_DURATION_PATTERN) + return duration + end) +end + +return Duration \ No newline at end of file diff --git a/src/extensions/cp/apple/finalcutpro/timeline/Timeline.lua b/src/extensions/cp/apple/finalcutpro/timeline/Timeline.lua index 4ec0532af..1497a0d8a 100644 --- a/src/extensions/cp/apple/finalcutpro/timeline/Timeline.lua +++ b/src/extensions/cp/apple/finalcutpro/timeline/Timeline.lua @@ -4,15 +4,17 @@ --- The timeline module provides an interface to the Final Cut Pro timeline. --- It delegates to the `contents` property, so any functions which can be called --- on the `contents` property can be called on the Timeline module. +--- +--- Extends: [Group](cp.ui.Group.md) +--- Delegates To: [contents](#contents) local require = require -- local log = require "hs.logger".new "Timeline" local axutils = require "cp.ui.axutils" -local delegator = require "cp.delegator" -local Element = require "cp.ui.Element" -local go = require "cp.rx.go" +local Group = require "cp.ui.Group" +local SplitGroup = require "cp.ui.SplitGroup" local tools = require "cp.tools" local Contents = require "cp.apple.finalcutpro.timeline.Contents" @@ -28,6 +30,7 @@ local childMatching = axutils.childMatching local childrenWithRole = axutils.childrenWithRole local childWithRole = axutils.childWithRole +local go = require "cp.rx.go" local Do = go.Do local Done = go.Done local If = go.If @@ -35,8 +38,7 @@ local WaitUntil = go.WaitUntil local playErrorSound = tools.playErrorSound -local Timeline = Element:subclass("cp.apple.finalcutpro.timeline.Timeline") - :include(delegator) +local Timeline = Group:subclass("cp.apple.finalcutpro.timeline.Timeline") :delegateTo("contents") --- cp.apple.finalcutpro.timeline.Timeline.matches(element) -> boolean @@ -53,8 +55,8 @@ local Timeline = Element:subclass("cp.apple.finalcutpro.timeline.Timeline") --- * `element` should be an `AXGroup`, which contains an `AXSplitGroup` with an --- `AXIdentifier` of `_NS:237` (as of Final Cut Pro 10.4) function Timeline.static.matches(element) - local splitGroup = childWithRole(element, "AXSplitGroup") - return element:attributeValue("AXRole") == "AXGroup" + local splitGroup = childMatching(element, SplitGroup.matches) + return Group.matches(element) and splitGroup and Timeline.matchesMain(splitGroup) end @@ -122,7 +124,7 @@ function Timeline:initialize(app) Timeline.matches) end):monitor(app.primaryWindow.UI, app.secondaryWindow.UI) - Element.initialize(self, app, UI) + Group.initialize(self, app, UI) end --- cp.apple.finalcutpro.timeline.Timeline.isOnSecondary @@ -414,14 +416,19 @@ function Timeline.lazy.value:title() return self.toolbar.title end ---- cp.apple.finalcutpro.timeline.Timeline.rangeSelected +--- cp.apple.finalcutpro.timeline.Timeline.duration +--- Field +--- The Duration of the Timeline. +function Timeline.lazy.value:duration() + return self.toolbar.duration +end + +--- cp.apple.finalcutpro.timeline.Timeline.isRangeSelected --- Field --- Checks if a range is selected in the timeline. -function Timeline.lazy.prop:rangeSelected() - return self.toolbar.duration.UI:mutate(function(original) - local ui = original() - local value = ui and ui:attributeValue("AXValue") - return value and (value:find("/") ~= nil or value:find("/") ~= nil) +function Timeline.lazy.prop:isRangeSelected() + return self.duration.selection:mutate(function(original) + return original() ~= nil end) end diff --git a/src/extensions/cp/apple/finalcutpro/timeline/Toolbar.lua b/src/extensions/cp/apple/finalcutpro/timeline/Toolbar.lua index 7e93cae0c..1a5d5a866 100644 --- a/src/extensions/cp/apple/finalcutpro/timeline/Toolbar.lua +++ b/src/extensions/cp/apple/finalcutpro/timeline/Toolbar.lua @@ -18,6 +18,7 @@ local StaticText = require "cp.ui.StaticText" local delegator = require "cp.delegator" local Appearance = require "cp.apple.finalcutpro.timeline.Appearance" +local Duration = require "cp.apple.finalcutpro.timeline.Duration" local ToolPalette = require "cp.apple.finalcutpro.timeline.ToolPalette" local chain = fn.chain @@ -175,7 +176,7 @@ end --- Alternately, it may contain two timelines, separated by " / ", in which case it is the duration of the --- currently selected clips, then the current project/sequence duration. function Toolbar.lazy.value:duration() - return StaticText(self, self.UI:mutate( + return Duration(self, self.UI:mutate( cache(self, "_duration", StaticText.matches)( childMatching(StaticText.matches, 1, ax.leftToRight) ) diff --git a/src/extensions/cp/dev/init.lua b/src/extensions/cp/dev/init.lua index e80b5b46c..6c9ee3dd0 100644 --- a/src/extensions/cp/dev/init.lua +++ b/src/extensions/cp/dev/init.lua @@ -3,7 +3,7 @@ --- A set of handy developer tools for CommandPost. local require = require -local hs = _G.hs +local hs = _G["hs"] local log = require "hs.logger".new "dev" @@ -27,6 +27,7 @@ local mod = {} local function _inspectElement(e, options) mod.highlight(e) + options = options or {depth=1} local out = "\n Role = " .. inspect(e:attributeValue("AXRole"), options) local id = e:attributeValue("AXIdentifier") diff --git a/src/extensions/cp/fn.lua b/src/extensions/cp/fn.lua index 500139e64..acc301ac7 100644 --- a/src/extensions/cp/fn.lua +++ b/src/extensions/cp/fn.lua @@ -304,6 +304,21 @@ function mod.curry(fn, argCount) return _curryWith(fn, argCount) end +--- cp.fn.equals(a) -> function(b) -> boolean +--- Function +--- A function combinator that returns a function returning `true` if the first input is equal to the second input. +--- +--- Parameters: +--- * a - The first input to compare. +--- +--- Returns: +--- * A function that returns `true` if the first input is equal to the second input. +function mod.equals(a) + return function(b) + return a == b + end +end + --- cp.fn.flip(fn) -> function(...) -> function(...) -> any --- Function --- A combinator that flips the order of the next two arguments to a curried function. diff --git a/src/extensions/cp/fn/ax.lua b/src/extensions/cp/fn/ax.lua index 31ca23c4c..f5d1b737d 100644 --- a/src/extensions/cp/fn/ax.lua +++ b/src/extensions/cp/fn/ax.lua @@ -124,6 +124,8 @@ end --- --- Returns: --- * A function which will return the `AX` value of the given `name` from the given `uivalue`. +--- +--- Notes: --- * This is safe to use as a [cp.prop:mutate](cp.prop.md#mutate) getter, since it will resolve the `original` value before getting the named attribute. function mod.attribute(name) return function(uivalue) @@ -144,6 +146,8 @@ end --- --- Returns: --- * A function which will set the `AX` value of the given `name` from the given `uivalue`. +--- +--- Notes: --- * The `newValue` will be passed to the `setAttributeValue` method of the `uivalue`. --- * The `uivalue` will attempt to be resolved via [uielement](#uielement). --- * This is safe to use as a [cp.prop:mutate](cp.prop.md#mutate) setter, since it will take the `newValue` and `uivalue` in the correct order and resolve the `uivalue`. @@ -468,9 +472,38 @@ function mod.topToBottomBaseAligned(a, b) local aBottom = aFrame and aFrame.y + aFrame.h local bBottom = bFrame and bFrame.y + bFrame.h + return aBottom and bBottom and aBottom < bBottom end +--- cp.fn.ax.bottomToTopBaseAligned(a, b) -> boolean +--- Function +--- Returns `true` if the base of element `a` is below the base of element `b`, based on linear vertical alignment. +--- May be used with `table.sort`. +--- +--- Parameters: +--- * a - The first element +--- * b - The second element +--- +--- Returns: +--- * `true` if `a` is below `b`. +--- +--- Notes: +--- * Two elements are considered to be aligned if the intersection of the height is at least 50% of the height of both elements. +function mod.bottomToTopBaseAligned(a, b) + if mod.areAligned(a, b) then + return false + end + + local aFrame = a and a:attributeValue("AXFrame") + local bFrame = b and b:attributeValue("AXFrame") + + local aBottom = aFrame.y + aFrame.h + local bBottom = bFrame and bFrame.y + bFrame.h + + return aBottom and bBottom and aBottom > bBottom +end + --- cp.fn.ax.narrowToWide(a, b) -> boolean --- Function --- Returns `true` if element `a` is narrower than element `b`. May be used with `table.sort`. @@ -533,9 +566,7 @@ mod.topDown = fn.compare(mod.topToBottomBaseAligned, mod.leftToRight, mod.shortT --- --- Returns: --- * `true` if `a` is below or to the right of `b` in the UI, `false` otherwise. -function mod.bottomUp(a, b) - return not mod.topDown(a, b) -end +mod.bottomUp = fn.compare(mod.bottomToTopBaseAligned, mod.rightToLeft, mod.shortToTall, mod.narrowToWide) --- cp.fn.ax.init(elementType, ...) -> function(parent, uiFinder) -> cp.ui.Element --- Function @@ -588,7 +619,7 @@ function mod.initElements(parent, elementsUiFinder, elementInits) if not elementInits or #elementInits == 0 then return nil end return imap(function(init, index) return init(parent, elementsUiFinder:mutate(chain // fn.call >> get(index))) - end, elementInits) + end)(elementInits) end --- cp.fn.ax.prop(uiFinder, attributeName[, settable]) -> cp.prop diff --git a/src/extensions/cp/fn/table.lua b/src/extensions/cp/fn/table.lua index 76a540538..81b045ca9 100644 --- a/src/extensions/cp/fn/table.lua +++ b/src/extensions/cp/fn/table.lua @@ -9,6 +9,7 @@ local require = require local cpfn = require "cp.fn" local cpfnargs = require "cp.fn.args" local is = require "cp.is" +local slice = require "cp.slice" local LazyList = require "cp.collect.LazyList" @@ -196,7 +197,7 @@ function mod.ifilter(predicate) end end ---- cp.fn.table.imap(fn, values | ...) -> table of any | ... +--- cp.fn.table.imap(fn) -> function(values | ...) -> table of any | ... --- Function --- Maps a function over a table using `ipairs`. The function is passed the current `value` and the `key`. --- @@ -209,16 +210,18 @@ end --- --- Notes: --- * If the values are a table, the results will be a table. Otherwise, the results will be a vararg list. -function mod.imap(fn, ...) - local args, packed = packArgs(...) - local results = LazyList( - function() return #args end, - function(i) - local value = args[i] - return fn(value, i) - end - ) - return unpackArgs(results, packed) +function mod.imap(fn) + return function(...) + local args, packed = packArgs(...) + local results = LazyList( + function() return #args end, + function(i) + local value = args[i] + return fn(value, i) + end + ) + return unpackArgs(results, packed) + end end --- cp.fn.table.last(table) -> any | nil @@ -259,7 +262,7 @@ function mod.matchesExactItems(...) end end ---- cp.fn.table.map(fn, t) -> table of any +--- cp.fn.table.map(fn) -> function(t) -> table of any --- Function --- Maps a function over a table using `pairs`. The function is passed the current `value` and the `key`. --- @@ -269,12 +272,14 @@ end --- --- Returns: --- * A table with the values updated via the function. -function mod.map(fn, t) - local results = {} - for i,arg in pairs(t) do - results[i] = fn(arg, i) +function mod.map(fn) + return function(t) + local results = {} + for i,arg in pairs(t) do + results[i] = fn(arg, i) + end + return results end - return results end --- cp.fn.table.mutate(key) -> function(fn) -> function(table) -> table @@ -331,6 +336,26 @@ function mod.size(t) return #t end +--- cp.fn.table.slice(start, [count]) -> function(table) -> table +--- Function +--- Returns a function that accepts a table and returns a slice of the table. +--- +--- Parameters: +--- * start - The starting index of the slice. +--- * count - The number of items to include in the slice. If not provided, the slice will include all items from the start index. +--- +--- Returns: +--- * A function. +--- +--- Notes: +--- * The returned function will wrap the table passed in, and updates to the original table will affect the slice. +--- * Example usage: `fn.table.slice(2, 3)({1,2,3,4,5})` -- returns `{2,3,4}` +function mod.slice(start, count) + return function(t) + return slice.new(t, start, count) + end +end + --- cp.fn.table.sort(...) -> function(table) -> table --- Function --- A combinator that returns a function that accepts a table and returns a new table, sorted with the compare functions. @@ -366,6 +391,9 @@ end --- Returns: --- * A function that accepts a table to split and returns a table of tables, followed by a table of splitter values function mod.split(predicate) + if type(predicate) ~= "function" then + error("predicate must be a function", 2) + end return function(t) local results = {} local current = {} diff --git a/src/extensions/cp/prop/init.lua b/src/extensions/cp/prop/init.lua index 8ae9afb25..ab6e6b4ef 100644 --- a/src/extensions/cp/prop/init.lua +++ b/src/extensions/cp/prop/init.lua @@ -939,20 +939,19 @@ end --- The `getFn` is a function with the following signature: --- --- ```lua ---- function(original, owner, prop) --> mutantValue +--- function(original, owner, mutantProp) --> mutantValue --- ``` --- --- * `originalProp` - The original `cp.prop` being mutated. --- * `owner` - The owner of the mutator property, if it has been bound. --- * `mutantProp` - The mutant property. ---- * `mutantValue` - The new value based off the original. --- --- You can ignore any parameters that you don't need. Most simply use the `original` prop. --- --- The `setFn` is optional, and is a function with the following signature: --- --- ```lua ---- function(mutantValue, original, owner, prop) --> nil +--- function(mutantValue, originalProp, owner, mutantProp) --> nil --- ``` --- --- * `mutantValue` - The new value being sent in. diff --git a/src/extensions/cp/result.lua b/src/extensions/cp/result.lua index 5786593d5..d314e6d65 100644 --- a/src/extensions/cp/result.lua +++ b/src/extensions/cp/result.lua @@ -71,12 +71,13 @@ function mod.success(value) return setmetatable({success = true, value = value}, mod.mt) end ---- cp.result.failure(message) -> result +--- cp.result.failure(message, [...]) -> result --- Constructor --- Creates a new `failure` result, with the specified error `message`. --- --- Parameters: --- * message - Error message +--- * ... - Optional arguments to be formatted into the message --- --- Returns: --- * A new result @@ -129,7 +130,7 @@ end --- cp.result.okValue(ok, value) -> result --- Constructor --- Provides a simple wrapper for the common `ok, value|err` pattern of function error handling in Lua. ---- If `ok` is true, `value` is the successful result, otherwise `value` is the error message. +--- If `ok` is `true`, `value` is the successful result, otherwise `value` is the error message. --- --- Parameters: --- * ok - if `true`, the operation was successful. diff --git a/src/extensions/cp/rx/go/SetProp.lua b/src/extensions/cp/rx/go/SetProp.lua index c908fc124..3e0144779 100644 --- a/src/extensions/cp/rx/go/SetProp.lua +++ b/src/extensions/cp/rx/go/SetProp.lua @@ -94,7 +94,7 @@ end) --- --- A `Statement.Modifier` that defines what value to set a `cp.prop` to. ---- cp.rx.go.If.Then +--- cp.rx.go.SetProp.To --- Constant --- This is a configuration of `SetProp`, which should be created via `SetProp:To(value)` diff --git a/src/extensions/cp/slice.lua b/src/extensions/cp/slice.lua new file mode 100644 index 000000000..e6cd596b2 --- /dev/null +++ b/src/extensions/cp/slice.lua @@ -0,0 +1,317 @@ +--- === cp.slice === +--- +--- A slice of a table, from the provided `start` index to the end, or the optional `count` if provided. +--- + +--local log = require "hs.logger" .new "slice" + +local format = string.format +local max = math.max + +local mod = {} +mod.mt = {} + +-- __len(t) -> number +-- Function +-- Returns the length of the table, shifted by the slice start index (retrieved from the metatable). +-- If the slice size (also from the metatable) is specified, and the requested index exceeds it, returns `nil`. +-- +-- Parameters: +-- * t - The table to get the length of. +-- +-- Returns: +-- * The length of the table. +-- +-- Notes: +-- * This is a private function. +local function __len(t) + local mt = getmetatable(t) + local sliceLen = rawget(mt, "__sliceLen") + + if sliceLen then + return sliceLen + end + + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + + return max((original.n or #original) - sliceShift + 1, 0) +end + +-- __index(t, i) -> any +-- Function +-- Returns the value at the specified index in the table, shifted by the slice start index (retrieved from the metatable). +-- If the slice size (also from the metatable) is specified, and the requested index exceeds it, returns `nil`. +-- +-- Parameters: +-- * t - The table to get the value from. +-- * i - The index to get the value at. +-- +-- Returns: +-- * The value at the specified index. +-- +-- Notes: +-- * This is a private function. +local function __index(t, i) + local mtValue = mod.mt[i] + if mtValue then + return mtValue + end + + local mt = getmetatable(t) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(t) + + if i == "n" then + return sliceLen + elseif type(i) ~= "number" then + return original[i] + elseif i < 1 or i > sliceLen then + return nil + end + + local sliceIndex = i + sliceShift + + return original[sliceIndex] +end + +-- __newindex(t, i, v) +-- Function +-- Sets the value at the specified index in the table, shifted by the slice start index (retrieved from the metatable). +-- If the slice size (also from the metatable) is specified, and the requested index exceeds it, returns `nil`. +-- +-- Parameters: +-- * t - The table to set the value in. +-- * i - The index to set the value at. +-- * v - The value to set. +-- +-- Returns: +-- * Nothing +-- +-- Notes: +-- * This is a private function. +local function __newindex(t, i, v) + if i == "n" then + return + elseif type(i) ~= "number" then + return rawset(t, i, v) + end + + local mt = getmetatable(t) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(t) + + local sliceIndex = i + sliceShift + + if sliceLen and sliceIndex >= sliceLen then + error(format("index out of bounds: %d", i), 2) + end + + rawset(original, sliceIndex, v) +end + +--- cp.slice.is(other) -> boolean +--- Function +--- Checks if the other value is a `cp.slice`. +--- +--- Parameters: +--- * other - The other value to check. +--- +--- Returns: +--- * `true` if the other value is a `cp.slice`, otherwise `false`. +function mod.is(other) + if type(other) ~= "table" then return false end + local mt = getmetatable(other) + return mt ~= nil and rawget(mt, "__sliceTable") ~= nil +end + +--- cp.slice.new(t, start, [count]) -> cp.slice +--- Constructor +--- Creates a new `cp.slice` over the provided `table`. +--- +--- Parameters: +--- * `t` - The table to slice. +--- * `start` - The starting index. +--- * `count` - The number of items to slice. If not provided, then the slice will go to the end of the table. +--- +--- Returns: +--- * The new `cp.slice` instance. +function mod.new(t, start, count) + if type(t) ~= "table" then + error(format("expected table, got %s", type(t)), 2) + end + if start < 1 then + error(format("start index must be 1 or higher, but was %d", start), 2) + end + if count and count < 0 then + error(format("invalid count: %d", count), 2) + end + local shift = start - 1 + count = count or max(#t - shift, 0) + return setmetatable({}, { + __index = __index, + __newindex = __newindex, + __len = __len, + __sliceTable = t, + __sliceShift = shift, + __sliceLen = count, + }) +end + +--- cp.slice.from(value) -> cp.slice +--- Constructor +--- Creates a new `cp.slice` from the provided `value`. +--- If it is already a `slice`, it is returned unmodified. If it's a table, then a new `slice` is created from it, starting at index 1. +--- Any other value generates an error. +--- +--- Parameters: +--- * `value` - The value to create a `slice` from. +--- +--- Returns: +--- * The new `cp.slice` instance. +function mod.from(value) + if mod.is(value) then + return value + elseif type(value) == "table" then + return mod.new(value, 1) + end + error(format("expected table or slice, got %s", type(value)), 2) +end + +--- cp.slice:shift(n) -> cp.slice +--- Method +--- Returns a new slice which is shifted by the specified number of items, without changing the length. +--- +--- Parameters: +--- * `n` - The number of items to shift the slice by. +--- +--- Returns: +--- * The new `cp.slice` instance. +--- +--- Notes: +--- * The original slice is not modified. +function mod.mt:shift(n) + if n < 0 then + error(format("invalid shift: %d", n), 2) + end + local mt = getmetatable(self) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(self) + + return mod.new(original, 1 + sliceShift + n, sliceLen) +end + +--- cp.slice:drop(n) -> cp.slice +--- Method +--- Returns a new slice which is shifted by the specified number of items, and the length is reduced by the specified number of items. +--- +--- Parameters: +--- * `n` - The number of items to drop. +--- +--- Returns: +--- * The new `cp.slice` instance. +--- +--- Notes: +--- * The original slice is not modified. +function mod.mt:drop(n) + if n < 0 then + error(format("invalid drop: %d", n), 2) + end + local mt = getmetatable(self) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(self) + + if sliceLen < n then + error(format("dropping %d but only %d are available", n, sliceLen), 2) + else + return mod.new(original, 1 + sliceShift + n, sliceLen - n) + end +end + +--- cp.slice:pop() -> any, cp.slice +--- Method +--- Returns the first element of the slice, returning a new `slice` with the first element removed. +--- +--- Parameters: +--- * None. +--- +--- Returns: +--- * The the popped element, and a new `cp.slice` instance. +--- +--- Notes: +--- * The original slice is not modified. +function mod.mt:pop() + local mt = getmetatable(self) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(self) + + if sliceLen == 0 then + error("pop from empty slice", 2) + else + -- get the first item + local item = original[1+sliceShift] + -- return the item, plus a new slice of the original, incremented by 1 + return item, mod.new(original, 2+sliceShift, sliceLen - 1) + end +end + +--- cp.slice:split(n) -> cp.slice, cp.slice +--- Method +--- Splits the slice into two new slices, where the first slice contains the first `n` items, +--- and the second slice contains the remaining items. +--- +--- Parameters: +--- * `n` - The number of items to include in the first slice. +--- +--- Returns: +--- * The first slice, and the second slice. +--- +--- Notes: +--- * The original slice is not modified. +function mod.mt:split(n) + if n < 0 then + error(format("invalid split: %d", n), 2) + end + local mt = getmetatable(self) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(self) + + if sliceLen < n then + error(format("split size (%d) is greater than slice size (%d)", n, sliceLen), 2) + end + + local firstLen = n + local secondLen = sliceLen - firstLen + + local first = mod.new(original, sliceShift + 1, firstLen) + local second = mod.new(original, sliceShift + firstLen + 1, secondLen) + + return first, second +end + +--- cp.slice:clone() -> cp.slice +--- Method +--- Creates a new slice that is a copy of the original. +--- It will be pointing at the original table, and will have the same length and shift. +--- +--- Parameters: +--- * None. +--- +--- Returns: +--- * The new slice. +function mod.mt:clone() + local mt = getmetatable(self) + local original = rawget(mt,"__sliceTable") + local sliceShift = rawget(mt, "__sliceShift") + local sliceLen = __len(self) + + return mod.new(original, sliceShift + 1, sliceLen) +end + +return mod \ No newline at end of file diff --git a/src/extensions/cp/ui/Builder.lua b/src/extensions/cp/ui/Builder.lua index 74be99cee..2f62298d2 100644 --- a/src/extensions/cp/ui/Builder.lua +++ b/src/extensions/cp/ui/Builder.lua @@ -92,6 +92,8 @@ local Builder = class("cp.ui.Builder") function Builder:initialize(elementType, ...) self[ELEMENT_TYPE] = elementType + self.matches = elementType.matches + local extraArgs = {} local extraArgsCount = select("#", ...) for i = 1, extraArgsCount do diff --git a/src/extensions/cp/ui/Button.lua b/src/extensions/cp/ui/Button.lua index fad933929..e9750624d 100644 --- a/src/extensions/cp/ui/Button.lua +++ b/src/extensions/cp/ui/Button.lua @@ -129,8 +129,8 @@ function Button:__call() return self:press() end -function Button:__tostring() - return string.format("cp.ui.Button: %q (parent: %s)", self:title(), self:parent()) +function Button:__valuestring() + return self:title() end return Button diff --git a/src/extensions/cp/ui/CheckBox.lua b/src/extensions/cp/ui/CheckBox.lua index 9b5d91a43..ff5babf2b 100644 --- a/src/extensions/cp/ui/CheckBox.lua +++ b/src/extensions/cp/ui/CheckBox.lua @@ -244,4 +244,8 @@ function CheckBox:__call(parent, value) return self:checked(value) end +function CheckBox:__valuestring() + return self:checked() and " ✔ " or " ✘ " +end + return CheckBox diff --git a/src/extensions/cp/ui/Element.lua b/src/extensions/cp/ui/Element.lua index cad4042f0..330590b7a 100644 --- a/src/extensions/cp/ui/Element.lua +++ b/src/extensions/cp/ui/Element.lua @@ -1,6 +1,10 @@ --- === cp.ui.Element === --- ---- A support class for `hs.axuielement` management. +--- The base class for `hs.axuielement` management. +--- +--- Includes: +--- * [cp.lazy](cp.lazy.md) +--- * [cp.delegator](cp.delegator.md) --- --- See: --- * [Button](cp.ui.Button.md) @@ -10,25 +14,35 @@ local require = require --local log = require "hs.logger".new("Element") +local inspect = require "cp.dev" .inspect local drawing = require "hs.drawing" +local window = require "hs.window" -local axutils = require "cp.ui.axutils" -local Builder = require "cp.ui.Builder" -local go = require "cp.rx.go" +local deferred = require "cp.deferred" local is = require "cp.is" -local lazy = require "cp.lazy" +local just = require "cp.just" local prop = require "cp.prop" +local axutils = require "cp.ui.axutils" +local Builder = require "cp.ui.Builder" +local notifier = require "cp.ui.notifier" local class = require "middleclass" +local lazy = require "cp.lazy" +local delegator = require "cp.delegator" -local cache = axutils.cache +local go = require "cp.rx.go" local Do, Given, If = go.Do, go.Given, go.If +local WaitUntil = go.WaitUntil + +local cache = axutils.cache +local doUntil = just.doUntil local isFunction = is.fn local isCallable = is.callable local pack, unpack = table.pack, table.unpack +local format = string.format -local Element = class("cp.ui.Element"):include(lazy) +local Element = class("cp.ui.Element"):include(lazy):include(delegator) --- cp.ui.Element:defineBuilder(...) -> cp.ui.Element --- Method @@ -83,24 +97,6 @@ function Element.static:defineBuilder(...) return self end ---- cp.ui.Element:isTypeOf(thing) -> boolean ---- Function ---- Checks if the `thing` is an `Element`. If called on subclasses, it will check ---- if the `thing` is an instance of the subclass. ---- ---- Parameters: ---- * `thing` - The thing to check ---- ---- Returns: ---- * `true` if the thing is a `Element` instance. ---- ---- Notes: ---- * This is a type method, not an instance method or a type function. It is called with `:` on the type itself, ---- not an instance. For example `Element:isTypeOf(value)` -function Element.static:isTypeOf(thing) - return type(thing) == "table" and thing.isInstanceOf ~= nil and thing:isInstanceOf(self) -end - --- cp.ui.Element.matches(element) -> boolean --- Function --- Matches to any valid `hs.axuielement`. Sub-types should provide their own `matches` method. @@ -114,9 +110,29 @@ function Element.static.matches(element) return element ~= nil and isFunction(element.isValid) and element:isValid() end +--- cp.ui.Element:__valuestring() -> string +--- Method +--- Returns a string representation of current `Element`, +--- or `nil` if no extra detail is available. Defaults to returning `nil`. +--- +--- Returns: +--- * A string representation of the `Element`. +--- +--- Notes: +--- * This will be called by `__tostring` and added to the class name. +--- * If you want to change the whole string, you can override `__tostring()` instead. +function Element:__valuestring() -- luacheck:ignore + return nil +end + -- Defaults to describing the class by it's class name function Element:__tostring() - return self.class.name + local className = self.class.name + local valueString = self:__valuestring() + if valueString then + return format("%s <%s>", className, valueString) + end + return className end --- cp.ui.Element(parent, uiFinder) -> cp.ui.Element @@ -219,6 +235,23 @@ function Element.lazy.method:doShow() return If(function() return self:parent() end) :Then(function(parent) return parent.doShow and parent:doShow() end) :Otherwise(false) + :Label("Element:doShow()") +end + +--- cp.ui.Element:doShowContentsAt(frame) -> cp.rx.go.Statement +--- Method +--- Returns a `Statement` that will ensure the Element is showing its contents +--- at the provided frame/rectangle. +--- Does nothing by default - subclasses that have variable contents should override this. +--- +--- Parameters: +--- * frame - The frame to show the contents at. +--- +--- Returns: +--- * A Statement +function Element.lazy.method:doShowContentsAt(frame) + return Do(function() self:showContentsAt(frame) end) + :Label("Element:doShowContentsAt(frame)") end --- cp.ui.Element:show() -> self @@ -238,6 +271,24 @@ function Element:show() return self end +--- cp.ui.Element:showContentsAt(frame) -> self +--- Method +--- Shows the Element's contents at the provided frame/rectangle. +--- Does nothing by default - subclasses that have variable contents should override this. +--- +--- Parameters: +--- * frame - The frame to show the contents at. +--- +--- Returns: +--- * self +function Element:showContentsAt(frame) + local parent = self:parent() + if parent then + parent:showContentsAt(frame) + end + return self +end + --- cp.ui.Element:focus() -> self, boolean --- Method --- Attempt to set the focus on the element. @@ -365,6 +416,13 @@ function Element.lazy.prop:role() return axutils.prop(self.UI, "AXRole") end +--- cp.ui.Element.roleDescription +--- Field +--- Returns the `AX` role description for the element. +function Element.lazy.prop:roleDescription() + return axutils.prop(self.UI, "AXRoleDescription") +end + --- cp.ui.Element.subrole --- Field --- Returns the `AX` subrole name for the element. @@ -443,6 +501,125 @@ function Element:app() return parent and parent:app() end +--- cp.ui.Element.windowUI +--- Field +--- The `AXWindow` instance. +function Element.lazy.prop:windowUI() + return axutils.prop(self.UI, "AXWindow") +end + +--- cp.ui.Element.hsWindow +--- Method +--- The `hs.window` instance. +function Element.lazy.prop:hsWindow() + return self.windowUI:mutate(function(original) + local windowUI = original() + return windowUI and windowUI:asHSWindow() + end) +end + +--- cp.ui.Element:doFocusOnWindow() -> cp.rx.go.Statement +--- Method +--- Returns a `Statement` which will attempt to the OS on the `Element`'s window. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The `Statement` which will attempt to focus the window. +function Element.lazy.method:doFocusOnWindow() + return If(self.hsWindow) + :Then(function(hsWindow) + hsWindow:focus() + return WaitUntil(function() + return hsWindow == window.focusedWindow() + end) + :TimeoutAfter(1000) + end) +end + +--- cp.ui.Element:focusOnWindow() -> boolean +--- Method +--- Attempts to focus the `Element`'s window. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * `true` if the window was focused, otherwise `false`. +function Element:focusOnWindow() + local hsWindow = self:hsWindow() + if hsWindow then + hsWindow:focus() + return doUntil(function() + return hsWindow == window.focusedWindow() + end, 1) + end +end + +--- cp.ui.Element:notifier() -> cp.ui.notifier +--- Method +--- Returns the [notifier](cp.ui.notifier.lua) instance for this `Element`. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * cp.ui.notifier +function Element.lazy.method:notifier() + return notifier.new(self:app():bundleID(), self.UI) +end + +--- cp.ui.Element:watchFor(eventList, callback[, deferBy]) -> function | cp.prop +--- Method +--- Watches for the specified `AX` events and calls the callback when they occur. +--- If the `callback` is a `cp.prop`, the `cp.prop:update()` method will be called instead. +--- +--- Parameters: +--- * eventList - The list of events to watch for. +--- * callback - The callback or `cp.prop` to call when the event occurs. +--- * deferBy - The number of seconds to defer the callback. Defaults to instantaneous. +--- +--- Returns: +--- * The original callback value +--- +--- Notes: +--- * Common events: +--- * `AXFocusedWindowChanged` - The focused window has changed. +--- * `AXFocusedUIElementChanged` - The focused element has changed. +--- * `AXLiveRegionChanged` - The live region has changed. +--- * `AXMenuClosedNotification` - The menu has been closed. +--- * `AXMenuItemSelectedNotification` - The menu item has been selected. +--- * `AXMenuOpenedNotification` - The menu has been opened. +--- * `AXSelectedChildrenChanged` - The selected children have changed. +--- * `AXUIElementCreated` - The element has been created. +--- * `AXUIElementDestroyed` - The element has been destroyed. +--- * `AXValidationErrorChanged` - The validation error has changed. +--- * `AXValueChanged` - The value of the element has changed. +--- * `AXWindowCreated` - The window has been created. +--- * `AXWindowDestroyed` - The window has been destroyed. +function Element:watchFor(eventList, callback, deferBy) + local originalCallback = callback + if prop.is(callback) then + local propToUpdate = callback + callback = function() + propToUpdate:update() + end + elseif not isCallable(callback) then + error("The callback must be a function or a cp.prop.", 2) + end + + if deferBy then + local deferredFn = callback + local d = deferred.new(deferBy):action(deferredFn) + callback = function() d:run() end + end + + self:notifier():start():watchFor(eventList, callback) + + return originalCallback +end + --- cp.ui.Element:snapshot([path]) -> hs.image | nil --- Method --- Takes a snapshot of the button in its current state as a PNG and returns it. @@ -523,6 +700,33 @@ function Element:highlight(color, duration) return self end +-- cp.ui.Element:inspect([options]) -> string +-- Method +-- Returns a string representation of the `Element`. +-- +-- Parameters: +-- * options - (optional) The options table. +-- +-- Returns: +-- * The string representation. +function Element:inspect(options) + options = options or {depth=1} + return inspect(self, options) +end + +--- cp.ui.Element:inspectUI([options]) -> string +--- Method +--- Returns a string representation of the `Element`'s `UI`. +--- +--- Parameters: +--- * options - (optional) The options table. +--- +-- Returns: +--- * The string representation. +function Element:inspectUI(options) + options = options or {depth=1} + return cp.dev.inspect(self:UI(), options) +end --- cp.ui.Element:saveLayout() -> table --- Method @@ -610,6 +814,16 @@ function Element:doLayout(layout) :Label("cp.ui.Element:doLayout(layout)") end +--- cp.ui.Element:doStoreLayout(id) -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) which will attempt to store the layout based on the parameters +--- provided by the `id` key value. This can then be restored via the [doRecallLayout](#doRecallLayout) method. +--- +--- Parameters: +--- * id - a `string` key to store the layout under. +--- +--- Returns: +--- * The [Statement](cp.rx.go.Statement.md) to execute. function Element:doStoreLayout(id) return Given(self:doSaveLayout()) :Then(function(layout) @@ -621,6 +835,16 @@ function Element:doStoreLayout(id) :Label("cp.ui.Element:doStoreLayout(id)") end +--- cp.ui.Element:doForgetLayout(id) -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) which will attempt to forget the layout based on the parameters +--- provided by the `id` key value. +--- +--- Parameters: +--- * id - a `string` key to forget the layout under. +--- +--- Returns: +--- * The [Statement](cp.rx.go.Statement.md) to execute. function Element:doForgetLayout(id) return Do(function() local layouts = self.__storedLayouts @@ -635,6 +859,17 @@ function Element:doForgetLayout(id) :Label("cp.ui.Element:doForgetLayout(id)") end +--- cp.ui.Element:doRecallLayout(id, [preserve]) -> cp.rx.go.Statement +--- Method +--- Returns a [Statement](cp.rx.go.Statement.md) which will attempt to recall the layout based on the parameters +--- provided by the `id` key value. +--- +--- Parameters: +--- * id - a `string` key to recall the layout under. +--- * preserve - (optional) a `boolean` indicating whether to preserve the current layout. Defaults to forgetting it after restoring. +--- +--- Returns: +--- * The [Statement](cp.rx.go.Statement.md) to execute. function Element:doRecallLayout(id, preserve) local doForget = preserve and nil or self:doForgetLayout(id) @@ -659,4 +894,51 @@ function Element:__call() return self end +--- cp.ui.Element:extension(name) -> table +--- Function +--- Returns the extension table for the specified `name`. +--- +--- Parameters: +--- * name - The name of the extension. +--- +--- Returns: +--- * The extension table. +--- +--- Notes: +--- * Extensions are intended to compose additional shared functionality across multiple +--- [Element](cp.ui.Element.md) classes. +--- * They have `lazy` values, so can be used to define additional `value`/`method`/`prop` properties, +--- like a standard Element. +--- * They have a `static` value, so can be used to define additional class/static properties. + +-- TODO: @randomeizer to review the below code: + +function Element.static:extension(name) -- luacheck:ignore + local extension = { + lazy = { value = {}, method = {}, prop = {} }, + static = {}, + } + + function extension:included(klass) -- luacheck:ignore + for key, value in pairs(self.static) do + klass[key] = value + end + + if not klass.lazy then + error(string.format("extension requires that %s has already included cp.lazy", klass.name), 2) + end + for key, value in pairs(self.lazy.value) do + klass.lazy.value[key] = value + end + for key, value in pairs(self.lazy.prop) do + klass.lazy.prop[key] = value + end + for key, value in pairs(self.lazy.method) do + klass.lazy.method[key] = value + end + end + + return extension +end + return Element diff --git a/src/extensions/cp/ui/ElementCache.lua b/src/extensions/cp/ui/ElementCache.lua deleted file mode 100644 index dcebb3154..000000000 --- a/src/extensions/cp/ui/ElementCache.lua +++ /dev/null @@ -1,155 +0,0 @@ ---- === cp.ui.ElementCache === ---- ---- Provides caching for [Element](cp.ui.Element.md) subclasses that want to cache children. - -local class = require "middleclass" -local axutils = require "cp.ui.axutils" - -local insert = table.insert - -local ElementCache = class("cp.ui.ElementCache") - ---- cp.ui.ElementCache(parent[, createFn]) ---- Constructor ---- Creates and returns a new `ElementCache`, with the specified parent and function which ---- will create new elements on demand. The `createFn` has the signature of `function(parent, ui) -> cp.ui.Element`, ---- and should take the parent provided here and the `axuielement` and return a new `Element` subclass. ---- ---- Parameters: ---- * parent - the parent [Element](cp.ui.Element.md) that contains the cached items. ---- * createFn - a function that will create new `Element` subclasses based on cached `axuielement` values. ---- ---- Returns: ---- * The new `ElementCache`. -function ElementCache:initialize(parent, createFn) - self.items = setmetatable({}, {__mode="k"}) - self.parent = parent - self.createFn = createFn -end - ---- cp.ui.ElementCache:clean() ---- Method ---- Clears the cache of any invalid (aka dead) items. ---- ---- Parameters: ---- * None ---- ---- Returns: ---- * None -function ElementCache:clean() - local cache = self.items - if cache then - for ui,_ in pairs(cache) do - if not axutils.isValid(ui) then - cache[ui] = nil - end - end - end -end - ---- cp.ui.ElementCache:reset() -> none ---- Method ---- Removes all cached items from the cache. ---- ---- Parameters: ---- * None ---- ---- Returns: ---- * None -function ElementCache:reset() - self.items = {} -end - ---- cp.ui.ElementCache:cachedElement(cache, ui) -> cp.ui.Element or nil ---- Method ---- Returns the cached [Element](cp.ui.Element.md), if it is present. ---- ---- Parameters: ---- * ui - The `axuielement` it is linked to. If not provided, it will be fetched by calling `Element:UI()`. ---- ---- Returns: ---- * `cp.ui.Element` or `nil` -function ElementCache:cachedElement(ui) - local cache = self.items - if cache then - for cachedUI,row in pairs(cache) do - if cachedUI == ui then - return row - end - end - end -end - ---- cp.ui.ElementCache:cacheElement(element[, ui]) -> none ---- Method ---- Caches the provided [Element](cp.ui.Element.md). ---- ---- Parameters: ---- * element - The [Element](cp.ui.Element.md) ---- * ui - The `axuielement` it is linked to. If not provided, it will be fetched by calling `Element:UI()`. ---- ---- Returns: ---- * None -function ElementCache:cacheElement(element, ui) - local cache = self.items - ui = ui or element:UI() - if axutils.isValid(ui) then - cache[ui] = element - end -end - ---- cp.ui.ElementCache:fetchElement(ui) -> cp.ui.Element or nil ---- Method ---- Retrieves the matching [Element](cp.ui.Element.md) instance from the cache. ---- If none exists and the `createFn` was provided in the constructor, ---- it will be used to create a new one, which is automatically cached for future reference. ---- ---- Parameters: ---- * ui - The `axuielement` being fetched for. ---- ---- Returns: ---- * `cp.ui.Element` or `nil` -function ElementCache:fetchElement(ui) - if ui:attributeValue("AXParent") ~= self.parent:UI() then - return nil - end - - if not axutils.isValid(ui) then - return nil - end - - local element = self:cachedElement(ui) - local createFn = self.createFn - if not element and createFn then - element = createFn(self.parent, ui) - self:cacheElement(element, ui) - end - return element -end - ---- cp.ui.ElementCache:fetchElements(uis) -> table of cp.ui.Elements or nil ---- Method ---- Fetches a list of [Element](cp.ui.Element.md) instances linked to the provided `axuielement` list. ---- ---- Parameters: ---- * uis - A `table` of `axuielement` values. ---- ---- Returns: ---- * A `table` of [Element](cp.ui.Element.md) values. ---- ---- Notes: ---- * If any of the provided `axuielement` values are either not from the parent, or no longer valid, a `nil` value will be stored in the matching index. Note that in that case, this will break useage of `ipairs` due to leaving holes in the list. -function ElementCache:fetchElements(uis) - if uis then - self:clean() - local elements = {} - - for _,ui in ipairs(uis) do - insert(elements, self:fetchElement(ui)) - end - - return elements - end -end - -return ElementCache \ No newline at end of file diff --git a/src/extensions/cp/ui/Grid.lua b/src/extensions/cp/ui/Grid.lua new file mode 100644 index 000000000..7774804df --- /dev/null +++ b/src/extensions/cp/ui/Grid.lua @@ -0,0 +1,262 @@ +--- === cp.ui.Grid === +--- +--- An `AXGrid` UI element. It typically represents multiple items of the same type, +--- arranged into a grid of some number of columns and rows. +--- +--- These are accessible via an [ElementRepeater](cp.ui.has.ElementRepeater.md) at the [children](cp.ui.HasRepeatingChildren.md#children) property, +--- and an indication of how it's split up via the [rowCount](cp.ui.Grid.md#rowCount) property. +--- +--- Extends: [cp.ui.Element](cp.ui.Element.md) +--- Includes: +--- * [HasRepeatingChildren](cp.ui.HasRepeatingChildren.md) + +local require = require + +-- local log = require "hs.logger".new "Grid" + +local ax = require "cp.fn.ax" +local has = require "cp.ui.has" +local Element = require "cp.ui.Element" +local HasRepeatingChildren = require "cp.ui.HasRepeatingChildren" + +local go = require "cp.rx.go" +local If = go.If + +local handler, zeroOrMore = has.handler, has.zeroOrMore + +local Grid = Element:subclass("cp.ui.Grid") + :include(HasRepeatingChildren) + :delegateTo("children") + :defineBuilder("containing") + +--- === cp.ui.Grid.Builder === +--- +--- Builder for [Grid](cp.ui.Grid.md). +--- +--- Extends [Builder](cp.ui.Builder.md). + +--- cp.ui.Grid.Builder:containing(childHandler) -> cp.ui.Builder +--- Method +--- Sets the `Element` type for the `children` property. +--- +--- Parameters: +--- * childHandler - The `Element` type to use for the `children` property. +--- +--- Returns: +--- * The `Builder` instance. + +--- cp.ui.Grid:containing(childHandler) -> cp.ui.Builder +--- Function +--- Sets the `Element` type for the `children` property. +--- +--- Parameters: +--- * childHandler - The `Element` type to use for the `children` property. +--- +--- Returns: +--- * The `Builder` instance. + +--- cp.ui.Grid.matches(element) -> boolean +--- Function +--- Checks if the `element` is a `Grid`. +--- +--- Parameters: +--- * element - An `axuielementObject` to check. +--- +--- Returns: +--- * A boolean +Grid.static.matches = ax.matchesIf(Element.matches, ax.hasRole "AXGrid") + +--- cp.ui.Grid(parent, uiFinder, childHandler) -> cp.ui.Grid +--- Constructor +--- Creates a new `Grid` instance. +--- +--- Parameters: +--- * parent - The parent `Element` or `nil` if there is no parent. +--- * uiFinder - A `hs.ui.AXUIElement` or `axuielementObject` to use to find the `Grid` in the UI. +--- * childHandler - The `Element` type to use for the `children` property. +--- +--- Returns: +--- * A new `Grid` instance. +function Grid:initialize(parent, uiFinder, childHandler) + Element.initialize(self, parent, uiFinder) + self._childHandler = handler(childHandler) + self:childHandler(self._childHandler) +end + +--- cp.ui.Grid.rowCount +--- Field +--- The number of rows in the grid. +function Grid.lazy.prop:rowCount() + return ax.prop(self.UI, "AXRowCount") +end + +--- cp.ui.Grid.selectedChildrenUI() -> +--- Function +--- Returns the `hs.axuielement`s for the selected children. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A table of `hs.axuielement`s. +function Grid.lazy.prop:selectedChildrenUI() + return self:watchFor( + "AXSelectedChildrenChanged", + ax.prop(self.UI, "AXSelectedChildren", true), + 0.01 + ) +end + +--- cp.ui.Grid.selectedChildren +--- Field +--- The selected children of the grid. +--- These will match the types defined by the `childHandler` in the initializer. +function Grid.lazy.value:selectedChildren() + return zeroOrMore(self._childHandler):build(self, self.selectedChildrenUI) + -- return self:repeater(self.selectedChildrenUI) +end + +--- cp.ui.Grid:selectChildUI(childUI) -> self +--- Method +--- Select a specific child within a Scroll Area. +--- +--- Parameters: +--- * childUI - The `hs.axuielement` object of the child you want to select. +--- +--- Return: +--- * Self +function Grid:selectChildUI(childUI) + if childUI then + self:selectedChildrenUI({childUI}) + end + return self +end + +--- cp.ui.Grid:selectChild(child) -> self +--- Method +--- Select a specific child within a Scroll Area. +--- +--- Parameters: +--- * child - The child you want to select. +--- +--- Return: +--- * Self +function Grid:selectChild(child) + local childUI = child and child:UI() + if childUI then + self:selectedChildrenUI({childUI}) + end + return self +end + +--- cp.ui.Grid:doSelectChild(child) -> cp.rx.go.Statement +--- Method +--- A [Statement](cp.rx.go.Statement.md) that selects a specific child within the Grid. +--- +--- Parameters: +--- * child - The child [Element](cp.ui.Element.md) you want to select. +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) that can be used to select the specified child. +function Grid:doSelectChild(child) + return If(self.UI):Then(function() + self:selectChild(child) + end) + :Label("Grid:doSelectChild(child)") +end + +--- cp.ui.Grid:selectChildAt(index) -> self +--- Method +--- Select a child element in the given a specific index. +--- +--- Parameters: +--- * index - The index of the child you want to select. +--- +--- Return: +--- * Self +function Grid:selectChildAt(index) + local ui = self:childrenUI() + if ui and index >= 0 and #ui >= index then + self:selectChildUI(ui[index]) + end + return self +end + +--- cp.ui.Grid:doSelectChildAt(index) -> cp.rx.go.Statement +--- Method +--- A [Statement](cp.rx.go.Statement.md) that selects a child element in the given a specific index. +--- +--- Parameters: +--- * index - The index of the child you want to select. +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) that can be used to select the specified child. +function Grid:doSelectChildAt(index) + return If(self.UI):Then(function() + self:selectChildAt(index) + end) + :Label("Grid:doSelectChildAt(index)") +end + +--- cp.ui.Grid:selectAll([childrenUI]) -> self +--- Method +--- Select all children in a scroll area. +--- +--- Parameters: +--- * childrenUI - A table of `hs.axuielement` objects. +--- +--- Return: +--- * Self +function Grid:selectAll(childrenUI) + childrenUI = childrenUI or self:childrenUI() + if childrenUI then + self:selectedChildrenUI(childrenUI) + end + return self +end + +--- cp.ui.Grid:doSelectAll([childrenUI]) -> cp.rx.go.Statement +--- Method +--- A [Statement](cp.rx.go.Statement.md) that selects all children in a scroll area. +--- +--- Parameters: +--- * childrenUI - A table of `hs.axuielement` objects. +--- +--- Returns: +--- * A [Statement](cp.rx.go.Statement.md) that can be used to select all children. +function Grid:doSelectAll(childrenUI) + return If(self.UI):Then(function() + self:selectAll(childrenUI) + end) + :Label("Grid:doSelectAll(childrenUI)") +end + +--- cp.ui.Grid:saveLayout() -> table +--- Method +--- Saves the current layout of the Grid. +--- +--- Parameters: +--- * None +--- +--- Return: +--- * A table of the current layout of the Grid. +function Grid:saveLayout() + local layout = {} + local children = self:childrenUI() + if children then + for _,child in ipairs(children) do + local frame = child.AXFrame + if frame then + table.insert(layout, { + x = frame.x, + y = frame.y, + width = frame.w, + height = frame.h, + }) + end + end + end + return layout +end + +return Grid \ No newline at end of file diff --git a/src/extensions/cp/ui/GridElement.lua b/src/extensions/cp/ui/GridElement.lua index 726f22e61..ad801c6f9 100644 --- a/src/extensions/cp/ui/GridElement.lua +++ b/src/extensions/cp/ui/GridElement.lua @@ -409,7 +409,7 @@ function GridElement:selectRows(rows) local rowsUI = {} for _,row in ipairs(rows) do -- check it's a supported row type - if not self._rowInit:isTypeOf(row) then + if not self._rowInit:isClassFor(row) then error("Unsupported row type: " .. tostring(row)) end local rowUI = row:UI() @@ -432,7 +432,7 @@ end --- * `nil` function GridElement:selectRow(row) -- check it's a supported row type - if not self._rowInit:isTypeOf(row) then + if not self._rowInit:isClassFor(row) then error("Unsupported row type: " .. tostring(row)) end -- select the row @@ -467,6 +467,36 @@ function GridElement:doSelectRowAt(path) end) end +--- cp.ui.GridElement:saveLayout() -> table +--- Method +--- Saves the current layout of the grid. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * A table containing the current layout of the grid. +function GridElement:saveLayout() + local layout = {} + layout.selectedRowsUI = self:selectedRowsUI() + return layout +end + +--- cp.ui.GridElement:loadLayout(layout) -> nil +--- Method +--- Loads the layout of the grid. +--- +--- Parameters: +--- * layout - A table containing the layout to load. +--- +--- Returns: +--- * `nil` +function GridElement:loadLayout(layout) + if layout.selectedRowsUI then + self:selectedRowsUI(layout.selectedRowsUI) + end +end + -- === cp.ui.GridElement.Factory === -- -- A factory for processing `GridElement` contents, such as [Rows](cp.ui.Row.md) and [Columns](cp.ui.Column.md). diff --git a/src/extensions/cp/ui/Group.lua b/src/extensions/cp/ui/Group.lua index 6e39727c9..adeb3a95e 100644 --- a/src/extensions/cp/ui/Group.lua +++ b/src/extensions/cp/ui/Group.lua @@ -1,6 +1,66 @@ --- === cp.ui.Group === --- ---- UI Group. +--- Represents an `AXGroup` element. Typically contains several specific child elements in a prectable order. +--- +--- For example, if you have group containing a [StaticText](cp.ui.StaticText.md) then a [Button](cp.ui.Button.md), +--- then you can define it like so: +--- +--- ```lua +--- local Group = require "cp.ui.Group" +--- local StaticText = require "cp.ui.StaticText" +--- local Button = require "cp.ui.Button" +--- local has = require "cp.ui.has" +--- +--- local MyGroup = Group:subclass("MyGroup") +--- function MyGroup:initialize(parent, uiFinder) +--- Group.initialize(self, parent, uiFinder, has.list { +--- StaticText, Button +--- }) +--- end +--- ``` +--- +--- The above will create a `Group` with two children, a `StaticText` and a `Button`, which can be accessed via the `children[1]` +--- and `children[2]` properties, respectively. You could also choose to expose them more explicitly like so: +--- +--- ```lua +--- function MyGroup.lazy.value:label() -- return `StaticText` +--- return self.children[1] +--- end +--- +--- function MyGroup.lazy.value:activate() -- return `Button` +--- return self.children[2] +--- end +--- ``` +--- +--- Alternately, if you don't need to create a full subclass, you can use the `:containing(...)` class function to create a +--- `Group` with the specified children: +--- +--- ```lua +--- local Group = require "cp.ui.Group" +--- local StaticText = require "cp.ui.StaticText" +--- local Button = require "cp.ui.Button" +--- +--- return Group:containing(StaticText, Button) -- a `Group.Builder`, not a `Group` +--- ``` +--- +--- This is most useful in situations where it is embedded in a [ScrollArea](cp.ui.ScrollArea.md) or [SplitGroup](cp.ui.SplitGroup.md), +--- or similar, where it can be passed into that parent's `:containing(...)` method. For example: +--- +--- ```lua +--- local ScrollArea = require "cp.ui.ScrollArea" +--- local Group = require "cp.ui.Group" +--- local StaticText = require "cp.ui.StaticText" +--- local Button = require "cp.ui.Button" +--- +--- return ScrollArea:containing( +--- Group:containing(StaticText, Button) +--- ) +--- ``` +--- +--- Extends: [cp.ui.Element](cp.ui.Element.md) +--- +--- Includes: +--- * [HasExactChildren](cp.ui.HasExactChildren.md) local require = require @@ -8,15 +68,26 @@ local require = require local ax = require "cp.fn.ax" local Element = require "cp.ui.Element" - -local pack = table.pack +local HasExactChildren = require "cp.ui.HasExactChildren" local Group = Element:subclass("cp.ui.Group") + :include(HasExactChildren) :defineBuilder("containing") + :delegateTo("children") --- === cp.ui.Group.Builder === --- ---- Defines a `Group` builder. +--- Defines a `Group` [Builder](cp.ui.Builder.md). + +--- cp.ui.Group.Builder:containing(...) -> cp.ui.Group.Builder +--- Method +--- Defines the provided [Element](cp.ui.Element.md) initializers as the elements in `contents`. +--- +--- Parameters: +--- * ... - The [Element](cp.ui.Element.md) initializers to use. +--- +--- Returns: +--- * The `Builder` instance. --- cp.ui.Group:containing(...) -> cp.ui.Group.Builder --- Function @@ -37,37 +108,29 @@ local Group = Element:subclass("cp.ui.Group") --- --- Returns: --- * `true` if matches otherwise `false` -function Group.static.matches(element) - return Element.matches(element) and element:attributeValue("AXRole") == "AXGroup" -end +Group.static.matches = ax.matchesIf( + Element.matches, + ax.hasRole "AXGroup" +) ---- cp.ui.Group(parent, uiFinder[, contentsClass]) -> Alert +--- cp.ui.Group(parent, uiFinder, [childrenHandler]) -> cp.ui.Group --- Constructor --- Creates a new `Group` instance. --- --- Parameters: ---- * parent - The parent object. ---- * uiFinder - A function which will return the `hs.axuielement` when available. +--- * parent - The parent `Element` instance. +--- * uiFinder - The `axuielementObject` to use for the `Group`. +--- * childrenHandler - An optional function to use to handle the children. --- --- Returns: ---- * A new `Group` object. -function Group:initialize(parent, uiFinder, ...) +--- * The new `Group` instance. +--- +--- Notes: +--- * The `children` property will be populated with the provided `Element` initializers, in the provided order. +--- * If the `Group` is provided insufficient child initializers, it will default to `Element` for any missing children. +function Group:initialize(parent, uiFinder, childrenHandler) Element.initialize(self, parent, uiFinder) - self.childInits = pack(...) -end - ---- cp.ui.Group.childrenUI ---- Field ---- Contains the list of `axuielement` children of the group. -function Group.lazy.prop:childrenUI() - return ax.prop(self.UI, "AXChildren") -end - ---- cp.ui.Group.children ---- Field ---- Contains the list of `Element` children of the group. -function Group.lazy.value:children() - return ax.initElements(self, self.childrenUI, self.childInits) + self:childrenHandler(childrenHandler) end return Group diff --git a/src/extensions/cp/ui/HasExactChildren.lua b/src/extensions/cp/ui/HasExactChildren.lua new file mode 100644 index 000000000..76a4d3628 --- /dev/null +++ b/src/extensions/cp/ui/HasExactChildren.lua @@ -0,0 +1,63 @@ +--- === cp.ui.HasExactChildren === +--- +--- A mixin for [Element](cp.ui.Element.md) classes that have a specific set of `children`, always in the same order. +--- +--- The mixin can be applied to a subclass by calling `:include(cp.ui.HasExactChildren)`: +--- +--- ```lua +--- local HasExactChildren = require "cp.ui.HasExactChildren" +--- local MyGroup = Group:subclass("MyGroup"):include(HasExactChildren) +--- +--- function MyGroup:initialize(parent, uiFinder) +--- Group.initialize(self, parent, uiFinder) +--- self:childTypes(StaticText, TextField, Button) +--- end +--- ``` + +local require = require + +local fn = require "cp.fn" +local ax = require "cp.fn.ax" +local Element = require "cp.ui.Element" +local has = require "cp.ui.has" + +local chain, call = fn.chain, fn.call +local sort = fn.table.sort + +local HasExactChildren = Element:extension("cp.ui.HasExactChildren") + +local CHILDREN_HANDLER = {} + +local DEFAULT_HANDLER = has.zeroOrMore { Element } + +--- cp.ui.HasExactChildren.defaultChildrenHandler +--- Constant +--- The default handler for children (any number of [Element](cp.ui.Element.md) values). +HasExactChildren.static.defaultChildrenHandler = DEFAULT_HANDLER + +--- cp.ui.HasExactChildren.children +--- Constant +--- Defines the [UIHandler] that describes the children of the element. +--- By default, will map to any number of [Element](cp.ui.Element.md) objects. +function HasExactChildren:childrenHandler(handler) + self[CHILDREN_HANDLER] = has.handler(handler or DEFAULT_HANDLER) +end + +--- cp.ui.HasExactChildren.childrenUI +--- Field +--- The children UI elements in [top-down](cp.fn.ax.md#topDown) order. +function HasExactChildren.lazy.prop:childrenUI() + return ax.prop(self.UI, "AXChildren"):mutate( + chain // call >> sort(ax.topDown) + ) +end + +--- cp.ui.HasExactChildren.children +--- Field +--- Provides access to the [Elements](cp.ui.Element.md) of this `Element`'s children. +function HasExactChildren.lazy.value:children() + local handler = self[CHILDREN_HANDLER] or self.defaultChildrenHandler + return handler:build(self, self.childrenUI) +end + +return HasExactChildren \ No newline at end of file diff --git a/src/extensions/cp/ui/HasRepeatingChildren.lua b/src/extensions/cp/ui/HasRepeatingChildren.lua new file mode 100644 index 000000000..46bd80b6b --- /dev/null +++ b/src/extensions/cp/ui/HasRepeatingChildren.lua @@ -0,0 +1,150 @@ +--- === cp.ui.HasRepeatingChildren === +--- +--- A mixin for [Element](cp.ui.Element.md) classes that have `children` of a particular +--- type which repeat multiple times. +--- +--- The mixin can be applied to a subclass by calling `:include(cp.ui.HasRepeatingChildren)`: +--- +--- ```lua +--- local MyElement = Element:subclass("MyElement"):include(cp.ui.HasRepeatingChildren) +--- ``` +--- +--- The `childrenUI` property is used to get the table of `hs.axuielement` values for the `Element`. +--- The `children` property is used to get the [ElementRepeater](cp.ui.has.ElementRepeater.md) for the `Element`'s children. +--- +--- By default, the `children` property will contain [Element](cp.ui.Element.md) instances. This +--- can be changed by setting the `childType` property: +--- +--- ```lua +--- local MyElement = Element:subclass("MyElement"):include(cp.ui.HasRepeatingChildren) +--- +--- function MyElement:initialize(parent, uiFinder) +--- Element.initialize(self, parent, uiFinder) +--- self:childrenHandler(cp.ui.has.zeroOrMore(MyChildElement)) +--- end +--- +--- See also: +--- * [HasExactChildren](cp.ui.HasExactChildren.md) + +local require = require + +local fn = require "cp.fn" +local ax = require "cp.fn.ax" +local Element = require "cp.ui.Element" +local has = require "cp.ui.has" + +local chain = fn.chain + +local zeroOrMore, handler = has.zeroOrMore, has.handler + +local HasRepeatingChildren = Element:extension("cp.ui.HasRepeatingChildren") + +local DEFAULT_HANDLER = zeroOrMore(Element) + +local CHILDREN_HANDLER = {} + +--- cp.ui.HasRepeatingChildren:childrenHandler(childrenHandler) -> nil +--- Method +--- Sets the [UIHandler](cp.ui.has.UIHandler.md) for the `Element` being extended +--- +--- Parameters: +--- * childrenHandler - The [UIHandler](cp.ui.has.UIHandler.md) to use. +--- +--- Returns: +--- * `nil` +function HasRepeatingChildren:childrenHandler(childrenHandler) + childrenHandler = childrenHandler and handler(childrenHandler) or DEFAULT_HANDLER + self[CHILDREN_HANDLER] = childrenHandler +end + +--- cp.ui.HasRepeatingChildren:childHandler(childHandler) -> nil +--- Method +--- Sets the [UIHandler](cp.ui.has.UIHandler.md) for individual child `Element`. +--- This `Element` will allow zero or more of the specified child handler to match. +--- +--- Parameters: +--- * childHandler - The [UIHandler](cp.ui.has.UIHandler.md) to use. +--- +--- Returns: +--- * `nil` +function HasRepeatingChildren:childHandler(childHandler) + childHandler = childHandler and handler(childHandler or Element) + self:childrenHandler(zeroOrMore(childHandler)) +end + + +--- cp.ui.HasRepeatingChildren.childrenUI +--- Field +--- The children UI elements. +function HasRepeatingChildren.lazy.prop:childrenUI() + return ax.prop(self.UI, "AXChildren") +end + +--- cp.ui.HasRepeatingChildren.children +--- Field +--- Provides access to the [Elements](cp.ui.Element.md) of this `Element`'s children. +function HasRepeatingChildren.lazy.value:children() + return self[CHILDREN_HANDLER]:build(self, self.childrenUI) +end + +--- cp.ui.HasRepeatingChildren.childrenInTopDownOrderUI +--- Field +--- The children UI elements in top-down order. +--- +--- Notes: +--- * This may be expensive on [Elements](cp.ui.Element.md) that have many children. +function HasRepeatingChildren.lazy.prop:childrenInTopDownOrderUI() + return self:watchFor( + {"AXCreated", "AXUIElementDestroyed", "AXLiveRegionChanged"}, + self.childrenUI:mutate( + chain // ax.children >> fn.table.sort(ax.topDown) + ):cached(), + 0.01 + ) +end + +--- cp.ui.HasRepeatingChildren.childrenInTopDownOrder +--- Field +--- Provides access to the [Elements](cp.ui.Element.md) of this `Element`'s children in top-down order. +--- +--- Notes: +--- * This may be expensive on [Elements](cp.ui.Element.md) that have many children. +function HasRepeatingChildren.lazy.value:childrenInTopDownOrder() + return self[CHILDREN_HANDLER]:build(self, self.childrenInTopDownOrderUI) +end + +--- cp.ui.HasRepeatingChildren.childrenInNavigationOrderUI +--- Field +--- The children UI elements in navigation order. +function HasRepeatingChildren.lazy.prop:childrenInNavigationOrderUI() + return ax.prop(self.UI, "AXChildrenInNavigationOrder") +end + +--- cp.ui.HasRepeatingChildren.childrenInNavigationOrder +--- Field +--- The child [Elements](cp.ui.Element.md) of this `HasRepeatingChildren` in navigation order. +--- This will return an element for any number requested from `1` or above, +--- even if there is not currently a child at that index. It will always be +--- linked to the `childrenInNavigationOrderUI` at that index. +function HasRepeatingChildren.lazy.value:childrenInNavigationOrder() + return self[CHILDREN_HANDLER]:build(self, self.childrenInNavigationOrderUI) +end + +--- cp.ui.HasRepeatingChildren.visibleChildrenUI +--- Field +--- The visible children UI elements. +function HasRepeatingChildren.lazy.prop:visibleChildrenUI() + return ax.prop(self.UI, "AXVisibleChildren") +end + +--- cp.ui.HasRepeatingChildren.visibleChildren +--- Field +--- The visible child [Elements](cp.ui.Element.md) of this `HasRepeatingChildren`. +--- This will return an element for any number requested from `1` or above, +--- even if there is not currently a child at that index. It will always be +--- linked to the `visibleChildrenUI` at that index. +function HasRepeatingChildren.lazy.value:visibleChildren() + return self[CHILDREN_HANDLER]:build(self, self.visibleChildrenUI) +end + +return HasRepeatingChildren \ No newline at end of file diff --git a/src/extensions/cp/ui/Menu.lua b/src/extensions/cp/ui/Menu.lua index fe759b9d7..ff21629d0 100644 --- a/src/extensions/cp/ui/Menu.lua +++ b/src/extensions/cp/ui/Menu.lua @@ -162,4 +162,29 @@ function Menu:doSelectItemMatching(pattern, altPattern) :Label("cp.ui.Menu:doSelectItemMatching(pattern, altPattern)") end +--- cp.ui.Menu:doSelectItemWhere(predicate) -> cp.rx.go.Statement +--- Method +--- A [Statement](cp.rx.go.Statement.md) that will select the first item on the `Menu` that passes the `predicate`. +--- +--- Parameters: +--- * predicate - A function that will be passed the `MenuItem` and should return `true` if it matches. +--- +--- Returns: +--- * the `Statement`. +function Menu:doSelectItemWhere(predicate) + return If(self.UI) + :Then(function(ui) + for _, item in ipairs(ui) do + if predicate(item) then + item:performAction("AXPress") + return WaitUntil(self.isShowing):Is(false):TimeoutAfter(TIMEOUT_AFTER) + end + end + return self:doCancel():Then(false) + end) + :Otherwise(false) + :Label("cp.ui.Menu:doSelectItemWhere(predicate)") +end + + return Menu \ No newline at end of file diff --git a/src/extensions/cp/ui/Outline.lua b/src/extensions/cp/ui/Outline.lua index 490850e06..10a5a96be 100644 --- a/src/extensions/cp/ui/Outline.lua +++ b/src/extensions/cp/ui/Outline.lua @@ -1,20 +1,21 @@ --- === cp.ui.Outline === --- --- A Outline UI element. It extends [GridElement](cp.ui.GridElement.md), so will inherit all of its properties and methods. +--- +--- Extends: [cp.ui.GridElement](cp.ui.GridElement.md) local require = require -- local log = require "hs.logger".new "Outline" local ax = require "cp.fn.ax" --- local Column = require "cp.ui.Column" local GridElement = require "cp.ui.GridElement" local Outline = GridElement:subclass("cp.ui.Outline") --- cp.ui.Outline.matches(element) -> boolean --- Function ---- Checks if the `element` is a `Outline`. +--- Checks if the `element` is an `Outline`. --- --- Parameters: --- * element - An `axuielementObject` to check. diff --git a/src/extensions/cp/ui/PopUpButton.lua b/src/extensions/cp/ui/PopUpButton.lua index 76d0013af..13e77ea6e 100644 --- a/src/extensions/cp/ui/PopUpButton.lua +++ b/src/extensions/cp/ui/PopUpButton.lua @@ -6,10 +6,13 @@ local require = require --local log = require "hs.logger".new "PopUpButton" +local just = require "cp.just" local axutils = require "cp.ui.axutils" local Element = require "cp.ui.Element" -local go = require "cp.rx.go" +local doUntil = just.doUntil + +local go = require "cp.rx.go" local Do = go.Do local If = go.If local WaitUntil = go.WaitUntil @@ -106,7 +109,8 @@ end function PopUpButton:selectItem(index) local ui = self:UI() if ui then - local items = ui:performAction("AXPress")[1] + ui:performAction("AXPress") + local items = doUntil(function() return ui[1] end, 0.5) if items then local item = items[index] if item then @@ -114,7 +118,7 @@ function PopUpButton:selectItem(index) item:performAction("AXPress") else -- close the menu again - items:doAXCancel() + items:performAction("AXCancel") end end end @@ -137,11 +141,9 @@ function PopUpButton:doSelectItem(index) :Then(function(menuUI) local item = menuUI[index] if item then - item:performAction("AXPress") - return true + return Do(item:doPerformAction("AXPress")):Then(true) else - item:performAction("AXCancel") - return false + return Do(item:doPerformAction("AXCancel")):Then(false) end end) :Then(WaitUntil(self.menuUI):Is(nil):TimeoutAfter(TIMEOUT_AFTER)) @@ -173,12 +175,10 @@ function PopUpButton:doSelectValue(value, overrideValue) :Then(function(menuUI) for _,item in ipairs(menuUI) do if item:attributeValue("AXTitle") == value and item:attributeValue("AXEnabled") then - item:performAction("AXPress") - return true + return Do(item:doPerformAction("AXPress")):Then(true) end end - menuUI:doCancel() - return false + return Do(menuUI:doPerformAction("AXCancel")):Then(false) end) :Then(WaitUntil(self.menuUI):Is(nil):TimeoutAfter(TIMEOUT_AFTER)) :Otherwise(false) @@ -261,8 +261,8 @@ function PopUpButton:__call(parent, value) return self:value(value) end -function PopUpButton:__tostring() - return string.format("cp.ui.PopUpButton: %s", self:value()) +function PopUpButton:__valuestring() + return self:value() end --- cp.ui.PopUpButton:saveLayout() -> table diff --git a/src/extensions/cp/ui/ScrollArea.lua b/src/extensions/cp/ui/ScrollArea.lua index bdeffa0b3..d4f9b4086 100644 --- a/src/extensions/cp/ui/ScrollArea.lua +++ b/src/extensions/cp/ui/ScrollArea.lua @@ -36,44 +36,49 @@ --- The main advantage of this style is that you can pass the `Builder` in to other `Element` types --- that require an "`Element` init" that will only be provided a parent and UI finder. --- ---- This is a subclass of [Element](cp.ui.Element.md). +--- Extends: [Element](cp.ui.Element.md). +--- Delegates To: [contents](#contents). local require = require +local log = require "hs.logger".new "ScrollArea" + local fn = require "cp.fn" local ax = require "cp.fn.ax" - +local is = require "cp.is" +local has = require "cp.ui.has" local Element = require "cp.ui.Element" local ScrollBar = require "cp.ui.ScrollBar" -local delegator = require "cp.delegator" local chain = fn.chain -local ifilter, sort = fn.table.ifilter, fn.table.sort +local handler = has.handler +local sort = fn.table.sort +local isFunction = is.fn local ScrollArea = Element:subclass("cp.ui.ScrollArea") - :include(delegator):delegateTo("contents") + :delegateTo("contents") :defineBuilder("containing") --- === cp.ui.ScrollArea.Builder === --- --- [Builder](cp.ui.Builder.md) class for [ScrollArea](cp.ui.ScrollArea.lua). ---- cp.ui.ScrollArea.Builder:containing(contentBuilder) -> cp.ui.ScrollArea.Builder +--- cp.ui.ScrollArea.Builder:containing(contentsHandler) -> cp.ui.ScrollArea.Builder --- Method --- Sets the content `Element` type/builder to the specified value. --- --- Parameters: ---- * contentBuilder - A `callable` that accepts a `parent` and `uiFinder` parameter, and returns an `Element` instance. +--- * contentsHandler - A [UIHandler](cp.ui.UIHandler.md) or value supported by [cp.ui.has.handler](cp.ui.has.md#handler). --- --- Returns: --- * The `Builder` instance. ---- cp.ui.ScrollArea:containing(elementInit) -> cp.ui.ScrollArea.Builder +--- cp.ui.ScrollArea:containing(contentsHandler) -> cp.ui.ScrollArea.Builder --- Function --- A static method that returns a new `ScrollArea.Builder`. --- --- Parameters: ---- * elementInit - An `Element` initializer. +--- * contentsHandler - A [UIHandler](cp.ui.UIHandler.md) or value supported by [cp.ui.has.handler](cp.ui.has.md#handler). --- --- Returns: --- * A new `ScrollArea.Builder` instance. @@ -82,6 +87,8 @@ local ScrollArea = Element:subclass("cp.ui.ScrollArea") -- cp.ui.ScrollArea ----------------------------------------------------------------------- +local DEFAULT_HANDLER = has.zeroOrMore(Element) + --- cp.ui.ScrollArea.matches(element) -> boolean --- Function --- Checks to see if an element matches what we think it should be. @@ -91,36 +98,61 @@ local ScrollArea = Element:subclass("cp.ui.ScrollArea") --- --- Returns: --- * `true` if matches otherwise `false` -ScrollArea.static.matches = fn.all(Element.matches, ax.hasRole "AXScrollArea") +ScrollArea.static.matches = ax.matchesIf(Element.matches, ax.hasRole "AXScrollArea") ---- cp.ui.ScrollArea(parent, uiFinder[, contentsInit]) -> cp.ui.ScrollArea +--- cp.ui.ScrollArea(parent, uiFinder[, contentsHandler]) -> cp.ui.ScrollArea --- Constructor --- Creates a new `ScrollArea`. --- --- Parameters: --- * parent - The parent object. --- * uiFinder - A `function` or `cp.prop` which will return the `hs.axuielement` when available. ---- * contentsInit - An optional function to initialise the `contentsUI`. Uses `cp.ui.Element` by default. +--- * contentsHandler - An optional [UIHandler](cp.ui.has.UIHandler.md) to initialise the `contentsUI`. --- --- Returns: --- * The new `ScrollArea`. -function ScrollArea:initialize(parent, uiFinder, contentsInit) +--- +--- Notes: +--- * If the `contentsHandler` is not provided, it will default to any number of [Element](cp.ui.Element.md). +--- * If the `contentsHandler` is provided, it can be any value passed in to the [cp.ui.has.handler](cp.ui.has.md#handler) function. +function ScrollArea:initialize(parent, uiFinder, contentsHandler) Element.initialize(self, parent, uiFinder) - self.contentsInit = contentsInit or Element + self.contentsHandler = handler(contentsHandler or DEFAULT_HANDLER) +end + +--- cp.ui.ScrollArea.childrenUI +--- Field +--- The `hs.axuielement`s for the `AXChildren` attribute. +function ScrollArea.lazy.prop:childrenUI() + return ax.prop(self.UI, "AXChildren") +end + +--- cp.ui.ScrollArea.childrenInNavigationOrderUI +--- Field +--- The `hs.axuielement`s for the `AXChildren` attribute, in navigation order. +function ScrollArea.lazy.prop:childrenInNavigationOrderUI() + return ax.prop(self.UI, "AXChildrenInNavigationOrder") end --- cp.ui.ScrollArea.contentsUI --- Field ---- Returns the `axuielement` representing the Scroll Area Contents, or `nil` if not available. +--- Returns the `hs.axuielement`s representing the Scroll Area Contents, or `nil` if not available. function ScrollArea.lazy.prop:contentsUI() - return self.UI:mutate(chain // ax.attribute "AXContents" >> fn.table.first) + return ax.prop(self.UI, "AXContents") +end + +--- cp.ui.ScrollArea.contentsUI +--- Field +--- Returns the `hs.axuielement`s representing the Scroll Area Contents, or `nil` if not available. +function ScrollArea.lazy.prop:contentsInTopDownOrderUI() + return self.UI:mutate(chain // ax.attribute "AXContents" >> sort(ax.topDown)) end --- cp.ui.ScrollArea.contents --- Field --- Returns the `Element` representing the `ScrollArea` Contents. function ScrollArea.lazy.value:contents() - return self.contentsInit(self, self.contentsUI) + return self.contentsHandler:build(self, self.contentsInTopDownOrderUI) end --- cp.ui.ScrollArea.verticalScrollBar @@ -137,37 +169,12 @@ function ScrollArea.lazy.value:horizontalScrollBar() return ScrollBar(self, ax.prop(self.UI, "AXHorizontalScrollBar")) end ---- cp.ui.ScrollArea.selectedChildrenUI ---- Field ---- Returns the `axuielement` representing the Scroll Area Selected Children, or `nil` if not available. -function ScrollArea.lazy.prop:selectedChildrenUI() - return ax.prop(self.contentsUI, "AXSelectedChildren") -end - ----------------------------------------------------------------------- -- -- CONTENT UI: -- ----------------------------------------------------------------------- ---- cp.ui.ScrollArea:childrenUI(filterFn) -> hs.axuielement | nil ---- Method ---- Returns the list of `axuielement`s representing the Scroll Area Contents, sorted top-down, or `nil` if not available. ---- ---- Parameters: ---- * filterFn - The function which checks if the child matches the requirements. ---- ---- Return: ---- * The `axuielement` or `nil`. -function ScrollArea:childrenUI(filterFn) - local finder = chain // - fn.constant(self.contentsUI) >> - ax.children >> - ifilter(filterFn) >> - sort(ax.topDown) - return finder() -end - --- cp.ui.ScrollArea.viewFrame --- Field --- A `cp.prop` reporting the Scroll Area frame as a table containing `{x, y, w, h}`. @@ -192,7 +199,7 @@ function ScrollArea.lazy.prop:viewFrame() :monitor(self.verticalScrollBar.frame) end ---- cp.ui.ScrollArea:showChild(childUI) -> self +--- cp.ui.ScrollArea:showChildUI(childUI) -> self --- Method --- Show's a child element in a Scroll Area. --- @@ -201,7 +208,7 @@ end --- --- Return: --- * Self -function ScrollArea:showChild(childUI) +function ScrollArea:showChildUI(childUI) local ui = self:UI() if ui and childUI then local vFrame = self:viewFrame() @@ -230,91 +237,65 @@ function ScrollArea:showChild(childUI) return self end ---- cp.ui.ScrollArea:showChildAt(index) -> self +--- cp.ui.ScrollArea:doShowContentsAt(frame) -> self --- Method ---- Show's a child element in a Scroll Area given a specific index. +--- Shows the contents of the Scroll Area at the given frame. --- --- Parameters: ---- * index - The index of the child you want to show. +--- * frame - The frame to show the contents at. --- ---- Return: +--- Returns: --- * Self -function ScrollArea:showChildAt(index) - local ui = self:childrenUI() - if ui and #ui >= index then - self:showChild(ui[index]) +function ScrollArea:showContentsAt(childFrame) + -- show ourself first + self:show() + + -- check we're actually showing... + if not self:isShowing() then + log.w("ScrollArea:showContentsAt: Scroll Area is not showing.") + return self end - return self -end ---- cp.ui.ScrollArea:selectChild(childUI) -> self ---- Method ---- Select a specific child within a Scroll Area. ---- ---- Parameters: ---- * childUI - The `hs.axuielement` object of the child you want to select. ---- ---- Return: ---- * Self -function ScrollArea:selectChild(childUI) - if childUI then - local parent = childUI.parent and childUI:parent() - if parent then - parent:setAttributeValue("AXSelectedChildren", { childUI } ) - end - end - return self -end + -- get the view frame + local vFrame = self:viewFrame() ---- cp.ui.ScrollArea:selectChildAt(index) -> self ---- Method ---- Select a child element in a Scroll Area given a specific index. ---- ---- Parameters: ---- * index - The index of the child you want to select. ---- ---- Return: ---- * Self -function ScrollArea:selectChildAt(index) - local ui = self:childrenUI() - if ui and #ui >= index then - self:selectChild(ui[index]) - end - return self -end + -- show the contents at the given frame + local top = vFrame.y + local bottom = vFrame.y + vFrame.h ---- cp.ui.ScrollArea:selectAll(childrenUI) -> self ---- Method ---- Select all children in a scroll area. ---- ---- Parameters: ---- * childrenUI - A table of `hs.axuielement` objects. ---- ---- Return: ---- * Self -function ScrollArea:selectAll(childrenUI) - childrenUI = childrenUI or self:childrenUI() - if childrenUI then - for _,clip in ipairs(childrenUI) do - self:selectChild(clip) + local childTop = childFrame.y + local childBottom = childFrame.y + childFrame.h + + if childTop < top or childBottom > bottom then + -- we need to scroll + local oFrame = self.contents:frame() + local scrollHeight = oFrame.h - vFrame.h + + local vValue + if childTop < top or childFrame.h > vFrame.h then + vValue = (childTop-oFrame.y)/scrollHeight + else + vValue = 1.0 - (oFrame.y + oFrame.h - childBottom)/scrollHeight end + self.verticalScrollBar.value:set(vValue) end + return self end ---- cp.ui.ScrollArea:deselectAll() -> self +--- cp.ui.ScrollArea:showChildAt(index) -> self --- Method ---- Deselect all children in a scroll area. +--- Show's a child element in a Scroll Area given a specific index. --- --- Parameters: ---- * None +--- * index - The index of the child you want to show. --- --- Return: --- * Self -function ScrollArea:deselectAll() - local contents = self:contentsUI() - if contents then - contents:setAttributeValue("AXSelectedChildren", {}) +function ScrollArea:showChildAt(index) + local ui = self:childrenUI() + if ui and #ui >= index then + self:showChildUI(ui[index]) end return self end @@ -385,7 +366,11 @@ function ScrollArea:saveLayout() layout.horizontalScrollBar = self.horizontalScrollBar:saveLayout() layout.verticalScrollBar = self.verticalScrollBar:saveLayout() - layout.selectedChildren = self:selectedChildrenUI() + + local contents = self.contents + if contents and isFunction(contents.saveLayout) then + layout.contents = contents:saveLayout() + end return layout end @@ -406,6 +391,11 @@ function ScrollArea:loadLayout(layout) self.verticalScrollBar:loadLayout(layout.verticalScrollBar) self.horizontalScrollBar:loadLayout(layout.horizontalScrollBar) + local contents = self.contents + if contents and isFunction(contents.loadLayout) then + contents:loadLayout(layout.contents) + end + Element.loadLayout(layout) end end diff --git a/src/extensions/cp/ui/Sheet.lua b/src/extensions/cp/ui/Sheet.lua index 5cdff8d7e..c4ac21d03 100644 --- a/src/extensions/cp/ui/Sheet.lua +++ b/src/extensions/cp/ui/Sheet.lua @@ -4,14 +4,16 @@ local require = require -local axutils = require("cp.ui.axutils") -local Button = require("cp.ui.Button") -local Element = require("cp.ui.Element") +local ax = require "cp.fn.ax" +local axutils = require "cp.ui.axutils" +local Button = require "cp.ui.Button" +local Element = require "cp.ui.Element" +local HasExactChildren = require "cp.ui.HasExactChildren" -local If = require("cp.rx.go.If") -local WaitUntil = require("cp.rx.go.WaitUntil") +local If = require "cp.rx.go.If" +local WaitUntil = require "cp.rx.go.WaitUntil" -local Sheet = Element:subclass("cp.ui.Sheet") +local Sheet = Element:subclass("cp.ui.Sheet"):include(HasExactChildren) --- cp.ui.Sheet.matches(element) -> boolean --- Function @@ -22,43 +24,43 @@ local Sheet = Element:subclass("cp.ui.Sheet") --- --- Returns: --- * `true` if matches otherwise `false` -function Sheet.static.matches(element) - return Element.matches(element) and element:attributeValue("AXRole") == "AXSheet" -end +Sheet.static.matches = ax.matchesIf(Element.matches, ax.hasRole "AXSheet") ---- cp.ui.Sheet(parent, uiFinder) -> Sheet +--- cp.ui.Sheet(parent, uiFinder, [childrenHandler]) -> Sheet --- Constructor --- Creates a new `Sheet` instance. --- --- Parameters: --- * parent - The parent object. --- * uiFinder - The UI, either a `cp.prop` or a `function`. +--- * childrenHandler - An optional [UIHandler](cp.ui.has.UIHandler.md) to use to convert children `hs.axuielement`s into [Element](cp.ui.Element.md) values. --- --- Returns: ---- * A new `Browser` object. -function Sheet:initialize(parent, UI) +--- * A new `Sheet` object. +function Sheet:initialize(parent, UI, childrenHandler) Element.initialize(self, parent, UI) + self:childrenHandler(childrenHandler) end --- cp.ui.Sheet.title --- Field --- Gets the title of the sheet. function Sheet.lazy.prop:title() - return axutils.prop(self.UI, "AXTitle") + return ax.prop(self.UI, "AXTitle") end --- cp.ui.Sheet.default --- Field --- The default [Button](cp.ui.Button.md) for the `Sheet`. function Sheet.lazy.value:default() - return Button(self, axutils.prop(self.UI, "AXDefaultButton")) + return Button(self, ax.prop(self.UI, "AXDefaultButton")) end --- cp.ui.Sheet.cancel --- Field --- The cancel [Button](cp.ui.Button.md) for the `Sheet`. function Sheet.lazy.value:cancel() - return Button(self, axutils.prop(self.UI, "AXCancelButton")) + return Button(self, ax.prop(self.UI, "AXCancelButton")) end --- cp.ui.Sheet:hide() -> none diff --git a/src/extensions/cp/ui/SplitGroup.lua b/src/extensions/cp/ui/SplitGroup.lua index 801fd2c86..bc8448b1f 100644 --- a/src/extensions/cp/ui/SplitGroup.lua +++ b/src/extensions/cp/ui/SplitGroup.lua @@ -1,8 +1,79 @@ --- === cp.ui.SplitGroup === --- --- Split Group UI. A SplitGroup is a container that can be split into multiple sections. ---- Each section is an [Element](cp.ui.Element.md), and they are divided by a [Splitter](cp.ui.Splitter.md), ---- resulting in something like `{ Element, Splitter, Element }`. +--- Each section is one or more [Elements](cp.ui.Element.md), and they are divided by a [Splitter](cp.ui.Splitter.md). +--- +--- It's possible to have multiple elements in a single section, so if you wish to specify specific [Element](cp.ui.Element.md) subclasses +--- for each section, you can do so like so: +--- +--- ```lua +--- local MySplitGroup = SplitGroup:subclass("MySplitGroup") +--- +--- function MySplitGroup:initialize(parent, uiFinder) +--- SplitGroup.initialize(self, parent, uiFinder, {StaticText, Outline}, TextField) +--- end +--- ``` +--- +--- The above will create a `MySplitGroup` with two sections, the first with a [StaticText](cp.ui.StaticText.md) and an [Outline](cp.ui.Outline.md), +--- and the second with a [TextField](cp.ui.TextField.md). +--- +--- The above is effectively the same as: +--- +--- ```lua +--- local mySplitGroupBuilder = SplitGroup:with( +--- { StaticText, Outline }, +--- TextField, +--- ) +--- local mySplitGroup = mySplitGroupBuilder:build(parent, uiFinder) +--- ``` +--- +--- This provides a [Builder](cp.ui.Builder.md) that can be used to create a `SplitGroup` with the specified sections, and can be useful +--- if you don't need or want to create a custom subclass in a given circumstance. +--- +--- Once you've defined your `SplitGroup` you can use it like so: +--- +--- ```lua +--- local mySplitGroup = MySplitGroup(parent, uiFinder) +--- local sidebar = mySplitGroup.sections[1] +--- local label = mySplitGroup.sections[1][1] +--- local outline = sidebar[2] +--- local content = mySplitGroup.sections[2] +--- ``` +--- +--- And because `SplitGroup` delegates to [sections](#sections), you can just access the contents of it directly: +--- +--- ```lua +--- local mySplitGroup = MySplitGroup(parent, uiFinder) +--- local sidebar = mySplitGroup[1] +--- local label = mySplitGroup[1][1] +--- local outline = sidebar[2] +--- local content = mySplitGroup[2] +--- ``` +--- +--- Of course, random indexes are a hassle to remember, so we can use the [cp.ui.has.alias](cp.ui.has.md#alias) API to make it easier to access: +--- +--- ```lua +--- local alias = require "cp.ui.has" .alias +--- +--- local mySplitGroup = SplitGroup:with( +--- alias "sidebar" { +--- alias "label" { StaticText }, +--- alias "outline" { Outline }, +--- }, +--- alias "content" { TextField } +--- ):build(parent, uiFinder) +--- +--- local sidebar = mySplitGroup.sidebar +--- local label = mySplitGroup.sidebar.label +--- local outline = sidebar.outline +--- local content = mySplitGroup.content +--- ``` +--- +--- Note: You can still access sections by their index, but the alias API is more readable. +--- +--- Extends: [cp.ui.Element](cp.ui.Element.md) +--- Delegates To: [sections](#sections) + local require = require -- local log = require "hs.logger".new "SplitGroup" @@ -11,75 +82,143 @@ local fn = require "cp.fn" local ax = require "cp.fn.ax" local Element = require "cp.ui.Element" local Splitter = require "cp.ui.Splitter" +local has = require "cp.ui.has" local chain = fn.chain -local get, sort, split = fn.table.get, fn.table.sort, fn.table.split -local ifilter = fn.table.ifilter +local split, imap, sort = fn.table.split, fn.table.imap, fn.table.sort +local insert = table.insert +local handler, list = has.handler, has.list local SplitGroup = Element:subclass("cp.ui.SplitGroup") + :delegateTo("sections") + :defineBuilder("with") + +--- === cp.ui.SplitGroup.Builder === +--- +--- Defines a `SplitGroup` [Builder](cp.ui.Builder.md). + +--- cp.ui.SplitGroup.Builder:with(...) -> cp.ui.SplitGroup.Builder +--- Method +--- Defines the provided [UIHandlers](cp.ui.has.UIHandler.md), one for each section, in the order they are specified. +--- +--- Parameters: +--- * ... - The [Element](cp.ui.Element.md) initializers to use. +--- +--- Returns: +--- * The `SplitGroup.Builder` instance. +--- +--- Notes: +--- * Each section value can be anything compatible with [cp.ui.has.handler](cp.ui.has.md#handler). --- cp.ui.SplitGroup.matches(element) -> boolean --- Function --- Checks to see if an element matches what we think it should be. --- --- Parameters: ---- * element - An `axuielementObject` to check. +--- * element - An `hs.axuielement` to check. --- --- Returns: --- * `true` if matches otherwise `false` SplitGroup.static.matches = ax.matchesIf(Element.matches, ax.hasRole "AXSplitGroup") ---- cp.ui.SplitGroup(parent, uiFinder, childInits) -> cp.ui.SplitGroup +--- cp.ui.SplitGroup(parent, uiFinder, [...]) -> cp.ui.SplitGroup --- Constructor --- Creates a new `SplitGroup`. --- --- Parameters: --- * parent - The parent object. --- * uiFinder - The `function` or `cp.prop` which returns an `hs.axuielement` for the `SplitGroup`, or `nil`. ---- * childInits - A `table` of section-creating functions, in order, including the `Splitter`s. +--- * ... - An optional list of [UIHandlers](cp.ui.has.UIHandler.md), one per section. --- --- Returns: --- * A new `SplitGroup` instance. --- --- Notes: ---- * Many `childInints` values can be the actual `Element` value (eg: `TextArea`), since they only require the `parent` and `uiFinder` parameters. ---- * The [cp.fn.ax.init](cp.fn.ax.md#init) function can be useful for passing in `Element` types which require more than just the `parent` and `uiFinder` values. ---- * Example: `SplitGroup(parent, uiFinder, { cp.fn.ax.init(ScrollArea, cp.ui.List), cp.fn.ax.init(ScrollArea, cp.ui.TextArea) }) -function SplitGroup:initialize(parent, uiFinder, childInits) - self.childInits = childInits or {} +--- * The values passed for the list of [UIHandlers](cp.ui.has.UIHandler.md) can be any valid value that can be passed to [cp.ui.has.handler](cp.ui.has.md#handler). +--- * For example, `SplitGroup(parent, ui, TextField, has.list { StaticText, Table })` would create a `SplitGroup` with two sections, the first with a `TextField`, and the second with a `StaticText` and `Table`. +function SplitGroup:initialize(parent, uiFinder, ...) + local sectionHandlers = {} + local splitterHandlers = {} + local childHandlers = {} + + for i = 1, select("#", ...) do + if i ~= 1 then + insert(splitterHandlers, Splitter) + insert(childHandlers, Splitter) + end + local sectionHandler = handler(select(i, ...)) + insert(sectionHandlers, sectionHandler) + insert(childHandlers, sectionHandler) + end + + self.sectionHandlers = sectionHandlers + self.splittersHandler = list(splitterHandlers) + self.childrenHandler = list(childHandlers) + Element.initialize(self, parent, uiFinder) end ---- cp.ui.SplitGroup.childrenUI +--- cp.ui.SplitGroup.childrenUI --- Field ---- The list of `axuielementObject`s for the sections, sorted in [top-down](cp.fn.ax.md#topDown) order. +--- The list of `axuielement`s for the sections, sorted in [top-down](cp.fn.ax.md#topDown) order. function SplitGroup.lazy.prop:childrenUI() - return self.UI:mutate(chain // ax.children >> sort(ax.topDown)) + return ax.prop(self.UI, "AXChildren") end ---- cp.ui.SplitGroup.children +--- cp.ui.SplitGroup.children --- Field ---- All children of the Split Group, based on the `childInits` passed to the constructor. ---- Is `nil` if no `childInits` were provided. +--- All children of the Split Group, including sections and splitters. function SplitGroup.lazy.value:children() - return ax.initElements(self, self.childrenUI, self.childInits) + return self.childrenHandler:build(self, self.childrenUI) end ---- cp.ui.SplitGroup.splittersUI +--- cp.ui.SplitGroup.splittersUI --- Field ---- The list of `axuielementObject`s for the splitters. +--- The list of `hs.axuielement`s for the splitters. function SplitGroup.lazy.prop:splittersUI() return ax.prop(self.UI, "AXSplitters") end ---- cp.ui.SplitGroup.splitters +--- cp.ui.SplitGroup.splitters --- Field --- The `Splitters` of the `SplitGroup`. There will be one less splitter than there are sections. -SplitGroup.lazy.value.splitters = chain // get "children" >> ifilter(Splitter.matches) +function SplitGroup.lazy.value:splitters() + return self.splittersHandler:build(self, self.splittersUI) +end ---- cp.ui.SplitGroup.sections +--- cp.ui.SplitGroup.sectionsUI --- Field ---- The `Sections` of the `SplitGroup`. Each section will be a `table` of `cp.ui.Element`s. -SplitGroup.lazy.value.sections = chain // get "children" >> split(Splitter.matches) +--- The list of tables of `hs.axuielement`s for the each section, each sorted [top-down](cp.fn.ax.md#topDown). +function SplitGroup.lazy.prop:sectionsUI() + return self.childrenUI:mutate(chain // fn.call + >> split(Splitter.matches) >> fn.args.only(1) + >> imap(sort(ax.topDown)) + ) +end + +--- cp.ui.SplitGroup.sections +--- Field +--- The sections of the `SplitGroup`. Each section will the result of the matching [UIHandler](cp.ui.has.UIHandler.md) provided to the initializer. +--- Sections can be accessed via their number (1-based). If the handler provided an `alias`, then the alias will be added as well. +function SplitGroup.lazy.value:sections() + local sectionHandlers = self.sectionHandlers + local sections = {} + + for i, sectionHandler in ipairs(sectionHandlers) do + local ui = self.sectionsUI:mutate(function(original) + local sectionUI = original() + if sectionUI then + return sectionUI[i] + end + end) + local section = sectionHandler:build(self, ui) + sections[i] = section + if sectionHandler.alias then + sections[sectionHandler.alias] = section + end + end + + return sections +end return SplitGroup diff --git a/src/extensions/cp/ui/StaticText.lua b/src/extensions/cp/ui/StaticText.lua index fec7c4a66..fd95a06e4 100644 --- a/src/extensions/cp/ui/StaticText.lua +++ b/src/extensions/cp/ui/StaticText.lua @@ -4,10 +4,11 @@ local require = require +local inspect = require "hs.inspect" local timer = require "hs.timer" +local ax = require "cp.fn.ax" local Element = require "cp.ui.Element" -local notifier = require "cp.ui.notifier" local prop = require "cp.prop" local delayedTimer = timer.delayed @@ -23,9 +24,7 @@ local StaticText = Element:subclass("cp.ui.StaticText") --- --- Returns: --- * If `true`, the element is a Static Text element. -function StaticText.static.matches(element) - return Element.matches(element) and element:attributeValue("AXRole") == "AXStaticText" -end +StaticText.static.matches = ax.matchesIf(Element.matches, ax.hasRole "AXStaticText") --- cp.ui.StaticText(parent, uiFinder[, convertFn]) -> StaticText --- Method @@ -133,8 +132,25 @@ function StaticText:clear() return self end -function StaticText.lazy.method:notifier() - return notifier.new(self:app():bundleID(), self.UI) +--- cp.ui.StaticText.insertionPointLineNumber +--- Field +--- The line number of the insertion point. +function StaticText.lazy.prop:insertionPointLineNumber() + return ax.prop(self.UI, "AXInsertionPointLineNumber") +end + +--- cp.ui.StaticText.selectedTextRange +--- Field +--- The selected text range as a `table` with a `length` and `location` number. +function StaticText.lazy.prop:selectedTextRange() + return ax.prop(self.UI, "AXSelectedTextRange") +end + +--- cp.ui.StaticText.visibleCharacterRange +--- Field +--- The visible character range as a `table` with a `length` and `location` number. +function StaticText.lazy.prop:visibleCharacterRange() + return ax.prop(self.UI, "AXVisibleCharacterRange") end --- cp.ui.StaticText:saveLayout() -> table @@ -184,4 +200,8 @@ function StaticText:__call(parent, value) return self:value(value) end +function StaticText:__valuestring() + return inspect(self:value()) +end + return StaticText diff --git a/src/extensions/cp/ui/TextField.lua b/src/extensions/cp/ui/TextField.lua index a29a7ba44..799e0fb65 100644 --- a/src/extensions/cp/ui/TextField.lua +++ b/src/extensions/cp/ui/TextField.lua @@ -6,13 +6,14 @@ local require = require -- local log = require "hs.logger" .new "TextField" +local inspect = require "hs.inspect" local go = require "cp.rx.go" local Element = require "cp.ui.Element" local If = go.If local TextField = Element:subclass("cp.ui.TextField") - :defineBuilder("convertingGet", "convertingSet") + :defineBuilder("forcingFocus", "convertingGet", "convertingSet") ----------------------------------------------------------------------- -- TextField.Builder definitions. @@ -22,6 +23,13 @@ local TextField = Element:subclass("cp.ui.TextField") --- --- Defines a `TextField` [Builder](cp.ui.Builder.md). +--- cp.ui.TextField.Builder:forcingFocus(value) -> cp.ui.TextField.Builder +--- Method +--- If set to `true`, the TextField will be focused when it is created. +--- +--- Parameters: +--- * value - If `true`, the TextField will be focused when its value is set. + --- cp.ui.TextField.Builder:convertingGet(getter) -> cp.ui.TextField.Builder --- Method --- Specifies a function that will convert the result of the `TextField:value()` getter to a different type. @@ -49,8 +57,18 @@ local TextField = Element:subclass("cp.ui.TextField") --- * The `setter` will be called with the input value from a `TextField:value(...)` call as its only parameter. --- It should return a `string` to be saved into the `TextField`. +--- cp.ui.TextField:forcingFocus(value) -> cp.ui.TextField.Builder +--- Function +--- If `true`, the `TextField` will be focused when its value is set. +--- +--- Parameters: +--- * value - If `true`, the `TextField` will be focused when its value is set. +--- +--- Returns: +--- * The `TextField.Builder` + --- cp.ui.TextField:convertingGet(getter) -> cp.ui.TextField.Builder ---- Field +--- Function --- Creates a `Builder` that will convert the result of the `TextField:value()` getter to a different type. --- --- Parameters: @@ -64,7 +82,7 @@ local TextField = Element:subclass("cp.ui.TextField") --- * For example, `TextField:convertGet(tonumber)` will use the standard `tonumber` function to convert the value to a number. --- cp.ui.TextField:convertingSet(setter) -> cp.ui.TextField.Builder ---- Field +--- Function --- Creates a `Builder` that will convert the value before setting it in the `TextField`. --- --- Parameters: @@ -97,7 +115,7 @@ function TextField.static.matches(element, subrole) (subrole == nil or element:attributeValue("AXSubrole") == subrole) end ---- cp.ui.TextField(parent, uiFinder[, convertFn]) -> TextField +--- cp.ui.TextField(parent, uiFinder, [forceFocus], [getConvertFn], [setConvertFn]) -> TextField --- Method --- Creates a new TextField. They have a parent and a finder function. --- Additionally, an optional `convert` function can be provided, with the following signature: @@ -116,13 +134,19 @@ end --- Parameters: --- * parent - The parent object. --- * uiFinder - The function will return the `axuielement` for the TextField. +--- * forceFocus - (optional) If `true`, the TextField will be forced to be focused. Defaults to `false`. --- * getConvertFn - (optional) If provided, will be passed the `string` value when returning. --- * setConvertFn - (optional) If provided, will be passed the `number` value when setting. --- --- Returns: --- * The new `TextField`. -function TextField:initialize(parent, uiFinder, getConvertFn, setConvertFn) +function TextField:initialize(parent, uiFinder, forceFocus, getConvertFn, setConvertFn) Element.initialize(self, parent, uiFinder) + if type(forceFocus) == "function" then + getConvertFn, setConvertFn = forceFocus, getConvertFn + forceFocus = nil + end + self._forceFocus = forceFocus self._getConvertFn = getConvertFn self._setConvertFn = setConvertFn end @@ -290,4 +314,8 @@ function TextField.__call(self, parent, value) return self:value(value) end +function TextField:__valuestring() + return inspect(self:value()) +end + return TextField diff --git a/src/extensions/cp/ui/Window.lua b/src/extensions/cp/ui/Window.lua index 04e7a101d..3d914f05f 100644 --- a/src/extensions/cp/ui/Window.lua +++ b/src/extensions/cp/ui/Window.lua @@ -7,8 +7,11 @@ local require = require local hswindow = require "hs.window" local class = require "middleclass" +local delegator = require "cp.delegator" local lazy = require "cp.lazy" local prop = require "cp.prop" + +local ax = require "cp.fn.ax" local axutils = require "cp.ui.axutils" local notifier = require "cp.ui.notifier" local Alert = require "cp.ui.Alert" @@ -19,7 +22,7 @@ local WaitUntil = require "cp.rx.go.WaitUntil" local format = string.format -local Window = class("cp.ui.Window"):include(lazy) +local Window = class("cp.ui.Window"):include(lazy):include(delegator) --- cp.ui.Window.matches(element) -> boolean --- Function @@ -156,19 +159,19 @@ function Window.lazy.prop:modal() end function Window.lazy.value:closeButton() - return Button(self.UI, "AXCloseButton") + return Button(self, ax.prop(self.UI, "AXCloseButton")) end function Window.lazy.value:minimizeButton() - return Button(self.UI, "AXMinimizeButton") + return Button(self, ax.prop(self.UI, "AXMinimizeButton")) end function Window.lazy.value:fullScreenButton() - return Button(self.UI, "AXFullScreenButton") + return Button(self, ax.prop(self.UI, "AXFullScreenButton")) end function Window.lazy.value:zoomButton() - return Button(self.UI, "AXZoomButton") + return Button(self, ax.prop(self.UI, "AXZoomButton")) end --- cp.ui.Window.exists diff --git a/src/extensions/cp/ui/has/AliasHandler.lua b/src/extensions/cp/ui/has/AliasHandler.lua new file mode 100644 index 000000000..10ee05510 --- /dev/null +++ b/src/extensions/cp/ui/has/AliasHandler.lua @@ -0,0 +1,56 @@ +--- === cp.ui.AliasHandler === +--- +--- A handler that matches a `hs.axuielement` and creates a `Field` instance as a varable on the parent. + +local require = require + +local UIHandler = require "cp.ui.has.UIHandler" + +local AliasHandler = UIHandler:subclass("cp.ui.has.AliasHandler") + +--- cp.ui.AliasHandler(alias, elementHandler) -> cp.ui.AliasHandler +--- Constructor +--- Creates a new `AliasHandler` instance. This will indicate that the value built by the provided `elementHandler` should be +--- assigned to the `alias` on the parent. +--- +--- Parameters: +--- * alias - The name of the field to create on the parent. +--- * elementHandler - The [ElementHandler](cp.ui.ElementHandler.md) to use to build the `Element` instance. +--- +--- Returns: +--- * The new `AliasHandler` instance. +function AliasHandler:initialize(alias, elementHandler) + UIHandler.initialize(self) + self.alias = alias + self.elementHandler = elementHandler +end + +--- cp.ui.AliasHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Returns the result from the wrapped `elementHandler`. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The result from the wrapped `elementHandler`. +function AliasHandler:matches(uiList) + return self.elementHandler:matches(uiList) +end + + +--- cp.ui.AliasHandler:build(parent, matchedUIFinder) -> any +--- Method +--- Builds the [Element](cp.ui.Element.md) for the `elementHandler` provided to the constructor. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The new [Element](cp.ui.Element.md) instance. +function AliasHandler:build(parent, matchedUIFinder) + return self.elementHandler:build(parent, matchedUIFinder) +end + +return AliasHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/ElementChoice.lua b/src/extensions/cp/ui/has/ElementChoice.lua new file mode 100644 index 000000000..67be83581 --- /dev/null +++ b/src/extensions/cp/ui/has/ElementChoice.lua @@ -0,0 +1,181 @@ +--- === cp.ui.has.ElementChoice === +--- +--- An [ElementChoice](cp.ui.has.ElementChoice.md) is a [has](cp.ui.has.md) instance that represents a choice of elements. +--- Only one of the choices will actually match the current set of `hs.axuielement` objects at a given time. + +local require = require + +--local log = require "hs.logger".new "ElementChoice" + +local is = require "cp.is" +local prop = require "cp.prop" +local slice = require "cp.slice" + +local class = require "middleclass" +local lazy = require "cp.lazy" + +local format = string.format +local isTable, isNumber = is.table, is.number + +local ElementChoice = class("cp.ui.has.ElementChoice"):include(lazy) + + +-- noMatchUpTo(index, handlerList, uiList) -> true, cp.slice | false, nil +-- Function +-- Given a `handlerList` and a `uiList`, checks each handler's `find` up to but not including the `index`. If all handlers +-- return `true`, then the last remainder is returned. If any handler returns `false`, then `false, nil` is returned. +-- +-- Parameters: +-- * index - The index to process up to. +-- * handlerList - A list of `UIHandler` subclasses. +-- * uiList - A list of `hs.axuielement` objects. +-- +-- Returns: +-- * `true, cp.slice` - If all handlers return `true`. +-- * `false, nil` - If any handler returns `false`. +local function noMatchUpTo(index, handlerList, uiList) + if index > #handlerList then + return false, nil + end + for i=1,index-1 do + local handler = handlerList[i] + local result = handler:matches(uiList) + if result then + return false, nil + end + end + return true, uiList +end + +local subclassNumber = 1 + +--- cp.ui.has.ElementChoice:of(uiHandlers) -> cp.ui.has.ElementChoice type +--- Function +--- Returns a function that will return a new `ElementChoice` instance when passed a `parent` and `uiFinder`. +--- +--- Parameters: +--- * uiHandlers - The list of [UIHandlers](cp.ui.has.UIHandler.md) to pass to the `ElementChoice` constructor. +--- +--- Returns: +--- * A function that will return a new `ElementChoice` instance. + +-- TODO: @randomeizer to review the below code: + +function ElementChoice.static:of(uiHandlers) -- luacheck:ignore + local choiceClass = self:subclass(format("%s_%d", self.name, subclassNumber)) + subclassNumber = subclassNumber + 1 + + + function choiceClass:initialize(parent, uiFinder) -- luacheck:ignore + choiceClass.super.initialize(self, parent, uiFinder, uiHandlers) + end + + -- map aliases to the appropriate index. + for i, handler in ipairs(uiHandlers) do + if handler.alias then + choiceClass.lazy.value[handler.alias] = function(s) + return s[i] + end + end + end + + return choiceClass +end + +--- cp.ui.has.ElementChoice(parent, uiFinder, uiHandlers) +--- Constructor +--- Creates a new `ElementChoice` instance. +--- +--- Parameters: +--- * parent - The parent `Element` instance. +--- * uiFinder - The `hs.axuielement` finder. +--- * uiHandlers - The list of [UIHandlers](cp.ui.has.UIHandler.md) to pass to the `ElementChoice` constructor. +--- +--- Returns: +--- * A new `ElementChoice` instance. +function ElementChoice:initialize(parent, uiFinder, uiHandlers) + rawset(self, "_elements", setmetatable({}, {__mode = "k"})) + rawset(self, "_parent", parent) + rawset(self, "UI", prop.FROM(uiFinder)) + rawset(self, "_uiHandlers", uiHandlers) +end + +--- cp.ui.has.ElementChoice:get(index) -> any +--- Method +--- Gets the `index`th element. +--- +--- Parameters: +--- * index - The index of the element to get. +--- +--- Returns: +--- * The `index`th element. +function ElementChoice:get(index) + if index < 1 then + return nil + end + + local elements = self._elements + local item = elements[index] + if item then + return item + end + + local uiHandlers = self._uiHandlers + local initCount = #uiHandlers + if index > initCount then + return nil + end + local uiHandler = uiHandlers[index] + + local itemUI = self.UI:mutate(function(original) + local uiElements = original:get() + if isTable(uiElements) then + uiElements = slice.from(uiElements) + local found, remainingUIElements = noMatchUpTo(index, uiHandlers, uiElements) + return found and remainingUIElements or nil + end + end) + + item = uiHandler:build(self._parent, itemUI) + elements[index] = item + return item +end + +--- cp.ui.has.ElementChoice:update() -> cp.ui.has.ElementChoice +--- Method +--- Updates the cache. +--- +--- Parameters: +--- * None. +--- +--- Returns: +--- * The `ElementChoice` instance. +function ElementChoice:update() + self.UI:update() + return self +end + +function ElementChoice:__len() + local value = self.UI:get() + if isTable(value) then + return #value + end + return 0 +end + +-- Note to self: defining these here so that we don't have to cache `parent`, `ui`, and `elementInits`, +-- causing infinite looping when inspecting. +function ElementChoice:__index(key) + if not isNumber(key) then + return + end + + return self:get(key) +end + +function ElementChoice.__newindex() + -- read-only +end + + +return ElementChoice \ No newline at end of file diff --git a/src/extensions/cp/ui/has/ElementHandler.lua b/src/extensions/cp/ui/has/ElementHandler.lua new file mode 100644 index 000000000..b5863c4da --- /dev/null +++ b/src/extensions/cp/ui/has/ElementHandler.lua @@ -0,0 +1,70 @@ + +--- === cp.ui.has.ElementHandler === +--- +--- Handles a single [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md). + +local require = require + +local ax = require "cp.fn.ax" +local slice = require "cp.slice" +local UIHandler = require "cp.ui.has.UIHandler" + +local ElementHandler = UIHandler:subclass("cp.ui.has.ElementHandler") + +--- cp.ui.has.ElementHandler(elementBuilder) -> cp.ui.has.ElementHandler +--- Constructor +--- Creates a new `ElementHandler` instance. +--- +--- Parameters: +--- * elementBuilder - The [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md) to use to create the `Element` instance. +--- +--- Returns: +--- * The new `ElementHandler` instance. +function ElementHandler:initialize(elementBuilder) + UIHandler.initialize(self) + self.elementBuilder = elementBuilder +end + +--- cp.ui.has.ElementHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Processes the list `hs.axuielement` and returns a `true` if the `hs.axuielement` matches, otherwise `false`. +--- If the `hs.axuielement` matches, a [slice](cp.slice.md) of the remaining `hs.axuielement` objects is returned. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function ElementHandler:matches(uiList) + local elementBuilder = self.elementBuilder + if elementBuilder.matches(uiList[1]) then + return true, uiList:drop(1) + end + return false, nil +end + +--- cp.ui.has.ElementHandler:build(parent, uiListFinder) -> any +--- Method +--- Builds the [Element](cp.ui.Element.md) for the `elementBuilder` provided to the constructor. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The new [Element](cp.ui.Element.md) instance. +function ElementHandler:build(parent, uiListFinder) + local key = {} + local ui = uiListFinder:mutate( + ax.cache(parent, key, self.elementBuilder.matches)(function(original) + local uiList = original() + if uiList and self:matches(slice.from(uiList)) then + return uiList[1] + end + end) + ) + return self.elementBuilder(parent, ui) +end + +return ElementHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/ElementList.lua b/src/extensions/cp/ui/has/ElementList.lua new file mode 100644 index 000000000..b8562e5b2 --- /dev/null +++ b/src/extensions/cp/ui/has/ElementList.lua @@ -0,0 +1,191 @@ +--- === cp.ui.has.ElementList === +--- +--- Provides caching for [Element](cp.ui.Element.md) subclasses that want to cache children. +--- +--- It is linked to a specfic `parent` [Element](cp.ui.Element.md), and a `uiFinder` that returns +--- a list of `hs.axuielement` objects. +--- +--- The `elementInit` should only expect to receive a `parent` (this `ParentElement`), then +--- a `uiFinder` "callable" pointing to the specific child at the specific index. +--- +--- When the `uiFinder` resolves to an actual table, the `#myCache` call will return the number of +--- children in the table, so the cache can be looped through in a for loop with an `ipairs` function call. + +local require = require + +-- local log = require "hs.logger".new "ElementList" + +local is = require "cp.is" +local prop = require "cp.prop" +local slice = require "cp.slice" + +local isTable = is.table +local isNumber = is.number +local format = string.format + +local class = require "middleclass" +local lazy = require "cp.lazy" + +local ElementList = class("cp.ui.has.ElementList"):include(lazy) + +-- matchesUpTo(index, handlerList, uiList) -> true, cp.slice | false, nil +-- Function +-- Given a `handlerList` and a `uiList`, checks each handler's `find` up to but not including the `index`. If all handlers +-- return `true`, then the last remainder is returned. If any handler returns `false`, then `false, nil` is returned. +-- +-- Parameters: +-- * index - The index to process up to. +-- * handlerList - A list of `UIHandler` subclasses. +-- * uiList - A list of `hs.axuielement` objects. +-- +-- Returns: +-- * `true, cp.slice` - If all handlers return `true`. +-- * `false, nil` - If any handler returns `false`. +local function matchesUpTo(index, handlerList, uiList) + if index > #handlerList then + return false, nil + end + local result = true + for i=1,index-1 do + local handler = handlerList[i] + result, uiList = handler:matches(uiList) + if not result then + return false, nil + end + end + return result, uiList +end + +local subclassNumber = 1 + +--- cp.ui.has.ElementList:ofExactly(uiHandlers) -> cp.ui.has.ElementList type +--- Function +--- Returns a function that will return a new `ElementList` instance when passed a `parent` and `uiFinder`. +--- +--- Parameters: +--- * uiHandlers - The list of [UIHandlers](cp.ui.has.UIHandler.md) to pass to the `ElementList` constructor. +--- +--- Returns: +--- * A new `ElementList` subclass that supports the provided list of `UIHandlers`. + +-- TODO: @randomeizer to review the below code: + +function ElementList.static:ofExactly(uiHandlers) -- luacheck:ignore + local listClass = self:subclass(format("%s_%d", self.name, subclassNumber)) + subclassNumber = subclassNumber + 1 + + function listClass:initialize(parent, uiFinder) -- luacheck:ignore + listClass.super.initialize(self, parent, uiFinder, uiHandlers) + end + + -- map aliases to the appropriate index. + for i, handler in ipairs(uiHandlers) do + if handler.alias then + listClass.lazy.value[handler.alias] = function(s) + return s[i] + end + end + end + + return listClass +end + +--- cp.ui.has.ElementList(parent, uiFinder, uiHandlers) +--- Constructor +--- Creates and returns a new `ElementList`, with the specified parent, uiFinder, and uiHandlers. +--- +--- Parameters: +--- * parent - the parent [Element](cp.ui.Element.md) that contains the cached items. +--- * uiFinder - a function which will return the table of child `hs.axuielement`s when available. +--- * uiHandlers - a table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Returns: +--- * The new `ElementList`. +function ElementList:initialize(parent, uiFinder, uiHandlers) + rawset(self, "_elements", setmetatable({}, {__mode="k"})) + -- rawset(self, "_cache", elements) + rawset(self, "_parent", parent) + rawset(self, "_uiHandlers", uiHandlers) + rawset(self, "UI", prop.FROM(uiFinder)) +end + +--- cp.ui.has.ElementList:get(index) -> any +--- Method +--- Gets the value at the specified index. This is often an [Element](cp.ui.Element.md) subclass, but can be any type. +--- +--- Parameters: +--- * index - the index of the `Element` to get. +--- +--- Returns: +--- * The value at the specified index. +function ElementList:get(index) + if index < 1 then + return nil + end + + local elements = self._elements + local item = elements[index] + if item then + return item + end + + local uiHandlers = self._uiHandlers + local initCount = #uiHandlers + if index > initCount then + return nil + end + local uiHandler = uiHandlers[index] + + local itemUI = self.UI:mutate(function(original) + local uiElements = original:get() + if isTable(uiElements) then + uiElements = slice.from(uiElements) + local found, remainingUIElements = matchesUpTo(index, uiHandlers, uiElements) + return found and remainingUIElements or nil + end + end) + + item = uiHandler:build(self._parent, itemUI) + elements[index] = item + return item +end + +--- cp.ui.has.ElementList:update() -> cp.ui.has.ElementList +--- Method +--- Updates the cache. +--- +--- Parameters: +--- * None. +--- +--- Returns: +--- * The `ElementList` instance. +function ElementList:update() + self.UI:update() + return self +end + +function ElementList:__len() + local value = self.UI:get() + if isTable(value) then + return #value + end + return 0 +end + +-- Note to self: defining these here so that we don't have to cache `parent`, `ui`, and `elementInits`, +-- causing infinite looping when inspecting. +function ElementList:__index(key) + if not isNumber(key) then + return + end + + return self:get(key) +end + +function ElementList.__newindex() + -- read-only +end + + + +return ElementList \ No newline at end of file diff --git a/src/extensions/cp/ui/has/ElementRepeater.lua b/src/extensions/cp/ui/has/ElementRepeater.lua new file mode 100644 index 000000000..10ffbc67c --- /dev/null +++ b/src/extensions/cp/ui/has/ElementRepeater.lua @@ -0,0 +1,154 @@ +--- === cp.ui.has.ElementRepeater === +--- +--- A table that can be used to repeat the results of a [UIHandler](cp.ui.has.UIHandler.md) or [Element](cp.ui.Element.md) over a range of values. +--- It can have any type of handler, so could be a single [element](cp.ui.has.md#element), a [list](cp.ui.has.md#list), or even another repeater +--- such as [upTo](cp.ui.has.md#upTo), [between](cp.ui.has.md#between), or [exactly](cp.ui.has.md#exactly). In general, sub-repeaters would +--- need to have a `maxCount` set, otherwise you'll only get one entry here. +--- +--- Includes: [cp.lazy](cp.lazy.md) + +local require = require + +local is = require "cp.is" +local slice = require "cp.slice" + +local class = require "middleclass" +local lazy = require "cp.lazy" + +local isNumber = is.number + +local ElementRepeater = class("cp.ui.has.ElementRepeater"):include(lazy) + +--- cp.ui.has.ElementRepeater(parent, uiListFinder, handler, [minCount], [maxCount]) -> cp.ui.has.ElementRepeater +--- Constructor +--- Creates a new `ElementRepeater` instance. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- * handler - The [UIHandler](cp.ui.has.UIHandler.md) to use to one item in the list. +--- * minCount - The minimum number of times to repeat the `handler`. Defaults to `nil`, allowing zero or more. +--- * maxCount - The maximum number of times to repeat the `handler`. Defaults to `nil`, allowing infinite. +--- +--- Returns: +--- * The new `ElementRepeater` instance. +function ElementRepeater:initialize(parent, uiListFinder, handler, minCount, maxCount) + self.parent = parent + self.uiListFinder = uiListFinder + self.handler = handler + self.minCount = minCount + self.maxCount = maxCount +end + +function ElementRepeater.lazy.value:_items() -- luacheck:ignore + return setmetatable({}, {__mode = "k"}) +end + +--- cp.ui.has.ElementRepeater:item(index) -> any +--- Method +--- Gets the value at the specified index. +--- +--- Parameters: +--- * index - The index to get the value at. +--- +--- Returns: +--- * The value at the specified index. +--- +--- Notes: +--- * If `maxCount` is set and the index is greater than the `maxCount`, `nil` is returned. +--- * Otherwise, a value is always returned, even if the `uiListFinder` currently contains less items. +--- This is different to acessing by index, which always checks the current list length. +function ElementRepeater:item(index) + if isNumber(self.maxCount) and index > self.maxCount then + return nil + end + + local item = self._items[index] + + if item then return item end + + local itemUIListFinder = self.uiListFinder:mutate(function(original) + local uiList = original() + if not uiList then + return nil + else + uiList = slice.from(uiList) + end + + local success + for _ = 1, index - 1 do + success, uiList = self.handler:matches(uiList) + if not success then + return nil + end + end + + -- check there are enough matching items to meet the criteria + if isNumber(self.minCount) then + local remainder = uiList + for _ = index, self.minCount do + success, remainder = self.handler:matches(remainder) + if not success then + return nil + end + end + end + + return uiList + end) + + local value = self.handler:build(self.parent, itemUIListFinder) + self._items[index] = value + return value +end + +--- cp.ui.has.ElementRepeater:count() -> number +--- Method +--- Gets the number of items in the repeater, based on the current `uiListFinder` value. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The number of items in the repeater. +function ElementRepeater:count() + local uiList = self.uiListFinder() + if not uiList then return 0 end + + local count = 0 + local success + local remainder = slice.from(uiList) + + while true do + if isNumber(self.maxCount) and count > self.maxCount then + break + end + + success, remainder = self.handler:matches(remainder) + if not success then + break + end + count = count + 1 + end + + if isNumber(self.minCount) and count < self.minCount then + return 0 + end + + return count +end + +function ElementRepeater:__len() + return self:count() +end + +function ElementRepeater:__index(key) + if isNumber(key) then + local item = self:item(key) + if item and item:UI() then + return item + end + end +end + +return ElementRepeater \ No newline at end of file diff --git a/src/extensions/cp/ui/has/EndHandler.lua b/src/extensions/cp/ui/has/EndHandler.lua new file mode 100644 index 000000000..973e47530 --- /dev/null +++ b/src/extensions/cp/ui/has/EndHandler.lua @@ -0,0 +1,56 @@ +--- === cp.ui.has.EndHandler === +--- +--- This expects to be the last [UIHandler](cp.ui.has.UIHandler.md), and only passes if the list of `hs.axuielement`s is empty. + +local require = require + +local UIHandler = require "cp.ui.has.UIHandler" + +local EndHandler = UIHandler:subclass("cp.ui.has.EndHandler") + +--- cp.ui.has.EndHandler() -> cp.ui.has.EndHandler +--- Constructor +--- Creates a new `EndHandler` instance. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The new `EndHandler` instance. +function EndHandler:initialize() + UIHandler.initialize(self) +end + +--- cp.ui.has.EndHandler:matches(uiList) -> true, cp.slice +--- Method +--- Processes the list `hs.axuielement`. It will only return `true` if the list is empty. +--- If so, the original list is returned. If not, `false` is returned. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function EndHandler:matches(uiList) -- luacheck:ignore + if #uiList == 0 then + return true, uiList + end + return false, nil +end + +--- cp.ui.has.EndHandler:build(parent, uiListFinder) -> nil +--- Method +--- Returns the result of the internal handler's `build` method. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The result of the internal handler's `build` method. +function EndHandler:build(parent, uiListFinder) -- luacheck:ignore + return nil +end + +return EndHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/ListHandler.lua b/src/extensions/cp/ui/has/ListHandler.lua new file mode 100644 index 000000000..0297fa4c1 --- /dev/null +++ b/src/extensions/cp/ui/has/ListHandler.lua @@ -0,0 +1,64 @@ +--- === cp.ui.has.ListHandler === +--- +--- A handler that receives multiple child [Handler](cp.ui.has.Handler.md) instances builds a list of +--- their build results. + +local require = require + +local ElementList = require "cp.ui.has.ElementList" +local UIHandler = require "cp.ui.has.UIHandler" + +local ListHandler = UIHandler:subclass("cp.ui.has.ListHandler") + +--- cp.ui.has.ListHandler(handlerList) -> cp.ui.has.ListHandler +--- Constructor +--- Creates a new `ListHandler` instance. +--- +--- Parameters: +--- * handlerList - The list of [UIHandler](cp.ui.has.UIHandler.md) instances to use to build the list. +--- +--- Returns: +--- * The new `ListHandler` instance. +function ListHandler:initialize(handlerList) + UIHandler.initialize(self) + self.handlerList = handlerList +end + +--- cp.ui.has.ListHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Processes the list `hs.axuielement` and returns a `true` if the `hs.axuielement` matches, otherwise `false`. +--- If the `hs.axuielement` matches, a [slice](cp.slice.md) of the remaining `hs.axuielement` objects is returned. +--- +--- Parameters: +--- * elements - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function ListHandler:matches(uiList) + local handlerList = self.handlerList + local result + for _, handler in ipairs(handlerList) do + result, uiList = handler:matches(uiList) + if not result then + return false, nil + end + end + return true, uiList +end + +--- cp.ui.has.ListHandler:build(parent, uiListFinder) -> cp.ui.has.ElementList +--- Method +--- Builds an [has](cp.ui.has.md) instance for the `handlerList` provided to the constructor. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The new [ElementList](cp.ui.has.ElementList.md) instance. +function ListHandler:build(parent, uiListFinder) + return ElementList:ofExactly(self.handlerList)(parent, uiListFinder) +end + +return ListHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/OneOfHandler.lua b/src/extensions/cp/ui/has/OneOfHandler.lua new file mode 100644 index 000000000..1101c6f46 --- /dev/null +++ b/src/extensions/cp/ui/has/OneOfHandler.lua @@ -0,0 +1,63 @@ +--- === cp.ui.has.OneOfHandler === +--- +--- A handler that receives multiple child [Handler](cp.ui.has.Handler.md) instances, makes all of them available, +--- but will only match the first one that matches a given `hs.axuielement` list. + +local require = require + +local ElementChoice = require "cp.ui.has.ElementChoice" +local UIHandler = require "cp.ui.has.UIHandler" + +local OneOfHandler = UIHandler:subclass("cp.ui.has.OneOfHandler") + +--- cp.ui.has.OneOfHandler(handlerList) -> cp.ui.has.OneOfHandler +--- Constructor +--- Creates a new `OneOfHandler` instance. +--- +--- Parameters: +--- * handlerList - The list of [UIHandler](cp.ui.has.UIHandler.md) instances to use to build the list. +--- +--- Returns: +--- * The new `OneOfHandler` instance. +function OneOfHandler:initialize(handlerList) + UIHandler.initialize(self) + self.handlerList = handlerList +end + +--- cp.ui.has.OneOfHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Processes the list `hs.axuielement` and returns a `true` if the `hs.axuielement` matches, otherwise `false`. +--- If the `hs.axuielement` matches, a [slice](cp.slice.md) of the remaining `hs.axuielement` objects is returned. +--- +--- Parameters: +--- * elements - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function OneOfHandler:matches(uiList) + local handlerList = self.handlerList + for _, handler in ipairs(handlerList) do + local result, remainingUIList = handler:matches(uiList) + if result then + return true, remainingUIList + end + end + return false, nil +end + +--- cp.ui.has.OneOfHandler:build(parent, uiListFinder) -> cp.ui.has.ElementChoice +--- Method +--- Builds an [has](cp.ui.has.md) instance for the `handlerList` provided to the constructor. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The new [ElementChoice](cp.ui.has.ElementChoice.md) instance. +function OneOfHandler:build(parent, uiListFinder) + return ElementChoice:of(self.handlerList)(parent, uiListFinder) +end + +return OneOfHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/OptionalHandler.lua b/src/extensions/cp/ui/has/OptionalHandler.lua new file mode 100644 index 000000000..7c35fb51b --- /dev/null +++ b/src/extensions/cp/ui/has/OptionalHandler.lua @@ -0,0 +1,67 @@ +--- === cp.ui.has.OptionalHandler === +--- +--- A [UIHandler](cp.ui.has.UIHandler.md) that will optionally match its contents. If they don't match, it will +--- still return `true` and the unchanged `hs.axuielement` list when matching. + +local require = require + +local inspect = require "hs.inspect" + +local UIHandler = require "cp.ui.has.UIHandler" + +local format = string.format + +local OptionalHandler = UIHandler:subclass("cp.ui.has.OptionalHandler") + +--- cp.ui.has.OptionalHandler(handler) -> cp.ui.has.OptionalHandler +--- Constructor +--- Creates a new `OptionalHandler` instance. +--- +--- Parameters: +--- * handler - The [UIHandler](cp.ui.has.UIHandler.md) which may or may not be present in the list. +--- +--- Returns: +--- * The new `OptionalHandler` instance. +function OptionalHandler:initialize(handler) + UIHandler.initialize(self) + if UIHandler:isSuperclassFor(handler) then + self.handler = handler + else + error(format("expected a UIHandler or a table of UIHandlers: %s", inspect(handler))) + end +end + +--- cp.ui.has.OptionalHandler:matches(uiList) -> true, cp.slice +--- Method +--- Processes the list `hs.axuielement`. It will always return `true`. If the the internal handler matches, the returned +--- `hs.axuielement` list will be the remaining list, otherwise it will be the original `uiList`, unmodified. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function OptionalHandler:matches(uiList) + local result, remainder = self.handler:matches(uiList) + if result then + return true, remainder + end + return true, uiList +end + +--- cp.ui.has.OptionalHandler:build(parent, uiListFinder) -> any +--- Method +--- Returns the result of the internal handler's `build` method. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The result of the internal handler's `build` method. +function OptionalHandler:build(parent, uiListFinder) + return self.handler:build(parent, uiListFinder) +end + +return OptionalHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/RepeatingHandler.lua b/src/extensions/cp/ui/has/RepeatingHandler.lua new file mode 100644 index 000000000..9f49cc034 --- /dev/null +++ b/src/extensions/cp/ui/has/RepeatingHandler.lua @@ -0,0 +1,79 @@ +--- === cp.ui.has.RepeatingHandler === +--- +--- A [RepeatingHandler](cp.ui.has.RepeatingHandler.md) is a [UIHandler](cp.ui.has.UIHandler.md) that can be used to build an [ElementRepeater](cp.ui.has.ElementRepeater.md). + +local require = require + +local ElementRepeater = require "cp.ui.has.ElementRepeater" +local UIHandler = require "cp.ui.has.UIHandler" + +local RepeatingHandler = UIHandler:subclass("cp.ui.has.RepeatingHandler") + +--- cp.ui.has.RepeatingHandler(handler, [minCount], [maxCount]) -> cp.ui.has.RepeatingHandler +--- Constructor +--- Creates a new `RepeatingHandler` instance. +--- +--- Parameters: +--- * handler - The [UIHandler](cp.ui.has.UIHandler.md) to use to build the repeating element. +--- * minCount - The minimum number of times to repeat the element. (default is no limit) +--- * maxCount - The maximum number of times to repeat the element. (default is no limit) +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +function RepeatingHandler:initialize(handler, minCount, maxCount) + UIHandler.initialize(self) + self.handler = handler + self.minCount = minCount + self.maxCount = maxCount +end + +--- cp.ui.has.RepeatingHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Matches multiple instances of the `handler`, between `minCount` and `maxCount` (if specified). +--- If `minCount` is specified and it repeats less than that value, it will return `false`. +--- If `maxCount` is specified it will ignore any subsequent matches, and they will be returned in the slice. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * `true` if the handler matches the `hs.axuielement`, otherwise `false`. +--- * The remaining `hs.axuielement` objects that were not matched as a slice, or `nil` if it was not matched. +function RepeatingHandler:matches(uiList) + local handler = self.handler + local minCount = self.minCount + local maxCount = self.maxCount + local count = 0 + local success, remainder + while true do + success, remainder = handler:matches(uiList) + if not success then + if minCount and count < minCount then + return false, nil + else + return true, uiList + end + end + count = count + 1 + uiList = remainder + if maxCount and count > maxCount then + return true, uiList + end + end +end + +--- cp.ui.has.RepeatingHandler:build(parent, uiListFinder) -> cp.ui.has.ElementRepeater +--- Method +--- Builds an [ElementRepeater](cp.ui.has.ElementRepeater.md) instance for the `handler` provided to the constructor. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A callable value which returns the list of `hs.axuielement` objects to match against. +--- +--- Returns: +--- * The new [ElementRepeater](cp.ui.has.ElementRepeater.md) instance. +function RepeatingHandler:build(parent, uiListFinder) + return ElementRepeater(parent, uiListFinder, self.handler, self.minCount, self.maxCount) +end + +return RepeatingHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/UIHandler.lua b/src/extensions/cp/ui/has/UIHandler.lua new file mode 100644 index 000000000..3b1cb5c61 --- /dev/null +++ b/src/extensions/cp/ui/has/UIHandler.lua @@ -0,0 +1,61 @@ +--- === cp.ui.has.UIHandler === +--- +--- A base class for element handler. A handler is responsible for matching a `hs.axuielement` to one or more specific +--- [Element](cp.ui.Element.md) subclass, which is typically passed in the constructor. + +local require = require + +local class = require "middleclass" + +local format = string.format + +local UIHandler = class("cp.ui.has.UIHandler") + +--- cp.ui.has.UIHandler() -> cp.ui.has.UIHandler +--- Constructor +--- Creates a new `Handler` instance. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * The new `Handler` instance. +function UIHandler:initialize() -- luacheck:ignore +end + +--- cp.ui.has.UIHandler:matches(uiList) -> true, cp.slice | false, nil +--- Method +--- Processes the list `hs.axuielement` objects and returns a `true` if the `hs.axuielement` matches, otherwise `false`. +--- If the `hs.axuielement` matches, a [slice](cp.slice.md) of the remaining `hs.axuielement` objects is returned. +--- +--- Parameters: +--- * uiList - The `cp.slice` of `hs.axuielement` objects to match. +--- +--- Returns: +--- * `true` if the handler matches followed by a `slice` of remaining `hs.axuielement`s, otherwise `false` followed by `nil`. +--- +--- Notes: +--- * The default implementation throws an error. +function UIHandler:matches(uiList) -- luacheck:ignore + error(format("%s:matches() is not implemented.", self.class.name)) +end + +--- cp.ui.has.UIHandler:build(parent, uiListFinder) -> any +--- Method +--- Builds the instance for this handler. Often this is a subclass of [Element](cp.ui.Element.md), but it can be any object. +--- It should consume whatever items it needs from the `uiListFinder`, and return the new value. +--- +--- Parameters: +--- * parent - The parent [Element](cp.ui.Element.md) that this handler is for. +--- * uiListFinder - A `cp.prop` value which returns the list of `hs.axuielement` objects. +--- +--- Returns: +--- * The new value built by the handler. +--- +--- Notes: +--- * The default implementation throws an error. +function UIHandler:build(parent, uiListFinder) -- luacheck:ignore + error(format("%s:build() is not implemented.", self.class.name)) +end + +return UIHandler \ No newline at end of file diff --git a/src/extensions/cp/ui/has/init.lua b/src/extensions/cp/ui/has/init.lua new file mode 100644 index 000000000..2bffdebd3 --- /dev/null +++ b/src/extensions/cp/ui/has/init.lua @@ -0,0 +1,488 @@ +--- === cp.ui.has === +--- +--- This module contains several support functions and classes to help define [Element](cp.ui.Element.md) +--- values for lists of `hs.axuielement`s. A typical example is the `AXChildren` of many elements, +--- which can come in complicated orders and combinations. +--- +--- Basically, it lets you define [UIHandler](cp.ui.has.UIHandler.md) values that expect to be receiving a list +--- of `hs.axuielement`s, consume some of them, returning the remaining list, and then build a value to process +--- them (often, but not always, an [Element](cp.ui.Element.md)). +--- +--- There are two main things you can do with a [UIHandler](cp.ui.has.UIHandler.md): +--- +--- 1. Match a list of `hs.axuielement`s against it. +--- 2. Build a value from the list of `hs.axuielement`s. +--- +--- ## Example +--- +--- For example, lets say our app has an `AXBox` element with an `AXChildren` attribute that has something like this: +--- +--- > AXStaticText, AXTextField, AXStaticText, AXCheckBox +--- +--- This might be a text field and a checkbox, each with a descriptive label. We can build a [UIHandler](cp.ui.has.UIHandler.md) +--- that will match against this list like so: +--- +--- ```lua +--- local fn = require "cp.fn" +--- local ax = require "cp.fn.ax" +--- local Element = require "cp.ui.Element" +--- local has = require "cp.ui.has" +--- +--- local Box = Element:subclass("my.Box") +--- +--- Box.static.matches = ax.matchesIf(Element.matches, ax.hasRole("AXBox")) +--- +--- Box.static.childrenHandler = has.list { +--- StaticText, TextField, +--- StaticText, CheckBox, +--- } +--- ``` +--- +--- The `Box.childrenHandler` will be a [ListHandler](cp.ui.has.ListHandler.md). We can then use it to build an [ElementList](cp.ui.has.ElementList.md): +--- +--- ```lua +--- function Box.lazy.prop:childrenUI() +--- return ax.prop(self.UI, "AXChildren") +--- end +--- +--- function Box.lazy.value:children() +--- return self.class.childrenHandler:build(self, self.childrenUI) -- returns ElementList +--- end +--- ``` +--- +--- Now, we can use the `children` property to get the list of children: +--- +--- ```lua +--- local box = Box(parent, uiFinder) +--- local textField = box.children[2] +--- local checkBox = box.children[4] +--- ``` +--- +--- It would also be nice if we could give them a name, rather than an index number. We can redefine the +--- `childrenHandler` like so: +--- +--- ```lua +--- Box.static.childrenHandler = has.list { +--- StaticText, has.alias "textField" { TextField }, +--- StaticText, has.alias "checkBox" { CheckBox }, +--- } +--- ``` +--- +--- Now, we can be more specific: +--- +--- ```lua +--- local box = Box(parent, uiFinder) +--- local textField = box.children.textField +--- local checkBox = box.children.checkBox +--- -- can still us index if you want: +--- checkBox = box.children[4] +--- ``` +--- +--- As indicated, accessing via index will still work, even with an alias set. +--- +--- There are several functions to help define the structure. More details are below. +--- +--- ## `list` +--- +--- [list](cp.ui.has.md#list) is a function that takes a list of [UIHandler](cp.ui.has.UIHandler.md)s. +--- It expects that it will be given a list of `hs.axuielement` objects, and will match through all +--- of the handlers in the list, in order. It returns an [ElementList](cp.ui.has.ElementList.md). +--- +--- ### Example: +--- +--- Here, the list has a [StaticText](cp.ui.StaticText.md) followed by a [TextField](cp.ui.TextField.md): +--- +--- ```lua +--- has.list { +--- StaticText, TextField +--- } +--- ``` +--- +--- ## `alias` +--- +--- [alias](cp.ui.has.md#alias) is a function that takes an alias and returns a function that accepts a handler. +--- It returns a function that will return the result of the handler, but will also set the alias on the result. +--- +--- ### Example: +--- +--- Here, the list has a [StaticText](cp.ui.StaticText.md) followed by a [TextField](cp.ui.TextField.md): +--- +--- ```lua +--- has.list { +--- StaticText, has.alias "textField" { TextField }, +--- } +--- ``` +--- +--- ## `oneOf` +--- +--- [oneOf](cp.ui.has.md#oneOf) is a function that takes a list of [UIHandler](cp.ui.has.UIHandler.md)s. +--- It expects that it will be given a list of `hs.axuielement` objects, and will attempt to match through +--- all of the handlers in the list, in order. It returns the first match and stops processing. +--- +--- ### Example: +--- +--- A piece of UI could be a [PopUpButton](cp.ui.PopUpButton.md) or a [TextField](cp.ui.TextField.md), depending +--- on the context. +--- +--- ```lua +--- has.oneOf { +--- has.alias "preset" { PopUpButton }, +--- has.alias "other" { TextField }, +--- } +--- ``` +--- +--- This will return a [OneOfHandler](cp.ui.has.OneOfHandler.md), which will build an [ElementChoice](cp.ui.has.ElementChoice.md). +--- Again, `alias` is optional, but it's clearer here than using an index. +--- +--- ## `optional` +--- +--- [optional](cp.ui.has.md#optional) is a function that takes a handler. It returns an [OptionalHandler](cp.ui.has.OptionalHandler.md), +--- which will build whatever the wrapped handler builds, but it will pass a `match` even if the wrapped handler +--- doesn't match. +--- +--- ### Example: +--- +--- In this case, a labeled [PopUpButton](cp.ui.PopUpButton.md) may not be present at all: +--- +--- ```lua +--- has.optional { +--- StaticText, has.alias "mode" { PopUpButton }, +--- } +--- ``` +--- +--- Here, it will build an [ElementList](cp.ui.has.ElementList.md) because it has been passed multiple handlers. +--- That list will have a field called `mode` that returns the [PopUpButton](cp.ui.PopUpButton.md). +--- +--- ## `ended` +--- +--- [ended](cp.ui.has.md#ended) is a special case. It is an [EndHandler](cp.ui.has.EndHandler.md) that will +--- return `nil`, and not consume any of the `hs.axuielement` objects. However, if that list is not empty, it will fail. +--- +--- It basically provides a way of guaranteeing that the definition has completely consumed the available +--- `hs.axuielement` objects. +--- +--- ### Example: +--- +--- Here, we have a [StaticText](cp.ui.StaticText.md) followed by a [TextField](cp.ui.TextField.md): +--- +--- ```lua +--- has.list { +--- StaticText, TextField, +--- has.ended +--- } +--- ``` +--- +--- If there are any extra `hs.axuielement` objects after the TextField, it will fail. +--- +--- ## `element` +--- +--- [element](cp.ui.has.md#element) is a function that takes an [Element](cp.ui.Element.md) and returns an [ElementHandler](cp.ui.has.ElementHandler.md). +--- It can be used to specify that an item is a single element, if that is ambiguous. +--- +--- In most cases, you can just pass in the [Element](cp.ui.Element.md) directly, and it will be wrapped automatically. +--- +--- ### Example: +--- +--- Here, we have a [StaticText](cp.ui.StaticText.md) followed by a [TextField](cp.ui.TextField.md), wrapped explicitly: +--- +--- ```lua +--- has.list { +--- has.element(StaticText), has.alias "textField" { has.element(TextField) }, +--- } +--- ``` +--- +--- ## `handler` +--- +--- [handler](cp.ui.has.md#handler) is a function that takes a value and returns a [UIHandler](cp.ui.has.UIHandler.md) for it, +--- or throws an error if not supported. +--- +--- It will accept the following input, and return the specified handler: +--- +--- * [UIHandler](cp.ui.has.UIHandler.md): returns the handler, unmodified. +--- * [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md): returns a [ElementHandler](cp.ui.has.ElementHandler.md) for the element. +--- * `table`: If the table only contains one element, it will be wrapped using [handler](cp.ui.has.md#handler) and returned directly. +--- Otherwise, it will be wrapped using [list](cp.ui.has.md#list) and returned as an [ElementList](cp.ui.has.ElementList.md). +--- +--- It is used internally to process input for most of the above, including [#alias] and [#optional]. +--- +--- ### Example: +--- +--- Here, we have a [StaticText](cp.ui.StaticText.md) followed by a [TextField](cp.ui.TextField.md): + + +local require = require + +local inspect = require "hs.inspect" + +local is = require "cp.is" + +local Builder = require "cp.ui.Builder" +local Element = require "cp.ui.Element" +local AliasHandler = require "cp.ui.has.AliasHandler" +local ElementHandler = require "cp.ui.has.ElementHandler" +local EndHandler = require "cp.ui.has.EndHandler" +local ListHandler = require "cp.ui.has.ListHandler" +local OneOfHandler = require "cp.ui.has.OneOfHandler" +local OptionalHandler = require "cp.ui.has.OptionalHandler" +local RepeatingHandler = require "cp.ui.has.RepeatingHandler" +local UIHandler = require "cp.ui.has.UIHandler" + +local format = string.format +local insert = table.insert +local isTable = is.table + +local has = {} + +local toHandler, toHandlers + +-- toHandler(value[, errorLevel]) -> cp.ui.has.UIHandler +-- Function +-- Converts a value to a `UIHandler`. +-- +-- Parameters: +-- * value - The value to convert. +-- * errorLevel - The error level to use when an error occurs. Defaults to `1`. +-- +-- Returns: +-- * The `UIHandler` +-- +-- Notes: +-- * If the value is already a `UIHandler`, it is returned. +-- * If the value is an [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md), it is wrapped in a `ElementHandler`. +-- * If the value is a table with a single value, it is converted to a `UIHandler`. +-- * If the value is a table with multiple values, it is converted to a `ListHandler`. +toHandler = function(value, errorLevel) + errorLevel = errorLevel or 1 + if UIHandler:isSuperclassFor(value) then + return value + elseif Element:isSuperclassOf(value) or Builder:isSuperclassFor(value) then + return ElementHandler(value) + elseif isTable(value) then + local count = #value + if count == 1 then + return toHandler(value[1], errorLevel + 1) + elseif count > 1 then + return ListHandler(toHandlers(value, errorLevel + 1)) + end + end + error(format("expected an Element, Builder, UIHandler, or table thereof, got %s: %s", type(value), inspect(value, {depth=2})), 1 + errorLevel) +end + +toHandlers = function(values, errorLevel) + local handlers = {} + for i, value in ipairs(values) do + local success, result = pcall(toHandler, value, 1 + errorLevel) + if success then + insert(handlers, result) + else + error(format("at %d: %s", i, result), 1 + errorLevel) + end + end + return handlers +end + +--- cp.ui.has.handler(value) -> cp.ui.has.UIHandler +--- Function +--- Converts a value to a `UIHandler`. +--- +--- Parameters: +--- * value - The value to convert. +--- +--- Returns: +--- * The `UIHandler` +--- +--- Notes: +-- * If the value is already a `UIHandler`, it is returned. +-- * If the value is an [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md), it is wrapped in a `ElementHandler`. +-- * If the value is a table with a single value, it is converted to a `UIHandler`. +-- * If the value is a table with multiple values, it is converted to a `ListHandler`. +function has.handler(value) + return toHandler(value, 2) +end + +--- cp.ui.has.element(elementBuilder) -> cp.ui.has.ElementHandler +--- Function +--- Creates a new [ElementHandler](cp.ui.has.ElementHandler.md) for the specified [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md). +--- +--- Parameters: +--- * elementBuilder - The [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md) to use to create the `Element` instance. +--- +--- Returns: +--- * The new `ElementHandler` instance. +function has.element(elementBuilder) + return ElementHandler(elementBuilder) +end + +--- cp.ui.has.alias(name) -> function(uiHandler) -> cp.ui.has.AliasHandler +--- Function +--- Creates a new [AliasHandler](cp.ui.has.AliasHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md), [Element](cp.ui.Element.md), or [Builder](cp.ui.Builder.md). +--- +--- Parameters: +--- * name - The name of the field to create on the parent. +--- +--- Returns: +--- * A function which accepts an [Element](cp.ui.Element.md)/[Builder](cp.ui.Builder.md), a [UIHandler](cp.ui.has.UIHandler.md), or a list of `Element`/`UIHandler` values. +--- +--- Notes: +--- * The `uiHandler` may be an [Element](cp.ui.Element.md) or [Builder](cp.ui.Builder.md), in which +--- case it will be wrapped in an [ElementHandler](cp.ui.has.ElementHandler.md). +--- * The `uiHandler` may be a [UIHandler](cp.ui.has.UIHandler.md), in which case it will be used as is. +--- * The `uiHandler` may be a list of `Element`/`Builder`/`UIHandler` values. If there is only one value, it is treated as if it were passed in directly. +--- If there are more than one, it is treated as a [list](#list). +function has.alias(name) + return function(uiHandler) + return AliasHandler(name, toHandler(uiHandler, 2)) + end +end + +--- cp.ui.has.list(uiHandlers) -> cp.ui.has.ListHandler +--- Function +--- Creates a new [ListHandler](cp.ui.has.ListHandler.md) for the specified list of [UIHandler](cp.ui.has.UIHandler.md)s. +--- +--- Parameters: +--- * uiHandlers - The list of [UIHandler](cp.ui.has.UIHandler.md)s to use to build the `Element` instances. +--- +--- Returns: +--- * The new `ListHandler` instance. +--- +--- Notes: +--- * Items in `uiHandlers` may also be [Element](cp.ui.Element.md)s or [Builder](cp.ui.Builder.md), in which +--- case they will be wrapped in an [ElementHandler](cp.ui.has.ElementHandler.md). +function has.list(uiHandlers) + return ListHandler(toHandlers(uiHandlers, 2)) +end + +--- cp.ui.has.oneOf(uiHandlers) -> cp.ui.has.OneOfHandler +--- Function +--- Creates a new [OneOfHandler](cp.ui.has.OneOfHandler.md) for the specified list of [UIHandler](cp.ui.has.UIHandler.md)s. +--- +--- Parameters: +--- * uiHandlers - The list of [UIHandler](cp.ui.has.UIHandler.md)s to use to build the `Element` instances. +--- +--- Returns: +--- * The new `OneOfHandler` instance. +--- +--- Notes: +--- * Items in `uiHandlers` may also be [Element](cp.ui.Element.md)s or [Builder](cp.ui.Builder.md), in which +--- case they will be wrapped in an [ElementHandler](cp.ui.has.ElementHandler.md). +function has.oneOf(uiHandlers) + return OneOfHandler(toHandlers(uiHandlers, 2)) +end + +--- cp.ui.has.optional(handlerOrList) -> cp.ui.has.OptionalHandler +--- Function +--- Creates a new [OptionalHandler](cp.ui.has.OptionalHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * handlerOrList - The [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) to use to build the `Element` instance. +--- +--- Returns: +--- * The new `OptionalHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.optional(handlerOrList) + return OptionalHandler(toHandler(handlerOrList, 2)) +end + +--- cp.ui.has.zeroOrMore(handlerOrList) -> cp.ui.has.RepeatingHandler +--- Function +--- Creates a new [RepeatingHandler](cp.ui.has.RepeatingHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * handlerOrList - The [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) to use to build the `Element` instance. +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.zeroOrMore(handlerOrList) + return RepeatingHandler(toHandler(handlerOrList, 2)) +end + +--- cp.ui.has.atLeast(minCount) -> function(handlerOrList) -> cp.ui.has.RepeatingHandler +--- Function +--- Creates a new [RepeatingHandler](cp.ui.has.RepeatingHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * minCount - The minimum number of times the [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) should be repeated. +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.atLeast(minCount) + return function(handlerOrList) + return RepeatingHandler(toHandler(handlerOrList, 2), minCount) + end +end + +--- cp.ui.has.atMost(maxCount) -> function(handlerOrList) -> cp.ui.has.RepeatingHandler +--- Function +--- Creates a new [RepeatingHandler](cp.ui.has.RepeatingHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * maxCount - The maximum number of times the [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) should be repeated. +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.atMost(maxCount) + return function(handlerOrList) + return RepeatingHandler(toHandler(handlerOrList, 2), nil, maxCount) + end +end + +--- cp.ui.has.between(minCount, maxCount) -> function(handlerOrList) -> cp.ui.has.RepeatingHandler +--- Function +--- Creates a new [RepeatingHandler](cp.ui.has.RepeatingHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * minCount - The minimum number of times the [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) should be repeated. +--- * maxCount - The maximum number of times the [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) should be repeated. +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.between(minCount, maxCount) + return function(handlerOrList) + return RepeatingHandler(toHandler(handlerOrList, 2), minCount, maxCount) + end +end + +--- cp.ui.has.exactly(count) -> function(handlerOrList) -> cp.ui.has.RepeatingHandler +--- Function +--- Creates a new [RepeatingHandler](cp.ui.has.RepeatingHandler.md) for the specified [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md). +--- +--- Parameters: +--- * count - The number of times the [UIHandler](cp.ui.has.UIHandler.md) or table of [UIHandlers](cp.ui.has.UIHandler.md) must be repeated. +--- +--- Returns: +--- * The new `RepeatingHandler` instance. +--- +--- Notes: +--- * The `handlerOrList` may be a single [UIHandler](cp.ui.has.UIHandler.md) or a table of [UIHandlers](cp.ui.has.UIHandler.md), in which case they will be wrapped +--- in a [ListHandler](cp.ui.has.ListHandler.md). +function has.exactly(count) + return function(handlerOrList) + return RepeatingHandler(toHandler(handlerOrList, 2), count, count) + end +end + +--- cp.ui.has.ended +--- Constant +--- Enforces that the complete list of `hs.axuielement`s have been processed. +has.ended = EndHandler() + +return has \ No newline at end of file diff --git a/src/extensions/cp/ui/mock/axuielement.lua b/src/extensions/cp/ui/mock/axuielement.lua new file mode 100644 index 000000000..f3caa125b --- /dev/null +++ b/src/extensions/cp/ui/mock/axuielement.lua @@ -0,0 +1,27 @@ +-- a mock definition for `axuielement` for testing. +-- Use by `require`ing this file and then calling `axuielementMock {...}`, +-- and specify any `AX` values you want to return. +local axuielementMock = {} +axuielementMock.__index = axuielementMock + +function axuielementMock:attributeValue(attribute) + return self[attribute] +end + +function axuielementMock:setAttributeValue(attribute, value) + self[attribute] = value +end + +function axuielementMock:performAction(action) -- luacheck:ignore + -- do nothing +end + +function axuielementMock:isValid() + return self._isValid or true +end + +local function new_axuielementMock(attributes) + return setmetatable(attributes, axuielementMock) +end + +return new_axuielementMock \ No newline at end of file diff --git a/src/extensions/cp/ui/notifier.lua b/src/extensions/cp/ui/notifier.lua index c63a3cd13..3df283566 100644 --- a/src/extensions/cp/ui/notifier.lua +++ b/src/extensions/cp/ui/notifier.lua @@ -13,6 +13,23 @@ --- o:watchFor("AXValueChanged", function(notifier, element, notification, details) ... end) --- o:start() --- ``` +--- +--- ## Common Notifications +--- +--- * `AXFocusedWindowChanged` - The focused window has changed. +--- * `AXFocusedUIElementChanged` - The focused element has changed. +--- * `AXLiveRegionChanged` - The live region has changed. +--- * `AXMenuClosedNotification` - The menu has been closed. +--- * `AXMenuItemSelectedNotification` - The menu item has been selected. +--- * `AXMenuOpenedNotification` - The menu has been opened. +--- * `AXSelectedChildrenChanged` - The selected children have changed. +--- * `AXUIElementCreated` - The element has been created. +--- * `AXUIElementDestroyed` - The element has been destroyed. +--- * `AXValidationErrorChanged` - The validation error has changed. +--- * `AXValueChanged` - The value of the element has changed. +--- * `AXWindowCreated` - The window has been created. +--- * `AXWindowDestroyed` - The window has been destroyed. + local require = require diff --git a/src/extensions/middleclass/init.lua b/src/extensions/middleclass/init.lua index b9f5b60e8..6c5709c1b 100755 --- a/src/extensions/middleclass/init.lua +++ b/src/extensions/middleclass/init.lua @@ -162,13 +162,28 @@ local DefaultMixin = { subclassed = function(self, other) end, -- luacheck:ignore isSubclassOf = function(self, other) + assert(type(self) == 'table', "Make sure that you are using 'Class:isSubclassOf' instead of 'Class.isSubclassOf'") return type(other) == 'table' and type(self.super) == 'table' and ( self.super == other or self.super:isSubclassOf(other) ) end, + isSuperclassOf = function(self, other) + assert(type(self) == 'table', "Make sure that you are using 'Class:isSuperclassOf' instead of 'Class.isSuperclassOf'") + return self == other or + type(other) == "table" and + type(other.super) == 'table' and + other.isSubclassOf ~= nil and + other:isSubclassOf(self) + end, + isClassFor = function(self, instance) assert(type(self) == 'table', "Make sure that you are using 'Class:isClassFor' instead of 'Class.isClassFor'") + return type(instance) == "table" and type(instance.class) == "table" and self == instance.class + end, + + isSuperclassFor = function(self, instance) + assert(type(self) == 'table', "Make sure that you are using 'Class:isSuperclassFor' instead of 'Class.isSuperclassFor'") return type(instance) == "table" and instance.isInstanceOf and instance:isInstanceOf(self) end, diff --git a/src/plugins/finalcutpro/pasteboard/manager.lua b/src/plugins/finalcutpro/pasteboard/manager.lua index 0d82e1163..e69a962d1 100644 --- a/src/plugins/finalcutpro/pasteboard/manager.lua +++ b/src/plugins/finalcutpro/pasteboard/manager.lua @@ -339,7 +339,7 @@ function mod.unarchiveFCPXData(fcpxData) return nil end ---- plugins.finalcutpro.pasteboard.manager.writeFCPXData(fcpxData, quiet) -> boolean +--- plugins.finalcutpro.pasteboard.manager.writeFCPXData(fcpxData, [quiet]) -> boolean --- Function --- Write Final Cut Pro data to Pasteboard. --- @@ -359,6 +359,25 @@ function mod.writeFCPXData(fcpxData, quiet) return result end +--- plugins.finalcutpro.pasteboard.manager.clearFCPXData([quiet]) -> boolean +--- Function +--- Clear Final Cut Pro data from Pasteboard. +--- +--- Parameters: +--- * quiet - Whether or not we should stop/start the watcher. +--- +--- Returns: +--- * `true` if the operation succeeded, otherwise `false` (which most likely means ownership of the pasteboard has changed). +function mod.clearFCPXData(quiet) + -------------------------------------------------------------------------------- + -- Clear data from Pasteboard: + -------------------------------------------------------------------------------- + if quiet then mod.stopWatching() end + local result = pasteboard.clearContents(PASTEBOARD.UTI) + if quiet then mod.startWatching() end + return result +end + --- plugins.finalcutpro.pasteboard.manager.watch(events) -> table --- Function --- Watch events. diff --git a/src/plugins/finalcutpro/timeline/audioeffects.lua b/src/plugins/finalcutpro/timeline/audioeffects.lua index 81c4f73be..1497479a0 100644 --- a/src/plugins/finalcutpro/timeline/audioeffects.lua +++ b/src/plugins/finalcutpro/timeline/audioeffects.lua @@ -4,15 +4,14 @@ local require = require -local log = require "hs.logger".new "audiofx" - -local timer = require "hs.timer" +-- local log = require "hs.logger".new "audiofx" local dialog = require "cp.dialog" local fcp = require "cp.apple.finalcutpro" local i18n = require "cp.i18n" -local doAfter = timer.doAfter +local go = require "cp.rx.go" +local Do = go.Do local mod = {} @@ -61,7 +60,6 @@ function mod.apply(action) -------------------------------------------------------------------------------- local effects = fcp.effects local effectsShowing = effects:isShowing() - local effectsLayout = effects:saveLayout() -------------------------------------------------------------------------------- -- Make sure FCPX is at the front. @@ -73,18 +71,17 @@ function mod.apply(action) -------------------------------------------------------------------------------- effects:show() + local effectsLayout = effects:saveLayout() + -------------------------------------------------------------------------------- -- Make sure "Installed Effects" is selected: -------------------------------------------------------------------------------- - local group = effects.group:UI() - if group then - local groupValue = group:attributeValue("AXValue") - if groupValue ~= fcp:string("PEMediaBrowserInstalledEffectsMenuItem") then - effects:showInstalledEffects() - end - else - log.ef("Failed to find Effects Group UI.") - end + effects.group:doSelectValue(fcp:string("PEMediaBrowserInstalledEffectsMenuItem")):Now() + + -------------------------------------------------------------------------------- + -- Get original search value: + -------------------------------------------------------------------------------- + local originalSearch = effects.search:value() -------------------------------------------------------------------------------- -- Make sure there's nothing in the search box: @@ -105,33 +102,29 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Perform Search: -------------------------------------------------------------------------------- - effects.search:setValue(name) + effects.search.value:set(name) -------------------------------------------------------------------------------- -- Get the list of matching effects: -------------------------------------------------------------------------------- - local matches = effects:currentItemsUI() - if not matches or #matches == 0 then + local effect = effects.childrenInNavigationOrder[1] + if not effect then dialog.displayErrorMessage("Unable to find an audio effect called '"..name.."'.") return false end - local effect = matches[1] - -------------------------------------------------------------------------------- -- Apply the selected Transition: -------------------------------------------------------------------------------- - effects:applyItem(effect) - - -------------------------------------------------------------------------------- - -- TODO: HACK: This timer exists to work around a mouse bug in - -- Hammerspoon Sierra - -------------------------------------------------------------------------------- - doAfter(0.1, function() + Do(effect:doApply()) + :Then(function() + effects.search.value:set(originalSearch) effects:loadLayout(effectsLayout) if transitionsLayout then transitions:loadLayout(transitionsLayout) end if not effectsShowing then effects:hide() end end) + :Now() + -------------------------------------------------------------------------------- -- Success: diff --git a/src/plugins/finalcutpro/timeline/generators.lua b/src/plugins/finalcutpro/timeline/generators.lua index f4738ca36..54d21192d 100644 --- a/src/plugins/finalcutpro/timeline/generators.lua +++ b/src/plugins/finalcutpro/timeline/generators.lua @@ -4,9 +4,10 @@ local require = require +local log = require "hs.logger".new "generators" + local base64 = require "hs.base64" local timer = require "hs.timer" -local window = require "hs.window" local config = require "cp.config" local dialog = require "cp.dialog" @@ -16,6 +17,10 @@ local json = require "cp.json" local just = require "cp.just" local doAfter = timer.doAfter +local doUntil = just.doUntil + +local go = require "cp.rx.go" +local Do, If = go.Do, go.If local mod = {} @@ -24,6 +29,109 @@ local mod = {} -- Titles cache. mod._cache = json.prop(config.cachePath, "Final Cut Pro", "Generators.cpCache", {}) +local function getCacheID(action) + local cacheID = action.name + if action.theme then cacheID = cacheID .. "-" .. action.theme end + if action.category then cacheID = cacheID .. "-" .. action.category end + return cacheID +end + +local function hasCacheItem(cacheID) + return mod._cache()[cacheID] ~= nil +end + +local function getCacheItem(cacheID) + local value = mod._cache()[cacheID] + if value then + return base64.decode(value) + end +end + +local function setCacheItem(cacheID, value) + local cache = mod._cache() + cache[cacheID] = base64.encode(value) + mod._cache(cache) +end + +local function getItemName(action) + local name = action.name + if action.theme then name = action.theme .. " - " .. name end + return name +end + +local function findItem(generators, action) + local fullName = getItemName(action) + for _, v in ipairs(generators.contents) do + local title = v:title() + if title == fullName then + return v + end + end +end + +local function preservePasteboard() + local pasteboard = mod.pasteboardManager + pasteboard.stopWatching() + local originalData = pasteboard.readFCPXData() + + -- always restore the pasteboard after the current action is complete + doAfter(0, function() + if originalData ~= nil and not pasteboard.writeFCPXData(originalData) then + log.w("Failed to restore original Pasteboard item after applying Generator.") + end + pasteboard.startWatching() + end) + + return originalData +end + +local function copyCachedItem(cacheID) + local cachedItem = getCacheItem(cacheID) + + if not cachedItem then + log.ef("Failed to find cached Generator: %s", cacheID) + return false + end + + -------------------------------------------------------------------------------- + -- Stop Watching Pasteboard: + -------------------------------------------------------------------------------- + preservePasteboard() + + -------------------------------------------------------------------------------- + -- Add Cached Item to Pasteboard: + -------------------------------------------------------------------------------- + local pasteboard = mod.pasteboardManager + local result = pasteboard.writeFCPXData(cachedItem) + if not result then + dialog.displayErrorMessage("Failed to add the cached item to Pasteboard.") + return false + end +end + +local function pasteAsConnectedClip() + -------------------------------------------------------------------------------- + -- Make sure Timeline has focus: + -------------------------------------------------------------------------------- + local timeline = fcp.timeline + timeline:show() + if not doUntil(function() return timeline:isShowing() end, 0.5) then + dialog.displayErrorMessage("Unable to display the Timeline.") + return false + end + + -------------------------------------------------------------------------------- + -- Trigger 'Paste' from Menubar: + -------------------------------------------------------------------------------- + local menuBar = fcp.menu + if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then + menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) + else + dialog.displayErrorMessage("Unable to paste Generator.") + return false + end +end + --- plugins.finalcutpro.timeline.generators.apply(action) -> boolean --- Function --- Applies the specified action as a generator. Expects action to be a table with the following structure: @@ -54,9 +162,7 @@ function mod.apply(action) action = { name = action } end - local name, category, theme = action.name, action.category, action.theme - - if name == nil then + if action.name == nil then dialog.displayMessage(i18n("noGeneratorShortcut")) return false end @@ -69,90 +175,29 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Build a Cache ID: -------------------------------------------------------------------------------- - local cacheID = name - if theme then cacheID = cacheID .. "-" .. theme end - if category then cacheID = cacheID .. "-" .. name end + local cacheID = getCacheID(action) -------------------------------------------------------------------------------- -- Restore from Cache, unless there's a range selected in the timeline: -------------------------------------------------------------------------------- - local rangeSelected = fcp.timeline:rangeSelected() - if not rangeSelected and mod._cache()[cacheID] then - - -------------------------------------------------------------------------------- - -- Stop Watching Pasteboard: - -------------------------------------------------------------------------------- - local pasteboard = mod.pasteboardManager - pasteboard.stopWatching() - - -------------------------------------------------------------------------------- - -- Save Current Pasteboard Contents for later: - -------------------------------------------------------------------------------- - local originalPasteboard = pasteboard.readFCPXData() - - -------------------------------------------------------------------------------- - -- Add Cached Item to Pasteboard: - -------------------------------------------------------------------------------- - local cachedItem = base64.decode(mod._cache()[cacheID]) - local result = pasteboard.writeFCPXData(cachedItem) - if not result then - dialog.displayErrorMessage("Failed to add the cached item to Pasteboard.") - pasteboard.startWatching() - return false - end - - -------------------------------------------------------------------------------- - -- Make sure Timeline has focus: - -------------------------------------------------------------------------------- - local timeline = fcp.timeline - timeline:show() - if not timeline:isShowing() then - dialog.displayErrorMessage("Unable to display the Timeline.") - pasteboard.startWatching() - return false - end + local rangeSelected = fcp.timeline:isRangeSelected() + if not rangeSelected and hasCacheItem(cacheID) then + ----------------------------------------------------------- + -- Restore from Cache: + ----------------------------------------------------------- + log.df("copying cached item: %s", cacheID) + copyCachedItem(cacheID) -------------------------------------------------------------------------------- - -- Trigger 'Paste' from Menubar: + -- Paste the cached item as a connected clip: -------------------------------------------------------------------------------- - local menuBar = fcp.menu - if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then - menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) - else - dialog.displayErrorMessage("Unable to paste Generator.") - pasteboard.startWatching() - return false - end - - -------------------------------------------------------------------------------- - -- Restore Pasteboard: - -------------------------------------------------------------------------------- - doAfter(1, function() - - -------------------------------------------------------------------------------- - -- Restore Original Pasteboard Contents: - -------------------------------------------------------------------------------- - if originalPasteboard ~= nil then - local cbResult = pasteboard.writeFCPXData(originalPasteboard) - if not cbResult then - dialog.displayErrorMessage("Failed to restore original Pasteboard item.") - pasteboard.startWatching() - return false - end - end - - -------------------------------------------------------------------------------- - -- Start watching the Pasteboard again: - -------------------------------------------------------------------------------- - pasteboard.startWatching() - - end) + log.df("pasting cached item as connected clip") + pasteAsConnectedClip() -------------------------------------------------------------------------------- -- All done: -------------------------------------------------------------------------------- return true - end -------------------------------------------------------------------------------- @@ -165,7 +210,6 @@ function mod.apply(action) -- Get Generators Browser: -------------------------------------------------------------------------------- local generators = fcp.generators - local generatorsLayout = generators:saveLayout() -------------------------------------------------------------------------------- -- Make sure the panel is open: @@ -176,84 +220,53 @@ function mod.apply(action) return false end + local generatorsLayout = generators:saveLayout() + -------------------------------------------------------------------------------- -- Make sure there's nothing in the search box: -------------------------------------------------------------------------------- generators.search:clear() -------------------------------------------------------------------------------- - -- Select the Category if provided otherwise just show all: + -- Make sure "Installed Generators" is selected: -------------------------------------------------------------------------------- - if category then - generators:showGeneratorsCategory(category) - else - generators:showAllGenerators() - end + generators:showInstalledGenerators() -------------------------------------------------------------------------------- - -- Make sure "Installed Generators" is selected: + -- Select the Category if provided: -------------------------------------------------------------------------------- - local group = generators.group:UI() - local groupValue = group:attributeValue("AXValue") - if groupValue ~= fcp:string("PEMediaBrowserInstalledGeneratorsMenuItem") then - generators:showInstalledGenerators() + if action.category then + generators:showGeneratorsCategory(action.category) end -------------------------------------------------------------------------------- -- Find the requested Generator: -------------------------------------------------------------------------------- - local currentItemsUI = generators:currentItemsUI() - local whichItem = nil - for _, v in ipairs(currentItemsUI) do - local title = name - if theme then - title = theme .. " - " .. name - end - if v:attributeValue("AXTitle") == title then - whichItem = v - end - end + local whichItem = findItem(generators, action) if not whichItem then - dialog.displayErrorMessage("Failed to get whichItem in plugins.finalcutpro.timeline.generators.apply.") - return false - end - local grid = currentItemsUI[1]:attributeValue("AXParent") - if not grid then - dialog.displayErrorMessage("Failed to get grid in plugins.finalcutpro.timeline.generators.apply.") + dialog.displayErrorMessage(string.format("Failed to get find generator \"%s\" in plugins.finalcutpro.timeline.generators.apply.", action.name)) return false end -------------------------------------------------------------------------------- - -- If there's a range selected, do the old fashion way: + -- If there's a range selected, do the old fashion way (ninja clicking): -------------------------------------------------------------------------------- if rangeSelected then -------------------------------------------------------------------------------- -- Apply item: -------------------------------------------------------------------------------- - generators:applyItem(whichItem) - - -------------------------------------------------------------------------------- - -- Restore Layout: - -------------------------------------------------------------------------------- - doAfter(0.1, function() - generators:loadLayout(generatorsLayout) - if browserLayout then browser:loadLayout(browserLayout) end - end) + Do(whichItem:doApply()) + :Then(generators:doLayout(generatorsLayout)) + :Then(If(browserLayout):Then(browser:doLayout(browserLayout))) + :Now() - return + return true end -------------------------------------------------------------------------------- -- Make sure the correct window has focus: -------------------------------------------------------------------------------- - local gridWindow = grid:attributeValue("AXWindow") - local whichWindow = gridWindow and gridWindow:asHSWindow() - if whichWindow then - whichWindow:focus() - just.doUntil(function() - return whichWindow == window.focusedWindow() - end) - else + if not whichItem:focusOnWindow() then dialog.displayErrorMessage("Failed to select the window that contains the Generator Browser.") return false end @@ -261,52 +274,32 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Select the chosen Generator: -------------------------------------------------------------------------------- - grid:setAttributeValue("AXSelectedChildren", {whichItem}) - whichItem:setAttributeValue("AXFocused", true) - - -------------------------------------------------------------------------------- - -- Stop Watching Pasteboard: - -------------------------------------------------------------------------------- - local pasteboard = mod.pasteboardManager - pasteboard.stopWatching() + generators.contents:selectChild(whichItem) + whichItem:isFocused(true) - -------------------------------------------------------------------------------- - -- Save Current Pasteboard Contents for later: - -------------------------------------------------------------------------------- - local originalPasteboard = pasteboard.readFCPXData() + preservePasteboard() -------------------------------------------------------------------------------- -- Trigger 'Copy' from Menubar: -------------------------------------------------------------------------------- + local pasteboard = mod.pasteboardManager + pasteboard.writeFCPXData("") + if not doUntil(function() return pasteboard.readFCPXData() == "" end) then + dialog.displayErrorMessage("Failed to clear the Pasteboard.") + return false + end local menuBar = fcp.menu - menuBar:selectMenu({"Edit", "Copy"}) - local newPasteboard = nil - just.doUntil(function() - - newPasteboard = pasteboard.readFCPXData() - - if newPasteboard == nil then - menuBar:selectMenu({"Edit", "Copy"}) - return false - end - - if originalPasteboard == nil and newPasteboard ~= nil then - return true - end - - if newPasteboard ~= originalPasteboard then - return true - end - - -------------------------------------------------------------------------------- - -- Let's try again: - -------------------------------------------------------------------------------- + local newData = doUntil(function() menuBar:selectMenu({"Edit", "Copy"}) - return false - + local data = pasteboard.readFCPXData() + if data == "" then + return nil + else + return data + end end) - if newPasteboard == nil then + if newData == nil then dialog.displayErrorMessage("Failed to copy Generator.") pasteboard.startWatching() return false @@ -315,30 +308,13 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Cache the item for faster recall next time: -------------------------------------------------------------------------------- - local cache = mod._cache() - cache[cacheID] = base64.encode(newPasteboard) - mod._cache(cache) + log.df("Caching Generator: %s", cacheID) + setCacheItem(cacheID, newData) -------------------------------------------------------------------------------- - -- Make sure Timeline has focus: + -- Paste the copied item as a connected clip: -------------------------------------------------------------------------------- - local timeline = fcp.timeline - timeline:show() - if not timeline:isShowing() then - dialog.displayErrorMessage("Unable to display the Timeline.") - return false - end - - -------------------------------------------------------------------------------- - -- Trigger 'Paste' from Menubar: - -------------------------------------------------------------------------------- - if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then - menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) - else - dialog.displayErrorMessage("Unable to paste Generator.") - pasteboard.startWatching() - return false - end + pasteAsConnectedClip() -------------------------------------------------------------------------------- -- Restore Layout: @@ -348,29 +324,6 @@ function mod.apply(action) if browserLayout then browser:loadLayout(browserLayout) end end) - -------------------------------------------------------------------------------- - -- Restore Pasteboard: - -------------------------------------------------------------------------------- - doAfter(1, function() - - -------------------------------------------------------------------------------- - -- Restore Original Pasteboard Contents: - -------------------------------------------------------------------------------- - if originalPasteboard ~= nil then - local result = pasteboard.writeFCPXData(originalPasteboard) - if not result then - dialog.displayErrorMessage("Failed to restore original Pasteboard item.") - pasteboard.startWatching() - return false - end - end - - -------------------------------------------------------------------------------- - -- Start watching Pasteboard again: - -------------------------------------------------------------------------------- - pasteboard.startWatching() - end) - -------------------------------------------------------------------------------- -- Success: -------------------------------------------------------------------------------- diff --git a/src/plugins/finalcutpro/timeline/titles.lua b/src/plugins/finalcutpro/timeline/titles.lua index e6dad2071..84d89aeb2 100644 --- a/src/plugins/finalcutpro/timeline/titles.lua +++ b/src/plugins/finalcutpro/timeline/titles.lua @@ -5,11 +5,9 @@ local require = require local log = require "hs.logger".new "titles" -local inspect = require "hs.inspect" local base64 = require "hs.base64" local timer = require "hs.timer" -local window = require "hs.window" local config = require "cp.config" local dialog = require "cp.dialog" @@ -19,6 +17,10 @@ local json = require "cp.json" local just = require "cp.just" local doAfter = timer.doAfter +local doUntil = just.doUntil + +local go = require "cp.rx.go" +local Do, If = go.Do, go.If local mod = {} @@ -27,6 +29,109 @@ local mod = {} -- Titles cache. mod._cache = json.prop(config.cachePath, "Final Cut Pro", "Titles.cpCache", {}) +local function getCacheID(action) + local cacheID = action.name + if action.theme then cacheID = cacheID .. "-" .. action.theme end + if action.category then cacheID = cacheID .. "-" .. action.category end + return cacheID +end + +local function hasCacheItem(cacheID) + return mod._cache()[cacheID] ~= nil +end + +local function getCacheItem(cacheID) + local value = mod._cache()[cacheID] + if value then + return base64.decode(value) + end +end + +local function setCacheItem(cacheID, value) + local cache = mod._cache() + cache[cacheID] = base64.encode(value) + mod._cache(cache) +end + +local function getItemName(action) + local name = action.name + if action.theme then name = action.theme .. " - " .. name end + return name +end + +local function findItem(generators, action) + local fullName = getItemName(action) + for _, v in ipairs(generators.contents) do + local title = v:title() + if title == fullName or title == action.name then + return v + end + end +end + +local function preservePasteboard() + local pasteboard = mod.pasteboardManager + pasteboard.stopWatching() + local originalData = pasteboard.readFCPXData() + + -- always restore the pasteboard after the current action is complete + doAfter(0, function() + if originalData ~= nil and not pasteboard.writeFCPXData(originalData) then + log.w("Failed to restore original Pasteboard item after applying Generator.") + end + pasteboard.startWatching() + end) + + return originalData +end + +local function copyCachedItem(cacheID) + local cachedItem = getCacheItem(cacheID) + + if not cachedItem then + log.ef("Failed to find cached Generator: %s", cacheID) + return false + end + + -------------------------------------------------------------------------------- + -- Stop Watching Pasteboard: + -------------------------------------------------------------------------------- + preservePasteboard() + + -------------------------------------------------------------------------------- + -- Add Cached Item to Pasteboard: + -------------------------------------------------------------------------------- + local pasteboard = mod.pasteboardManager + local result = pasteboard.writeFCPXData(cachedItem) + if not result then + dialog.displayErrorMessage("Failed to add the cached item to Pasteboard.") + return false + end +end + +local function pasteAsConnectedClip() + -------------------------------------------------------------------------------- + -- Make sure Timeline has focus: + -------------------------------------------------------------------------------- + local timeline = fcp.timeline + timeline:show() + if not doUntil(function() return timeline:isShowing() end, 0.5) then + dialog.displayErrorMessage("Unable to display the Timeline.") + return false + end + + -------------------------------------------------------------------------------- + -- Trigger 'Paste' from Menubar: + -------------------------------------------------------------------------------- + local menuBar = fcp.menu + if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then + menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) + else + dialog.displayErrorMessage("Unable to paste Generator.") + return false + end +end + --- plugins.finalcutpro.timeline.titles.apply(action) -> boolean --- Function --- Applies the specified action as a title. Expects action to be a table with the following structure: @@ -57,7 +162,7 @@ function mod.apply(action) action = { name = action } end - local name, category, theme = action.name, action.category, action.theme + local name, category = action.name, action.category if name == nil then dialog.displayMessage(i18n("noTitleShortcut")) @@ -72,89 +177,29 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Build a Cache ID: -------------------------------------------------------------------------------- - local cacheID = name - if theme then cacheID = cacheID .. "-" .. theme end - if category then cacheID = cacheID .. "-" .. name end + local cacheID = getCacheID(action) -------------------------------------------------------------------------------- -- Restore from Cache, unless there's a range selected in the timeline: -------------------------------------------------------------------------------- - local rangeSelected = fcp.timeline:rangeSelected() - if not rangeSelected and mod._cache()[cacheID] then - -------------------------------------------------------------------------------- - -- Stop Watching Pasteboard: - -------------------------------------------------------------------------------- - local pasteboard = mod.pasteboardManager - pasteboard.stopWatching() - - -------------------------------------------------------------------------------- - -- Save Current Pasteboard Contents for later: - -------------------------------------------------------------------------------- - local originalPasteboard = pasteboard.readFCPXData() - - -------------------------------------------------------------------------------- - -- Add Cached Item to Pasteboard: - -------------------------------------------------------------------------------- - local cachedItem = base64.decode(mod._cache()[cacheID]) - local result = pasteboard.writeFCPXData(cachedItem) - if not result then - dialog.displayErrorMessage("Failed to add the cached item to Pasteboard.") - pasteboard.startWatching() - return false - end + local rangeSelected = fcp.timeline:isRangeSelected() + if not rangeSelected and hasCacheItem(cacheID) then + ----------------------------------------------------------- + -- Restore from Cache: + ----------------------------------------------------------- + log.df("copying cached item: %s", cacheID) + copyCachedItem(cacheID) -------------------------------------------------------------------------------- - -- Make sure Timeline has focus: + -- Paste the cached item as a connected clip: -------------------------------------------------------------------------------- - local timeline = fcp.timeline - timeline:show() - if not timeline:isShowing() then - dialog.displayErrorMessage("Unable to display the Timeline.") - pasteboard.startWatching() - return false - end - - -------------------------------------------------------------------------------- - -- Trigger 'Paste' from Menubar: - -------------------------------------------------------------------------------- - local menuBar = fcp.menu - if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then - menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) - else - dialog.displayErrorMessage("Unable to paste Title.") - pasteboard.startWatching() - return false - end - - -------------------------------------------------------------------------------- - -- Restore Pasteboard: - -------------------------------------------------------------------------------- - doAfter(1, function() - - -------------------------------------------------------------------------------- - -- Restore Original Pasteboard Contents: - -------------------------------------------------------------------------------- - if originalPasteboard ~= nil then - local v = pasteboard.writeFCPXData(originalPasteboard) - if not v then - dialog.displayErrorMessage("Failed to restore original Pasteboard item.") - pasteboard.startWatching() - return false - end - end - - -------------------------------------------------------------------------------- - -- Start watching the Pasteboard again: - -------------------------------------------------------------------------------- - pasteboard.startWatching() - - end) + log.df("pasting cached item as connected clip") + pasteAsConnectedClip() -------------------------------------------------------------------------------- -- All done: -------------------------------------------------------------------------------- return true - end -------------------------------------------------------------------------------- @@ -167,7 +212,6 @@ function mod.apply(action) -- Get Titles Browser: -------------------------------------------------------------------------------- local generators = fcp.generators - local generatorsLayout = generators:saveLayout() -------------------------------------------------------------------------------- -- Make sure the panel is open: @@ -178,64 +222,31 @@ function mod.apply(action) return false end + local generatorsLayout = generators:saveLayout() + -------------------------------------------------------------------------------- -- Make sure there's nothing in the search box: -------------------------------------------------------------------------------- generators.search:clear() -------------------------------------------------------------------------------- - -- Select the Category if provided otherwise just show all: + -- Make sure "Installed Titles" is selected: -------------------------------------------------------------------------------- - if category then - generators:showTitlesCategory(category) - else - generators:showAllTitles() - end + generators:showInstalledTitles() -------------------------------------------------------------------------------- - -- Make sure "Installed Titles" is selected: + -- Select the Category if provided otherwise just show all: -------------------------------------------------------------------------------- - local group = generators.group:UI() - local groupValue = group:attributeValue("AXValue") - if groupValue ~= fcp:string("PEMediaBrowserInstalledTitlesMenuItem") then - generators:showInstalledTitles() + if category then + generators:showTitlesCategory(action.category) end -------------------------------------------------------------------------------- -- Find the requested Title: -------------------------------------------------------------------------------- - local currentItemsUI = generators:currentItemsUI() - local whichItem = nil - local altWhichItem = nil - for _, v in ipairs(currentItemsUI) do - -------------------------------------------------------------------------------- - -- First we try "Theme - Name", if that fails, we just try "Name": - -------------------------------------------------------------------------------- - local title = name - if theme then - title = theme .. " - " .. name - end - if v:attributeValue("AXTitle") == title then - whichItem = v - end - if v:attributeValue("AXTitle") == name then - altWhichItem = v - end - end - if whichItem == nil then - whichItem = altWhichItem - end - if whichItem == nil then - log.ef("Failed to get whichItem in plugins.finalcutpro.timeline.titles.apply.") - log.ef("action: %s", inspect(action)) - dialog.displayErrorMessage("Something went wrong when trying to select the requested Title.") - return false - end - local grid = currentItemsUI[1]:attributeValue("AXParent") - if not grid then - log.ef("Failed to get grid in plugins.finalcutpro.timeline.titles.apply.") - log.ef("action: %s", inspect(action)) - dialog.displayErrorMessage("Something went wrong when trying to select the requested Title.") + local whichItem = findItem(generators, action) + if not whichItem then + dialog.displayErrorMessage(string.format("Failed to get find generator \"%s\" in plugins.finalcutpro.timeline.generators.apply.", action.name)) return false end @@ -246,84 +257,52 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Apply item: -------------------------------------------------------------------------------- - generators:applyItem(whichItem) + Do(whichItem:doApply()) + :Then(generators:doLayout(generatorsLayout)) + :Then(If(browserLayout):Then(browser:doLayout(browserLayout))) + :Now() - -------------------------------------------------------------------------------- - -- Restore Layout: - -------------------------------------------------------------------------------- - doAfter(0.1, function() - generators:loadLayout(generatorsLayout) - if browserLayout then browser:loadLayout(browserLayout) end - end) - - return + return true end -------------------------------------------------------------------------------- -- Make sure the correct window has focus: -------------------------------------------------------------------------------- - local gridWindow = grid:attributeValue("AXWindow") - local whichWindow = gridWindow and gridWindow:asHSWindow() - if whichWindow then - whichWindow:focus() - just.doUntil(function() - return whichWindow == window.focusedWindow() - end) - else - dialog.displayErrorMessage("Failed to select the window that contains the Titles Browser.") + if not whichItem:focusOnWindow() then + dialog.displayErrorMessage("Failed to select the window that contains the Generator Browser.") return false end -------------------------------------------------------------------------------- -- Select the chosen Title: -------------------------------------------------------------------------------- - grid:setAttributeValue("AXSelectedChildren", {whichItem}) - whichItem:setAttributeValue("AXFocused", true) - - -------------------------------------------------------------------------------- - -- Stop Watching Pasteboard: - -------------------------------------------------------------------------------- - local pasteboard = mod.pasteboardManager - pasteboard.stopWatching() + generators.contents:selectChild(whichItem) + whichItem:isFocused(true) - -------------------------------------------------------------------------------- - -- Save Current Pasteboard Contents for later: - -------------------------------------------------------------------------------- - local originalPasteboard = pasteboard.readFCPXData() + preservePasteboard() -------------------------------------------------------------------------------- -- Trigger 'Copy' from Menubar: -------------------------------------------------------------------------------- + local pasteboard = mod.pasteboardManager + pasteboard.writeFCPXData("") + if not doUntil(function() return pasteboard.readFCPXData() == "" end) then + dialog.displayErrorMessage("Failed to clear the Pasteboard.") + return false + end local menuBar = fcp.menu - menuBar:selectMenu({"Edit", "Copy"}) - local newPasteboard = nil - just.doUntil(function() - - newPasteboard = pasteboard.readFCPXData() - - if newPasteboard == nil then - menuBar:selectMenu({"Edit", "Copy"}) - return false - end - - if originalPasteboard == nil and newPasteboard ~= nil then - return true - end - - if newPasteboard ~= originalPasteboard then - return true - end - - -------------------------------------------------------------------------------- - -- Let's try again: - -------------------------------------------------------------------------------- + local newData = doUntil(function() menuBar:selectMenu({"Edit", "Copy"}) - return false - + local data = pasteboard.readFCPXData() + if data == "" then + return nil + else + return data + end end) - if newPasteboard == nil then - dialog.displayErrorMessage("Failed to copy Title.") + if newData == nil then + dialog.displayErrorMessage("Failed to copy Generator.") pasteboard.startWatching() return false end @@ -331,30 +310,13 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Cache the item for faster recall next time: -------------------------------------------------------------------------------- - local cache = mod._cache() - cache[cacheID] = base64.encode(newPasteboard) - mod._cache(cache) - - -------------------------------------------------------------------------------- - -- Make sure Timeline has focus: - -------------------------------------------------------------------------------- - local timeline = fcp.timeline - timeline:show() - if not timeline:isShowing() then - dialog.displayErrorMessage("Unable to display the Timeline.") - return false - end + log.df("Caching Generator: %s", cacheID) + setCacheItem(cacheID, newData) -------------------------------------------------------------------------------- - -- Trigger 'Paste' from Menubar: + -- Paste the copied item as a connected clip: -------------------------------------------------------------------------------- - if menuBar:isEnabled({"Edit", "Paste as Connected Clip"}) then - menuBar:selectMenu({"Edit", "Paste as Connected Clip"}) - else - dialog.displayErrorMessage("Unable to paste Title.") - pasteboard.startWatching() - return false - end + pasteAsConnectedClip() -------------------------------------------------------------------------------- -- Restore Layout: @@ -364,29 +326,6 @@ function mod.apply(action) if browserLayout then browser:loadLayout(browserLayout) end end) - -------------------------------------------------------------------------------- - -- Restore Pasteboard: - -------------------------------------------------------------------------------- - doAfter(1, function() - - -------------------------------------------------------------------------------- - -- Restore Original Pasteboard Contents: - -------------------------------------------------------------------------------- - if originalPasteboard ~= nil then - local result = pasteboard.writeFCPXData(originalPasteboard) - if not result then - dialog.displayErrorMessage("Failed to restore original Pasteboard item.") - pasteboard.startWatching() - return false - end - end - - -------------------------------------------------------------------------------- - -- Start watching Pasteboard again: - -------------------------------------------------------------------------------- - pasteboard.startWatching() - end) - -------------------------------------------------------------------------------- -- Success: -------------------------------------------------------------------------------- diff --git a/src/plugins/finalcutpro/timeline/videoeffects.lua b/src/plugins/finalcutpro/timeline/videoeffects.lua index 6a35278ba..a69909c7c 100644 --- a/src/plugins/finalcutpro/timeline/videoeffects.lua +++ b/src/plugins/finalcutpro/timeline/videoeffects.lua @@ -4,17 +4,18 @@ local require = require -local timer = require("hs.timer") +-- local log = require "hs.logger".new "videoeffects" -local dialog = require("cp.dialog") -local fcp = require("cp.apple.finalcutpro") -local i18n = require("cp.i18n") +local dialog = require "cp.dialog" +local fcp = require "cp.apple.finalcutpro" +local i18n = require "cp.i18n" -local doAfter = timer.doAfter +local go = require "cp.rx.go" +local Do = go.Do local mod = {} ---- plugins.finalcutpro.timeline.videoeffects(action) -> boolean +--- plugins.finalcutpro.timeline.videoeffects.apply(action) -> boolean --- Function --- Applies the specified action as a video effect. Expects action to be a table with the following structure: --- @@ -59,7 +60,6 @@ function mod.apply(action) -------------------------------------------------------------------------------- local effects = fcp.effects local effectsShowing = effects:isShowing() - local effectsLayout = effects:saveLayout() -------------------------------------------------------------------------------- -- Make sure FCPX is at the front. @@ -71,14 +71,18 @@ function mod.apply(action) -------------------------------------------------------------------------------- effects:show() + local effectsLayout = effects:saveLayout() + -------------------------------------------------------------------------------- -- Make sure "Installed Effects" is selected: -------------------------------------------------------------------------------- - local group = effects.group:UI() - local groupValue = group:attributeValue("AXValue") - if groupValue ~= fcp:string("PEMediaBrowserInstalledEffectsMenuItem") then - effects:showInstalledEffects() - end + effects.group:doSelectValue(fcp:string("PEMediaBrowserInstalledEffectsMenuItem")):Now() + + -- local group = effects.group:UI() + -- local groupValue = group:attributeValue("AXValue") + -- if groupValue ~= fcp:string("PEMediaBrowserInstalledEffectsMenuItem") then + -- effects:showInstalledEffects() + -- end -------------------------------------------------------------------------------- -- Get original search value: @@ -102,37 +106,33 @@ function mod.apply(action) -------------------------------------------------------------------------------- -- Perform Search: -------------------------------------------------------------------------------- - effects.search:setValue(name) + effects.search.value:set(name) -------------------------------------------------------------------------------- -- Get the list of matching effects -------------------------------------------------------------------------------- - local matches = effects:currentItemsUI() - if not matches or #matches == 0 then + local effect = effects.childrenInNavigationOrder[1] + if not effect then dialog.displayErrorMessage("Unable to find a video effect called '"..name.."'.") return false end - local effect = matches[1] - -------------------------------------------------------------------------------- -- Apply the selected Transition: -------------------------------------------------------------------------------- - effects:applyItem(effect) - - -- TODO: HACK: This timer exists to work around a mouse bug in Hammerspoon Sierra - doAfter(0.1, function() - effects.search:setValue(originalSearch) + Do(effect:doApply()) + :Then(function() + effects.search.value:set(originalSearch) effects:loadLayout(effectsLayout) if transitionsLayout then transitions:loadLayout(transitionsLayout) end if not effectsShowing then effects:hide() end end) + :Now() -- Success! return true end - local plugin = { id = "finalcutpro.timeline.videoeffects", group = "finalcutpro", diff --git a/src/tests/cp/apple/finalcutpro/_test.lua b/src/tests/cp/apple/finalcutpro/_test.lua index 5958c5cc1..5c81e9adf 100644 --- a/src/tests/cp/apple/finalcutpro/_test.lua +++ b/src/tests/cp/apple/finalcutpro/_test.lua @@ -148,7 +148,7 @@ return test.suite("cp.apple.finalcutpro"):with( ok(not fcp.commandEditor:isShowing()) fcp.commandEditor:show() ok(fcp.commandEditor:isShowing()) - ok(fcp.commandEditor.save:UI() ~= nil) + ok(fcp.commandEditor.saveButton:UI() ~= nil) fcp.commandEditor:hide() ok(not fcp.commandEditor:isShowing()) end diff --git a/src/tests/cp/fn/ax_spec.lua b/src/tests/cp/fn/ax_spec.lua index 05d728e32..808a1a553 100644 --- a/src/tests/cp/fn/ax_spec.lua +++ b/src/tests/cp/fn/ax_spec.lua @@ -273,6 +273,40 @@ return describe "cp.fn.ax" { end), }, + context "bottomToTopBaseAligned" { + it "returns false when both elements have the same bottom edge" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + expect(ax.bottomToTopBaseAligned(first, second)):is(false) + expect(ax.bottomToTopBaseAligned(second, first)):is(false) + end), + + it "returns true when the first element is more than 50% of the height above the second" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 8, w = 10, h = 10}} + expect(ax.bottomToTopBaseAligned(first, second)):is(false) + expect(ax.bottomToTopBaseAligned(second, first)):is(true) + end), + + it "returns false when the first element is less than 50% of the height above the second" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 2, w = 10, h = 10}} + expect(ax.bottomToTopBaseAligned(first, second)):is(false) + expect(ax.bottomToTopBaseAligned(second, first)):is(false) + end), + + it "isn't affected by differences in x or width" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 10, y = 0, w = 100, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + expect(ax.bottomToTopBaseAligned(first, second)):is(false) + expect(ax.bottomToTopBaseAligned(second, first)):is(false) + end), + }, + context "topDown" { it "returns false if both elements are exactly the same" :doing(function() @@ -338,4 +372,70 @@ return describe "cp.fn.ax" { expect(ax.topDown(second, first)):is(false) end), }, + + context "bottomUp" { + it "returns false if both elements are exactly the same" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + expect(ax.bottomUp(first, second)):is(false) + expect(ax.bottomUp(second, first)):is(false) + end), + + it "returns true if the both elements are aligned but the first is right of the second" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = -10, y = 0, w = 10, h = 10}} + expect(ax.bottomUp(first, second)):is(true) + expect(ax.bottomUp(second, first)):is(false) + end), + + it "returns true if the first element is left of the second element, but more than 50% of the height lower" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 10, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 10, y = 0, w = 10, h = 10}} + expect(ax.bottomUp(first, second)):is(true) + expect(ax.bottomUp(second, first)):is(false) + end), + + it "return true if the elements are aligned, have the same right position, and the first is taller" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 10, w = 10, h = 20}} + expect(ax.bottomUp(first, second)):is(false) + expect(ax.bottomUp(second, first)):is(true) + end), + + it "returns true if the elements are aligned, have the same right position, and the first is taller, even if the second is wider" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 20, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 10, w = 10, h = 20}} + expect(ax.bottomUp(first, second)):is(false) + expect(ax.bottomUp(second, first)):is(true) + end), + + it "returns true if the elements are aligned, have the same right position, are equal height, but the first is narrower" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 10, y = 0, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 20, h = 10}} + expect(ax.bottomUp(first, second)):is(true) + expect(ax.bottomUp(second, first)):is(false) + end), + + it "returns false if both elements have the same x value, but the second is taller" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 0, w = 10, h = 20}} + local second = new_axuielementMock {AXFrame = {x = 0, y = 10, w = 10, h = 10}} + expect(ax.bottomUp(first, second)):is(false) + expect(ax.bottomUp(second, first)):is(true) + end), + + it "returns true if the first element is right of the second element, even if the first is 20% lower" + :doing(function() + local first = new_axuielementMock {AXFrame = {x = 0, y = 2, w = 10, h = 10}} + local second = new_axuielementMock {AXFrame = {x = -10, y = 0, w = 10, h = 10}} + expect(ax.bottomUp(first, second)):is(true) + expect(ax.bottomUp(second, first)):is(false) + end), + }, } \ No newline at end of file diff --git a/src/tests/cp/fn/table_spec.lua b/src/tests/cp/fn/table_spec.lua index 901e5ae78..b2316f9ad 100644 --- a/src/tests/cp/fn/table_spec.lua +++ b/src/tests/cp/fn/table_spec.lua @@ -178,12 +178,12 @@ return describe "cp.fn.table" { context "imap" { it "can map a function over an ordered table" :doing(function() - expect(fntable.imap(double, {1, 2, 3})):is({2, 4, 6}) + expect(fntable.imap(double)({1, 2, 3})):is({2, 4, 6}) end), it "can map a function over a list of values" :doing(function() - local a, b, c, d = fntable.imap(double, 1, 2, 3) + local a, b, c, d = fntable.imap(double)(1, 2, 3) expect(a):is(2) expect(b):is(4) expect(c):is(6) @@ -192,7 +192,7 @@ return describe "cp.fn.table" { it "maps over ordered values and ignores key values in a table" :doing(function() - local result = fntable.imap(double, {1, 2, 3, a = 1, b = 2, c = 3}) + local result = fntable.imap(double)({1, 2, 3, a = 1, b = 2, c = 3}) expect(result):is({2, 4, 6}) end), }, @@ -236,14 +236,14 @@ return describe "cp.fn.table" { context "map" { it "can map over an unordered table" :doing(function() - local result = fntable.map(double, {1, 2, 3}) + local result = fntable.map(double)({1, 2, 3}) table.sort(result) expect(result):is({2, 4, 6}) end), it "can map over a table with key values" :doing(function() - local result = fntable.map(double, {a = 1, b = 2, c = 3}) + local result = fntable.map(double)({a = 1, b = 2, c = 3}) table.sort(result) expect(result):is({a = 2, b = 4, c = 6}) end), @@ -273,6 +273,20 @@ return describe "cp.fn.table" { end), }, + context "slice" { + it "can slice a table with a start index" + :doing(function() + local result = fntable.slice(2)({1, 2, 3, 4, 5}) + expect(result):is({2, 3, 4, 5}) + end), + + it "can slice a table with a start and count" + :doing(function() + local result = fntable.slice(2, 3)({1, 2, 3, 4, 5}) + expect(result):is({2, 3, 4}) + end), + }, + context "sort" { it "can sort an unsorted table of strings" :doing(function() diff --git a/src/tests/cp/is_spec.lua b/src/tests/cp/is_spec.lua index ed0998a7d..4d16ca9ec 100644 --- a/src/tests/cp/is_spec.lua +++ b/src/tests/cp/is_spec.lua @@ -198,7 +198,7 @@ return describe "cp.is" { it "is callable returns ${result} given ${input}" :doing(function(this) - expect(is.callable(this.input, this.result)) + expect(is.callable(this.input)):is(this.result) end) :where { { "input", "result" }, diff --git a/src/tests/cp/slice_spec.lua b/src/tests/cp/slice_spec.lua new file mode 100644 index 000000000..d5d454a2b --- /dev/null +++ b/src/tests/cp/slice_spec.lua @@ -0,0 +1,254 @@ +local spec = require("cp.spec") +local expect = require("cp.spec.expect") +local slice = require("cp.slice") + +local describe, it, context = spec.describe, spec.it, spec.context + +return describe "cp.slice" { + context "is" { + it "returns true if the value is a slice" + :doing(function() + local t = slice.new({1,2,3,4,5}, 2) + expect(slice.is(t)):is(true) + end), + + it "returns false if the value is a regular table" + :doing(function() + local t = {1,2,3,4,5} + expect(slice.is(t)):is(false) + end), + + it "returns false if the value any other type" + :doing(function() + expect(slice.is(nil)):is(false) + expect(slice.is(1)):is(false) + expect(slice.is("hello")):is(false) + expect(slice.is(true)):is(false) + end), + }, + + context "new" { + it "returns a new slice of a table from an index" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + end), + + it "returns a new slice of a table from a range" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 3) + expect(s):is({2,3,4}) + expect(#s):is(3) + end), + + it "returns a new slice of a table where the count exceeds the original table contents" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 10) + expect(s):is({2,3,4,5,nil,nil,nil,nil,nil,nil}) + expect(#s):is(10) + end), + + it "returns an empty table where the start index is higher than the original table count" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 10) + expect(s):is({}) + expect(#s):is(0) + end), + + it "throws an error when the start index is less than 1" + :doing(function(this) + local t = {1,2,3,4,5} + + this:expectAbort("start index must be 1 or higher, but was 0") + slice.new(t, 0) + end), + + -- it "throws an error when the table is not a table" + -- :doing(function(this) + -- this:expectAbort("expected table, got nil") + -- slice.new(nil, 1) + -- end), + + it "throws an error when the count is less than 0" + :doing(function(this) + local t = {1,2,3,4,5} + + this:expectAbort("invalid count: -1") + slice.new(t, 1, -1) + end), + }, + + context "from" { + it "returns a new slice when given a non-slice table" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.from(t) + expect(s):is({1,2,3,4,5}) + expect(#s):is(5) + end), + + it "returns the same slice when given a slice table" + :doing(function() + local t = {1,2,3,4,5} + local s0 = slice.new(t, 2) + local s = slice.from(s0) + expect(s):is(s0) + expect(#s):is(4) + end), + + it "throws an error when the value is not a slice or table" + :doing(function(this) + this:expectAbort("expected table or slice, got number") + slice.from(1) + end), + }, + + context "drop" { + it "returns a new slice which is shifted by the specified number of items, and the length is reduced by the specified number of items" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 3) + expect(s):is({2,3,4}) + expect(#s):is(3) + local s2 = s:drop(1) + expect(s2):is({3,4}) + expect(#s2):is(2) + + local s3 = s2:drop(1) + expect(s3):is({4}) + expect(#s3):is(1) + + expect(s):is({2,3,4}) + expect(#s):is(3) + end), + + it "returns an empty table when the count matches the slice length" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 3) + expect(s):is({2,3,4}) + expect(#s):is(3) + local s2 = s:drop(3) + expect(s2):is({}) + expect(#s2):is(0) + end), + + it "throws an error when the count is less than 0" + :doing(function(this) + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 3) + expect(s):is({2,3,4}) + expect(#s):is(3) + + this:expectAbort("invalid drop: -1") + s:drop(-1) + end), + + it "throws an error when the count is greater than the slice length" + :doing(function(this) + local t = {1,2,3,4,5} + local s = slice.new(t, 2, 3) + expect(s):is({2,3,4}) + expect(#s):is(3) + + this:expectAbort("dropping 4 but only 3 are available") + s:drop(4) + end), + }, + + context "pop" { + it "returns the first item from the front of the slice" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(#s):is(4) + local v, s2 = s:pop() + expect(v):is(2) + expect(s2):is({3,4,5}) + expect(#s2):is(3) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + end), + + it "throws an error when the slice is empty" + :doing(function(this) + local t = {1} + local s = slice.new(t, 2) + + this:expectAbort("pop from empty slice") + s:pop() + end), + }, + + context "shift" { + it "shifts the slice by the specified number of items" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + + local s2 = s:shift(2) + expect(s2):is({4,5,nil,nil}) + expect(#s2):is(4) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + end), + }, + + context "split" { + it "splits the slice into two slices" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + local s1, s2 = s:split(2) + expect(s1):is({2,3}) + expect(s2):is({4,5}) + expect(#s1):is(2) + expect(#s2):is(2) + end), + + it "splits the slice into two slices where the count equals the original table contents" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + local s1, s2 = s:split(4) + expect(s1):is({2,3,4,5}) + expect(s2):is({}) + expect(#s1):is(4) + expect(#s2):is(0) + end), + + -- it "splits the slice into two slices where the count exceeds the original table contents" + -- :doing(function(this) + -- local t = {1,2,3,4,5} + -- local s = slice.new(t, 2) + -- expect(s):is({2,3,4,5}) + -- expect(#s):is(4) + -- this:expectAbort("split size (10) is greater than slice size (4)") + -- local _, _ = s:split(10) + -- end), + }, + + context "clone" { + it "returns a new slice with the same contents" + :doing(function() + local t = {1,2,3,4,5} + local s = slice.new(t, 2) + expect(s):is({2,3,4,5}) + expect(#s):is(4) + local s2 = s:clone() + expect(s2):is({2,3,4,5}) + expect(#s2):is(4) + end), + } +} \ No newline at end of file diff --git a/src/tests/cp/ui/Builder_spec.lua b/src/tests/cp/ui/Builder_spec.lua index 0fc380dba..f74956741 100644 --- a/src/tests/cp/ui/Builder_spec.lua +++ b/src/tests/cp/ui/Builder_spec.lua @@ -168,5 +168,5 @@ return describe "cp.ui.Builder" { expect(c.alpha2):is(nil) expect(c.beta):is("beta") end), - } + }, } diff --git a/src/tests/cp/ui/has/ElementRepeater_spec.lua b/src/tests/cp/ui/has/ElementRepeater_spec.lua new file mode 100644 index 000000000..dad578c5f --- /dev/null +++ b/src/tests/cp/ui/has/ElementRepeater_spec.lua @@ -0,0 +1,330 @@ +local require = require +local spec = require "cp.spec" +local describe, it = spec.describe, spec.it +local context = spec.context +local expect = require "cp.spec.expect" + +local prop = require "cp.prop" +local has = require "cp.ui.has" +local Element = require "cp.ui.Element" +local ElementRepeater = require "cp.ui.has.ElementRepeater" + +local axmock = require "cp.ui.mock.axuielement" + +local MockElement = Element:subclass("MockElement") + +function MockElement.static.matches(element) + return element ~= nil and element.AXRole == "AXMock" +end + +return describe "cp.ui.has.ElementRepeater" { + context "StaticText" { + it "matches with mocks" + :doing(function() + local mock = axmock { AXRole = "AXMock", AXValue = "Hello World" } + expect(mock:isValid()):is(true) + expect(MockElement.matches(mock)):is(true) + end), + }, + + context "item" { + it "supports a single item" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + local item1 = element:item(1) + expect(MockElement:isClassFor(item1)):is(true) + expect(item1:UI()):is(axmock { AXRole="AXMock" }) + end), + + it "supports multiple items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + local item1 = element:item(1) + local item2 = element:item(2) + local item3 = element:item(3) + local item4 = element:item(4) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(MockElement:isClassFor(item3)):is(true) + expect(MockElement:isClassFor(item4)):is(true) + expect(item1:UI().AXValue):is("A") + expect(item2:UI().AXValue):is("B") + expect(item3:UI().AXValue):is("C") + expect(item4:UI()):is(nil) + end), + + it "supports zero items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return {} + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + local item1 = element:item(1) + local item2 = element:item(2) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(item1:UI()):is(nil) + expect(item2:UI()):is(nil) + end), + + it "returns values when there are at least minCount items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler, 2) + local item1 = element:item(1) + local item2 = element:item(2) + local item3 = element:item(3) + local item4 = element:item(4) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(MockElement:isClassFor(item3)):is(true) + expect(MockElement:isClassFor(item4)):is(true) + expect(item1:UI().AXValue):is("A") + expect(item2:UI().AXValue):is("B") + expect(item3:UI().AXValue):is("C") + expect(item4:UI()):is(nil) + end), + + it "returns nil when there are less than minCount items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler, 3) + local item1 = element:item(1) + local item2 = element:item(2) + local item3 = element:item(3) + local item4 = element:item(4) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(MockElement:isClassFor(item3)):is(true) + expect(MockElement:isClassFor(item4)):is(true) + expect(item1:UI()):is(nil) + expect(item2:UI()):is(nil) + expect(item3:UI()):is(nil) + expect(item4:UI()):is(nil) + end), + + it "returns nil when accessing items above the maxCount" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + axmock { AXRole="AXMock", AXValue="D" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler, nil, 3) + local item1 = element:item(1) + local item2 = element:item(2) + local item3 = element:item(3) + local item4 = element:item(4) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(MockElement:isClassFor(item3)):is(true) + expect(item4):is(nil) + + expect(item1:UI().AXValue):is("A") + expect(item2:UI().AXValue):is("B") + expect(item3:UI().AXValue):is("C") + end), + + it "returns values when there are more than minCount and less than maxCount" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + axmock { AXRole="AXMock", AXValue="D" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler, 2, 5) + local item1 = element:item(1) + local item2 = element:item(2) + local item3 = element:item(3) + local item4 = element:item(4) + local item5 = element:item(5) + local item6 = element:item(6) + expect(MockElement:isClassFor(item1)):is(true) + expect(MockElement:isClassFor(item2)):is(true) + expect(MockElement:isClassFor(item3)):is(true) + expect(MockElement:isClassFor(item4)):is(true) + expect(MockElement:isClassFor(item5)):is(true) + expect(item6):is(nil) + + expect(item1:UI().AXValue):is("A") + expect(item2:UI().AXValue):is("B") + expect(item3:UI().AXValue):is("C") + expect(item4:UI().AXValue):is("D") + end), + }, + + context "count" { + it "supports a single item" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(element:count()):is(1) + end), + + it "supports multiple items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(element:count()):is(3) + end), + + it "supports zero items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return {} + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(element:count()):is(0) + end), + }, + + context "len" { + it "supports a single item" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(#element):is(1) + end), + + it "supports multiple items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(#element):is(3) + end), + + it "supports zero items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return {} + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(#element):is(0) + end), + }, + + context "list index" { + it "supports a single item" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(element[1]):is(element:item(1)) + expect(element[2]):is(nil) + end), + + it "supports multiple items" + :doing(function() + local parent = {} + local uiFinder = prop(function() + return { + axmock { AXRole="AXMock", AXValue="A" }, + axmock { AXRole="AXMock", AXValue="B" }, + axmock { AXRole="AXMock", AXValue="C" }, + } + end) + local handler = has.element(MockElement) + + local element = ElementRepeater(parent, uiFinder, handler) + expect(element[1]):is(element:item(1)) + expect(element[2]):is(element:item(2)) + expect(element[3]):is(element:item(3)) + expect(element[4]):is(nil) + end), + + } +} \ No newline at end of file diff --git a/src/tests/cp/ui/has/UIHandler_spec.lua b/src/tests/cp/ui/has/UIHandler_spec.lua new file mode 100644 index 000000000..3537f4e08 --- /dev/null +++ b/src/tests/cp/ui/has/UIHandler_spec.lua @@ -0,0 +1,20 @@ +local spec = require "cp.spec" +local describe, it = spec.describe, spec.it + +local UIHandler = require "cp.ui.has.UIHandler" + +return describe "cp.ui.has.UIHandler" { + it "throws an error when calling matches" + :doing(function(this) + local handler = UIHandler() + this:expectAbort("cp.ui.has.UIHandler:matches() is not implemented.") + handler:matches({}) + end), + + it "throws an error when calling build" + :doing(function(this) + local handler = UIHandler() + this:expectAbort("cp.ui.has.UIHandler:build() is not implemented.") + handler:build({}, {}) + end), +} \ No newline at end of file diff --git a/src/tests/middleclass_spec.lua b/src/tests/middleclass_spec.lua index 8898121c8..96d7aaf95 100644 --- a/src/tests/middleclass_spec.lua +++ b/src/tests/middleclass_spec.lua @@ -10,6 +10,9 @@ local describe, context, it = spec.describe, spec.context, spec.it local class = require "middleclass" +local MyClass = class("MyClass") +local MySubclass = MyClass:subclass("MySubclass") + return describe "middleclass" { context "class" { @@ -19,6 +22,68 @@ return describe "middleclass" { expect(Alpha.name):is("Alpha") end), + + context "isClassFor" { + it "returns true if the value is a MyClass" + :doing(function() + local myInstance = MyClass() + expect(MyClass:isClassFor(myInstance)):is(true) + expect(MySubclass:isClassFor(myInstance)):is(false) + end), + + it "returns false if the value is a subclass" + :doing(function() + local myInstance = MySubclass() + + expect(MyClass:isClassFor(myInstance)):is(false) + expect(MySubclass:isClassFor(myInstance)):is(true) + end), + + it "returns false if the value is not an instance of the class" + :doing(function() + expect(MyClass:isClassFor("foo")):is(false) + end), + }, + + context "isSuperclassFor" { + it "returns true if the value is a subclass of MyClass" + :doing(function() + local myInstance = MySubclass() + expect(MyClass:isSuperclassFor(myInstance)):is(true) + expect(MySubclass:isSuperclassFor(myInstance)):is(true) + end), + + it "returns false if the value is not a subclass of MyClass" + :doing(function() + local myInstance = MyClass() + expect(MyClass:isSuperclassFor(myInstance)):is(true) + expect(MySubclass:isSuperclassFor(myInstance)):is(false) + end), + + it "returns false if the value is not an instance of the class" + :doing(function() + expect(MyClass:isSuperclassFor("foo")):is(false) + end), + }, + + context "isSuperclassOf" { + it "returns true if the value is a subclass of MyClass" + :doing(function() + expect(MyClass:isSuperclassOf(MyClass)):is(true) + expect(MyClass:isSuperclassOf(MySubclass)):is(true) + expect(MySubclass:isSuperclassOf(MySubclass)):is(true) + end), + + it "returns false if the value is not a subclass of MyClass" + :doing(function() + expect(MySubclass:isSuperclassOf(MyClass)):is(false) + end), + + it "returns false if the value is not an instance of the class" + :doing(function() + expect(MyClass:isSuperclassOf("foo")):is(false) + end), + }, }, context "instance" { @@ -65,15 +130,10 @@ return describe "middleclass" { local a = Alpha() local b = Beta() - expect(Alpha:isClassFor(a)):is(true) - expect(Alpha:isClassFor(b)):is(true) - expect(Beta:isClassFor(a)):is(false) - expect(Beta:isClassFor(b)):is(true) - - expect(Alpha:isClassFor(nil)):is(false) - expect(Alpha:isClassFor(123)):is(false) - expect(Alpha:isClassFor("Alpha")):is(false) - expect(Alpha:isClassFor(true)):is(false) + expect(a:isInstanceOf(Alpha)):is(true) + expect(b:isInstanceOf(Alpha)):is(true) + expect(a:isInstanceOf(Beta)):is(false) + expect(b:isInstanceOf(Beta)):is(true) end) },