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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions .idea/FreeCAD-CommandPalette.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/inspectionProfiles/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

182 changes: 132 additions & 50 deletions CommandPalette.FCMacro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
# shortcut(): shortcut keys for that action as a QKeySequence.
# trigger(): invoke that action.

from PySide import QtGui, QtCore
from PySide.QtGui import *
from PySide.QtCore import *

def log(str: str) -> None:
FreeCAD.Console.PrintMessage(str + "\n")


def enumerateActions(menu, path=[]):
actionList = []
Expand All @@ -28,6 +33,7 @@ def enumerateActions(menu, path=[]):
actionList.append((action, path))
return actionList


def getMenuItems():
def convertAction(action, path):
# For an unknown reason, there are a bunch of blank menu items -- filter them out.
Expand All @@ -38,42 +44,34 @@ def getMenuItems():
if not action.isEnabled():
return None

pathText = "->".join(path)
fullText = f"{action.text()} ({pathText})"
# fixedText = action.text().replace('&', '')
fixedText = fullText.replace('&', '')

item = PaletteListWidgetItem()
item.setText(fixedText)
item.setSearchText(fixedText.casefold())
item.setToolTip(action.toolTip())
item.setIcon(action.icon())
item.setTrigger(action.trigger)
item = PaletteListWidgetItem(action, path)
return item

mw = Gui.getMainWindow()
menuBar = mw.menuBar()
return [convertAction(action, path) for action, path in enumerateActions(menuBar) if action is not None]
return [item for item in
[convertAction(action, path) for action, path in enumerateActions(menuBar) if action is not None]
if item is not None]


class PaletteDialog(QtGui.QDialog):
class PaletteDialog(QDialog):
def __init__(self, parent):
super().__init__(parent)
self.installEventFilter(self)

def eventFilter(self, obj, event):
# Hide the palette if it ever loses focus.
if event.type() == QtCore.QEvent.WindowDeactivate:
self.hide()
if event.type() == QEvent.WindowDeactivate:
self.close()
return True
return False


class PaletteLineEdit(QtGui.QLineEdit):
keyMappings = {}
class PaletteLineEdit(QLineEdit):
keyMappings: dict[QKeyCombination, any] = {}

def keyPressEvent(self, event):
keyFn = self.keyMappings.get(event.key())
keyFn = self.keyMappings.get(event.keyCombination())
if keyFn is not None:
keyFn()
return
Expand All @@ -83,84 +81,169 @@ class PaletteLineEdit(QtGui.QLineEdit):
def setMappings(self, keyMappings):
self.keyMappings = keyMappings

class PaletteListWidgetItem(QtGui.QListWidgetItem):
_searchText = None

class PaletteListWidgetItem(QListWidgetItem):
_searchText: str = None
_trigger = None
_widget: QWidget = None
_actionText: str = None
_actionLabel: QLabel = None
_pathText: str = None
_pathLabel: QLabel = None

def __init__(self, action: QAction, path: list[str]):
super(PaletteListWidgetItem, self).__init__()

# Icon
icon = action.icon()
if icon.isNull():
# Add transparent
icon = QIcon()
pixmap = QPixmap(QSize(32, 32))
pixmap.fill(QColorConstants.Transparent)
icon.addPixmap(pixmap)
self.setIcon(icon)

# Create content
layout = QHBoxLayout()
layout.setContentsMargins(5, 0, 5, 0)

# Add action text
actionText = action.text().replace('&', '')
self._actionText = actionText.casefold()
self._actionLabel = QLabel(f"<span style=\"font-weight: 600; font-size: 16pt;\">{actionText}</span>")
layout.addWidget(self._actionLabel, 6)

# Add path text
pathText = "->".join(path).replace('&', '')
self._pathText = pathText.casefold()
self._pathLabel = QLabel(f"<span style=\"font-weight: 200; font-size: 12pt;\">{pathText}</span>")
layout.addWidget(self._pathLabel, 4)

# Add shortcut
shortcut = action.shortcut()
shortcutText = f"<span style=\"font-weight: 400; font-size: 12pt;\">{shortcut.toString(QKeySequence.SequenceFormat.NativeText)}</span>" if not shortcut.isEmpty() else ""
shortcutLabel = QLabel(shortcutText)
layout.addWidget(shortcutLabel, 1)

# Outer vertical layout
vLayout = QVBoxLayout()
vLayout.addLayout(layout)

# Add tooltip under action details
tooltipLabel = QLabel(action.toolTip())
vLayout.addWidget(tooltipLabel)

widget = QWidget()
widget.setLayout(vLayout)

self.setSizeHint(QSize(0, 60))
self.setToolTip(action.toolTip())
self._searchText = f"{self._actionText} {self._pathText}" # Use action and path for search
self._trigger = action.trigger
self._widget = widget

def searchText(self):
return self._searchText

def setSearchText(self, text):
self._searchText = text

def matches(self, text):
return False

def runTrigger(self):
self._trigger()

def setTrigger(self, trigger):
self._trigger = trigger
def widget(self):
return self._widget

def matches(self, terms: list[str]) -> bool:
# Can all search terms be found
match = all(term in self._searchText for term in terms)

if match:
self.setHidden(False)
self.__selectMatchedText(self._actionText, self._actionLabel, terms)
self.__selectMatchedText(self._pathText, self._pathLabel, terms)
else:
self.setHidden(True)

return match

def __selectMatchedText(self, text: str, label: QLabel, terms: list[str]) -> None:
start = None
end = None
for term in terms:
if (index := text.find(term)) >= 0:
if start == None:
start = index
end = index + len(term)
else:
if index < start:
start = index
else:
end = index + len(term)

label.setSelection(start or 0, 0 if end == None else end - start)


class CommandPalette:
MIN_DIALOG_WIDTH = 600
MIN_DIALOG_WIDTH = 700
MIN_DIALOG_HEIGHT = 400

def activate(self):
items = getMenuItems()
dialog = PaletteDialog(Gui.getMainWindow())
dialog.setObjectName("CommandPalette")
dialog.setMinimumWidth(self.MIN_DIALOG_WIDTH)
dialog.setMinimumHeight(self.MIN_DIALOG_HEIGHT)
dialog.setWindowFlags(dialog.windowFlags() | QtCore.Qt.FramelessWindowHint)
dialog.setWindowFlags(dialog.windowFlags() | Qt.FramelessWindowHint)
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)


vbox = QtGui.QVBoxLayout(dialog)
vbox = QVBoxLayout(dialog)

searchBar = PaletteLineEdit()
searchBar.textChanged.connect(self.textChanged)
searchBar.returnPressed.connect(self.returnPressed)
searchBar.setMappings({
QtCore.Qt.Key.Key_Down: self.downPressed,
QtCore.Qt.Key.Key_Up: self.upPressed,
# Up / down
QKeyCombination(Qt.Key.Key_Down): self.downPressed,
QKeyCombination(Qt.Key.Key_Up): self.upPressed,
QKeyCombination(Qt.KeyboardModifier.KeypadModifier, Qt.Key.Key_Down): self.downPressed,
QKeyCombination(Qt.KeyboardModifier.KeypadModifier, Qt.Key.Key_Up): self.upPressed,
# C-N, C-P (Vim)
QKeyCombination(Qt.KeyboardModifier.MetaModifier, Qt.Key.Key_N): self.downPressed,
QKeyCombination(Qt.KeyboardModifier.MetaModifier, Qt.Key.Key_P): self.upPressed,
QKeyCombination(Qt.KeyboardModifier.ControlModifier, Qt.Key.Key_N): self.downPressed,
QKeyCombination(Qt.KeyboardModifier.ControlModifier, Qt.Key.Key_P): self.upPressed,
})
vbox.addWidget(searchBar)

commandList = QtGui.QListWidget()
commandList = QListWidget()
vbox.addWidget(commandList)

for item in items:
commandList.addItem(item)
commandList.setItemWidget(item, item.widget())

commandList.itemClicked.connect(self.itemClicked)

commandList.show()
dialog.show()


self.commandList = commandList
self.dialog = dialog # prevent dialog from being garbage collected


def textChanged(self, newText):
foldedText = newText.casefold()
foldedTerms = foldedText.split()
terms = newText.casefold().split()
currentItemSet = False
for i in range(0, self.commandList.count()):
item = self.commandList.item(i)

if all(term in item.searchText() for term in foldedTerms):
item.setHidden(False)
if item.matches(terms):
if not currentItemSet:
self.commandList.setCurrentItem(item)
currentItemSet = True
else:
item.setHidden(True)

def returnPressed(self):
# Run the selected action.
currentItem = self.commandList.currentItem()
if currentItem is not None:
self.dialog.hide()
self.dialog.close()
currentItem.runTrigger()

def downPressed(self):
Expand All @@ -180,9 +263,8 @@ class CommandPalette:
return

def itemClicked(self, item):
self.dialog.hide()
self.dialog.close()
item.runTrigger()


palette = CommandPalette()
palette.activate()
if __name__ == "__main__":
CommandPalette().activate()
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
# ComandPalette Addon
Command Palette is a FreeCAD macro for quickly searching and executing menu actions.

This is a continuation of the [CommandPalette](https://github.com/ddfisher/CommandPalette) addon by [ddfisher](https://github.com/ddfisher).

![CommandPalette](screenshot.png)

Search among available menu actions, showing (_from left to right_) the menu item, menu path and keyboard shortcut (_if
available_).

## Basic Operation
- Add the CommandPalette macro to a global toolbar, then add a keyboard shortcut. (`Shift-Space` or `Ctrl-Space` recommended.)
- Press your assigned keyboard shortcut and type in name of the command you're looking for in the search box. Press the up or down arrows to select the item if necessary, then press enter to trigger the action.
- The Command Palette can be dismissed by pressing `Esc`.

![Searching](screenshot_search.png)

## Usage Notes
- Actions are drawn from the available menu items. The menu path to each action is displayed in parenthesis.
- Comand Palette uses a simple fuzzy search: each word in the search box is matched independently. (E.g. searching for "con hori d" will match "Constrain horizontal distance".)
- Keyboard shortcuts and additional details are displayed in the hover tooltip when you mouse over an action.
- Currently, disabled menu items are not displayed.

## Developer
## Developers

Day Fisher ([@ddfisher](https://github.com/ddfisher))
- Day Fisher ([@ddfisher](https://github.com/ddfisher))
- Michael Dahl-Kofoed ([@micdah](https://github.com/micdah))

## License

Expand Down
Loading