mirror of https://github.com/aseprite/aseprite.git
Add Run Command
This commit is contained in:
parent
94174509a4
commit
6f1870cff3
|
@ -1160,5 +1160,18 @@
|
|||
<style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/>
|
||||
<style id="ani_button" extends="buttonset_item_icon" width="17"/>
|
||||
<style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/>
|
||||
<style id="runner_command_item" height="15" border="3">
|
||||
<background color="menuitem_hot_face" state="selected" />
|
||||
<text color="text" x="3" align="left" />
|
||||
<text color="disabled" x="3" align="left" state="disabled" />
|
||||
<text color="menuitem_hot_text" x="3" align="left" state="selected" />
|
||||
</style>
|
||||
<style id="runner_extension_tag" font="mini">
|
||||
<background color="menuitem_highlight_face" />
|
||||
<text color="menuitem_highlight_text" align="center" />
|
||||
</style>
|
||||
<style id="runner_command_checked" width="8" height="8">
|
||||
<icon part="check_selected" align="center middle" />
|
||||
</style>
|
||||
</styles>
|
||||
</theme>
|
||||
|
|
|
@ -1150,5 +1150,18 @@
|
|||
<style id="outline_cell" extends="buttonset_item_icon" width="17" height="19"/>
|
||||
<style id="ani_button" extends="buttonset_item_icon" width="17"/>
|
||||
<style id="multi_window_item" extends="buttonset_item_icon" width="17" height="17"/>
|
||||
<style id="runner_command_item" height="15" border="3">
|
||||
<background color="menuitem_hot_face" state="selected" />
|
||||
<text color="text" x="3" align="left" />
|
||||
<text color="disabled" x="3" align="left" state="disabled" />
|
||||
<text color="menuitem_hot_text" x="3" align="left" state="selected" />
|
||||
</style>
|
||||
<style id="runner_extension_tag" font="mini">
|
||||
<background color="menuitem_highlight_face" />
|
||||
<text color="menuitem_highlight_text" align="center" />
|
||||
</style>
|
||||
<style id="runner_command_checked" width="8" height="8">
|
||||
<icon part="check_selected" align="center middle" />
|
||||
</style>
|
||||
</styles>
|
||||
</theme>
|
||||
|
|
|
@ -119,6 +119,7 @@
|
|||
<param name="frame" value="current" />
|
||||
</key>
|
||||
<key command="ReverseFrames" shortcut="Alt+I" />
|
||||
<key command="RunCommand" shortcut="Ctrl+Space" />
|
||||
<key command="GotoFirstFrame" shortcut="Home" />
|
||||
<key command="GotoPreviousFrame" shortcut="Left" context="Normal" />
|
||||
<key command="GotoNextFrame" shortcut="Right" context="Normal" />
|
||||
|
@ -1010,6 +1011,7 @@
|
|||
<menu text="@.view" id="view_menu">
|
||||
<item command="DuplicateView" text="@.view_duplicate_view" group="view_new" />
|
||||
<item command="ToggleWorkspaceLayout" text="@.view_workspace_layout" />
|
||||
<item command="RunCommand" text="@.view_run_command" />
|
||||
<separator />
|
||||
<item command="ShowExtras" text="@.view_show_extras" />
|
||||
<menu text="@.view_show" group="view_extras">
|
||||
|
|
|
@ -403,6 +403,7 @@ ReverseFrames = Reverse Frames
|
|||
Rotate = Rotate {0} {1}
|
||||
Rotate_Selection = Selection
|
||||
Rotate_Sprite = Sprite
|
||||
RunCommand = Run Command
|
||||
RunScript = Run Script
|
||||
SaveFile = Save File
|
||||
SaveFileAs = Save File As
|
||||
|
@ -1195,6 +1196,7 @@ select_save_to_file = &Save to MSK file
|
|||
view = &View
|
||||
view_duplicate_view = Duplicate &View
|
||||
view_workspace_layout = Workspace &Layout
|
||||
view_run_command = Run Command
|
||||
view_show_extras = &Extras
|
||||
view_show = &Show
|
||||
view_show_layer_edges = &Layer Edges
|
||||
|
@ -2003,3 +2005,17 @@ toggle_left_diagonal = Toggle -45° Symmetry
|
|||
show_options = Symmetry Options
|
||||
reset_position = Reset Symmetry to Center
|
||||
reset_position_to_view_center = Reset Symmetry to View Center
|
||||
|
||||
[run_command]
|
||||
title = Run Command
|
||||
title_help = Run Command: Help
|
||||
title_expression = Run Command: Math Expressions
|
||||
title_script = Run Command: Lua Script Runner
|
||||
command_placeholder = Search for commands, ? for help.
|
||||
more_result_count = ... ({} more results)
|
||||
tip_executed_command = Executed: '{0}'
|
||||
tip_code_executed = Code executed successfully.
|
||||
help_search = Type to search through Aseprite's functionality.
|
||||
help_expressions = You can also start with "=" to do math expressions
|
||||
help_lua = or "@" to execute Lua script code.
|
||||
extension_tag = Extension
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<!-- Aseprite -->
|
||||
<!-- Copyright (C) 2025 Igara Studio S.A. -->
|
||||
<gui>
|
||||
<window id="run_command" minwidth="256" maxwidth="256" text="@.title" help="run-command">
|
||||
<vbox>
|
||||
<search id="search" placeholder="@.command_placeholder" />
|
||||
|
||||
<label id="expression_result" visible="false" />
|
||||
|
||||
<vbox id="help" visible="false">
|
||||
<label text="@.help_search" />
|
||||
<label text="@.help_expressions" />
|
||||
<label id="help_lua" text="@.help_lua" />
|
||||
</vbox>
|
||||
|
||||
<vbox id="command_list" visible="false" />
|
||||
<vbox id="more_results" visible="false">
|
||||
<separator horizontal="true" />
|
||||
<label id="more_count" />
|
||||
</vbox>
|
||||
</vbox>
|
||||
</window>
|
||||
</gui>
|
|
@ -462,6 +462,7 @@ target_sources(app-lib PRIVATE
|
|||
commands/cmd_reselect_mask.cpp
|
||||
commands/cmd_reverse_frames.cpp
|
||||
commands/cmd_rotate.cpp
|
||||
commands/cmd_run_command.cpp
|
||||
commands/cmd_save_file.cpp
|
||||
commands/cmd_save_mask.cpp
|
||||
commands/cmd_save_palette.cpp
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
#include "app/ui/keyboard_shortcuts.h"
|
||||
#include "app/ui/main_window.h"
|
||||
#include "app/ui_context.h"
|
||||
#include "app/util/filetoks.h"
|
||||
#include "base/fs.h"
|
||||
#include "base/string.h"
|
||||
#include "fmt/format.h"
|
||||
|
@ -463,6 +462,8 @@ void AppMenus::reload()
|
|||
// Create native menus after the default + user defined keyboard
|
||||
// shortcuts are loaded correctly.
|
||||
createNativeMenus();
|
||||
|
||||
MenusLoaded();
|
||||
}
|
||||
|
||||
#ifdef ENABLE_SCRIPTING
|
||||
|
|
|
@ -74,6 +74,8 @@ public:
|
|||
void removeMenuItemFromGroup(Command* cmd);
|
||||
void removeMenuItemFromGroup(Widget* menuItem);
|
||||
|
||||
obs::signal<void()> MenusLoaded;
|
||||
|
||||
private:
|
||||
template<typename Pred>
|
||||
void removeMenuItemFromGroup(Pred pred);
|
||||
|
|
|
@ -23,6 +23,7 @@ public:
|
|||
|
||||
protected:
|
||||
void onLoadParams(const Params& params) override;
|
||||
bool onNeedsParams() const override { return true; };
|
||||
void onExecute(Context* context) override;
|
||||
std::string onGetFriendlyName() const override;
|
||||
bool isListed(const Params& params) const override { return !params.get("path").empty(); }
|
||||
|
|
|
@ -23,6 +23,7 @@ public:
|
|||
|
||||
protected:
|
||||
bool onEnabled(Context* context) override;
|
||||
bool onNeedsParams() const override { return true; };
|
||||
void onLoadParams(const Params& params) override;
|
||||
void onExecute(Context* context) override;
|
||||
std::string onGetFriendlyName() const override;
|
||||
|
|
|
@ -0,0 +1,570 @@
|
|||
// Aseprite
|
||||
// Copyright (C) 2025 Igara Studio S.A.
|
||||
//
|
||||
// This program is distributed under the terms of
|
||||
// the End-User License Agreement for Aseprite.
|
||||
|
||||
#ifdef HAVE_CONFIG_H
|
||||
#include "config.h"
|
||||
#endif
|
||||
|
||||
#include "app/app.h"
|
||||
#include "app/app_menus.h"
|
||||
#include "app/commands/command.h"
|
||||
#include "app/context.h"
|
||||
#include "app/i18n/strings.h"
|
||||
#include "app/ini_file.h"
|
||||
#include "app/match_words.h"
|
||||
#include "app/modules/gui.h"
|
||||
#include "app/ui/app_menuitem.h"
|
||||
#include "app/ui/keyboard_shortcuts.h"
|
||||
#include "app/ui/main_window.h"
|
||||
#include "app/ui/skin/skin_theme.h"
|
||||
#include "app/ui/status_bar.h"
|
||||
#include "base/chrono.h"
|
||||
#include "commands.h"
|
||||
#include "tinyexpr.h"
|
||||
#include "ui/entry.h"
|
||||
#include "ui/fit_bounds.h"
|
||||
#include "ui/label.h"
|
||||
#include "ui/message.h"
|
||||
#include "ui/system.h"
|
||||
|
||||
#ifdef ENABLE_SCRIPTING
|
||||
#include "app/script/engine.h"
|
||||
#endif
|
||||
|
||||
#include "run_command.xml.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace app {
|
||||
|
||||
using namespace ui;
|
||||
|
||||
namespace {
|
||||
constexpr auto kMaxVisibleResults = 10;
|
||||
constexpr double kDisabledScore = 200;
|
||||
|
||||
struct RunnerDB {
|
||||
struct Item {
|
||||
std::string label;
|
||||
std::vector<std::string> labelWords;
|
||||
std::string searchableText;
|
||||
std::string shortcutString;
|
||||
Command* command;
|
||||
Params params;
|
||||
};
|
||||
|
||||
std::vector<Item> items;
|
||||
};
|
||||
|
||||
class RunnerWindow final : public gen::RunCommand {
|
||||
enum class Mode : uint8_t { Search, Script, Expression, ShowHelp };
|
||||
|
||||
class CommandItemView final : public HBox {
|
||||
public:
|
||||
explicit CommandItemView()
|
||||
: m_shortcutLabel(new Label(""))
|
||||
, m_pluginTagLabel(new Label(Strings::run_command_extension_tag()))
|
||||
, m_checkIndicator(new Widget)
|
||||
, m_item(nullptr)
|
||||
{
|
||||
disableFlags(IGNORE_MOUSE);
|
||||
setFocusStop(true);
|
||||
|
||||
auto* filler = new BoxFiller();
|
||||
filler->setTransparent(true);
|
||||
addChild(filler);
|
||||
|
||||
m_pluginTagLabel->setVisible(false);
|
||||
m_pluginTagLabel->InitTheme.connect([this] {
|
||||
const auto* theme = skin::SkinTheme::get(this);
|
||||
m_pluginTagLabel->setStyle(theme->styles.runnerExtensionTag());
|
||||
});
|
||||
m_pluginTagLabel->initTheme();
|
||||
addChild(m_pluginTagLabel);
|
||||
|
||||
m_shortcutLabel->setTransparent(true);
|
||||
m_shortcutLabel->setEnabled(false);
|
||||
addChild(m_shortcutLabel);
|
||||
|
||||
m_checkIndicator->setVisible(false);
|
||||
m_checkIndicator->setMinSize(gfx::Size(8, 8));
|
||||
m_checkIndicator->InitTheme.connect([this] {
|
||||
const auto* theme = skin::SkinTheme::get(m_checkIndicator);
|
||||
m_checkIndicator->setStyle(theme->styles.runnerCommandChecked());
|
||||
});
|
||||
m_checkIndicator->initTheme();
|
||||
addChild(m_checkIndicator);
|
||||
|
||||
InitTheme.connect([this] {
|
||||
const auto* theme = skin::SkinTheme::get(this);
|
||||
this->setStyle(theme->styles.runnerCommandItem());
|
||||
});
|
||||
initTheme();
|
||||
}
|
||||
|
||||
void setChecked(bool checked) const { m_checkIndicator->setVisible(checked); }
|
||||
|
||||
void setItem(const RunnerDB::Item* item)
|
||||
{
|
||||
m_item = item;
|
||||
|
||||
if (item != nullptr) {
|
||||
setText(item->label);
|
||||
processMnemonicFromText();
|
||||
|
||||
m_pluginTagLabel->setVisible(item->command->isPlugin());
|
||||
|
||||
if (item->shortcutString.empty()) {
|
||||
m_shortcutLabel->setVisible(false);
|
||||
}
|
||||
else {
|
||||
m_shortcutLabel->setText(item->shortcutString);
|
||||
m_shortcutLabel->setVisible(true);
|
||||
}
|
||||
}
|
||||
else
|
||||
setVisible(false);
|
||||
|
||||
layout();
|
||||
}
|
||||
|
||||
void run()
|
||||
{
|
||||
if (isEnabled())
|
||||
Click(m_item);
|
||||
}
|
||||
|
||||
obs::signal<void(const RunnerDB::Item*)> Click;
|
||||
|
||||
protected:
|
||||
bool onProcessMessage(Message* msg) override
|
||||
{
|
||||
if (isEnabled()) {
|
||||
switch (msg->type()) {
|
||||
case kMouseDownMessage: {
|
||||
Click(m_item);
|
||||
} break;
|
||||
case kMouseEnterMessage:
|
||||
case kFocusEnterMessage: {
|
||||
setSelected(true);
|
||||
} break;
|
||||
case kMouseLeaveMessage:
|
||||
case kFocusLeaveMessage: {
|
||||
setSelected(false);
|
||||
} break;
|
||||
case kSetCursorMessage: {
|
||||
set_mouse_cursor(kHandCursor);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Widget::onProcessMessage(msg);
|
||||
}
|
||||
|
||||
private:
|
||||
Label* m_shortcutLabel;
|
||||
Label* m_pluginTagLabel;
|
||||
Widget* m_checkIndicator;
|
||||
const RunnerDB::Item* m_item;
|
||||
};
|
||||
|
||||
public:
|
||||
explicit RunnerWindow(Context* ctx, RunnerDB* db) : m_db(db), m_context(ctx)
|
||||
{
|
||||
search()->Change.connect(&RunnerWindow::onTextChange, this);
|
||||
#ifndef ENABLE_SCRIPTING
|
||||
helpLua()->setVisible(false);
|
||||
#endif
|
||||
|
||||
for (int i = 0; i <= kMaxVisibleResults; i++) {
|
||||
auto* view = new CommandItemView;
|
||||
view->Click.connect(&RunnerWindow::executeItem, this);
|
||||
commandList()->addChild(view);
|
||||
}
|
||||
commandList()->InitTheme.connect([this] { commandList()->noBorderNoChildSpacing(); });
|
||||
commandList()->initTheme();
|
||||
|
||||
setSizeable(false);
|
||||
};
|
||||
|
||||
private:
|
||||
bool onProcessMessage(Message* msg) override
|
||||
{
|
||||
switch (msg->type()) {
|
||||
case kKeyDownMessage: {
|
||||
const auto* keyMessage = static_cast<KeyMessage*>(msg);
|
||||
if (!search()->hasFocus() &&
|
||||
(keyMessage->scancode() == kKeyBackspace || keyMessage->scancode() == kKeyHome)) {
|
||||
// Go back to the search
|
||||
search()->requestFocus();
|
||||
break;
|
||||
}
|
||||
|
||||
if (keyMessage->scancode() != kKeyEnter && keyMessage->scancode() != kKeyEnterPad)
|
||||
break;
|
||||
|
||||
switch (m_mode) {
|
||||
case Mode::Search: {
|
||||
for (auto* child : commandList()->children()) {
|
||||
if (!child->hasFlags(HIDDEN) && child->isSelected()) {
|
||||
static_cast<CommandItemView*>(child)->run();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Mode::Expression: {
|
||||
// TODO: We could copy the result to the keyboard on Enter? Should tell the user tho.
|
||||
closeWindow(nullptr);
|
||||
return true;
|
||||
}
|
||||
#ifdef ENABLE_SCRIPTING
|
||||
case Mode::Script: {
|
||||
executeScript();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Window::onProcessMessage(msg);
|
||||
}
|
||||
|
||||
// Avoids invalidating every keystroke.
|
||||
void setText(const std::string& t)
|
||||
{
|
||||
if (t != text())
|
||||
Window::setText(t);
|
||||
}
|
||||
|
||||
void refit()
|
||||
{
|
||||
if (bounds().size() != sizeHint())
|
||||
expandWindow(sizeHint());
|
||||
}
|
||||
|
||||
void executeItem(const RunnerDB::Item* item)
|
||||
{
|
||||
closeWindow(nullptr);
|
||||
StatusBar::instance()->showTip(1000, Strings::run_command_tip_executed_command(item->label));
|
||||
m_context->executeCommand(item->command, item->params);
|
||||
}
|
||||
|
||||
#ifdef ENABLE_SCRIPTING
|
||||
void executeScript()
|
||||
{
|
||||
closeWindow(nullptr);
|
||||
|
||||
const base::Chrono timer;
|
||||
const std::string& text = search()->text();
|
||||
const bool result = App::instance()->scriptEngine()->evalCode(text.substr(1, text.length()));
|
||||
|
||||
// Give some feedback that the code executed, for errors the console will take care of that.
|
||||
// We use the timer to avoid showing the tip in cases where the command was obviously
|
||||
// successful, like after showing a modal window.
|
||||
if (result && timer.elapsed() < 0.5)
|
||||
StatusBar::instance()->showTip(1000, Strings::run_command_tip_code_executed());
|
||||
}
|
||||
#endif
|
||||
|
||||
void clear() const
|
||||
{
|
||||
// Switch between mode or make modeWidget()->
|
||||
expressionResult()->setVisible(false);
|
||||
help()->setVisible(false);
|
||||
commandList()->setVisible(false);
|
||||
moreResults()->setVisible(false);
|
||||
}
|
||||
|
||||
void setMode(const Mode newMode)
|
||||
{
|
||||
if (m_mode != newMode)
|
||||
clear();
|
||||
|
||||
m_mode = newMode;
|
||||
}
|
||||
|
||||
void onTextChange()
|
||||
{
|
||||
const std::string& text = search()->text();
|
||||
|
||||
switch (text[0]) {
|
||||
#ifdef ENABLE_SCRIPTING
|
||||
case '@':
|
||||
setMode(Mode::Script);
|
||||
setText(Strings::run_command_title_script());
|
||||
// Will only execute when pressing enter.
|
||||
// TODO: Add an engine function to check the syntax before running it with luaL_loadstring
|
||||
// && lua_isfunction
|
||||
break;
|
||||
#endif
|
||||
case '=':
|
||||
setMode(Mode::Expression);
|
||||
setText(Strings::run_command_title_expression());
|
||||
calculateExpression(text.substr(1, text.length()));
|
||||
break;
|
||||
case '?':
|
||||
setMode(Mode::ShowHelp);
|
||||
setText(Strings::run_command_title_help());
|
||||
help()->setVisible(true);
|
||||
break;
|
||||
default:
|
||||
setMode(Mode::Search);
|
||||
setText(Strings::run_command_title());
|
||||
searchCommand(text);
|
||||
break;
|
||||
}
|
||||
|
||||
refit();
|
||||
}
|
||||
|
||||
void searchCommand(const std::string& text)
|
||||
{
|
||||
if (text.empty()) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string& lowerText = base::string_to_lower(text);
|
||||
const MatchWords match(lowerText);
|
||||
std::multimap<double, const RunnerDB::Item*> results;
|
||||
|
||||
for (RunnerDB::Item& item : m_db->items) {
|
||||
if (match(item.searchableText) || match.fuzzyWords(item.labelWords)) {
|
||||
// Crude "score" to affect ordering in the multimap of results.
|
||||
// Lower is better.
|
||||
double score = 0;
|
||||
const std::string& lowerLabel = base::string_to_lower(item.label);
|
||||
if (lowerLabel != lowerText) {
|
||||
// Deprioritize non-exact matches.
|
||||
score = 50;
|
||||
|
||||
// Prioritize the ones starting with what we're typing.
|
||||
if (lowerLabel.find(lowerText) == 0)
|
||||
score -= 10;
|
||||
|
||||
// Simpler commands go first.
|
||||
if (item.params.empty())
|
||||
score -= 10;
|
||||
|
||||
// Prioritize listed commands or ones with keyboard shortcuts
|
||||
if (item.command->isListed(item.params) || item.shortcutString.empty())
|
||||
score -= 5;
|
||||
|
||||
// Bias against longer labels vs our search text
|
||||
if (lowerLabel.size() > lowerText.size())
|
||||
score += std::clamp(static_cast<int>(lowerLabel.size() - lowerText.size()), 1, 10);
|
||||
}
|
||||
|
||||
if (item.command->isPlugin())
|
||||
score += 5;
|
||||
|
||||
// Disabled commands go at the bottom.
|
||||
item.command->loadParams(item.params);
|
||||
if (!item.command->isEnabled(m_context))
|
||||
score += kDisabledScore;
|
||||
|
||||
results.emplace(score, &item);
|
||||
}
|
||||
}
|
||||
|
||||
int resultCount = 0;
|
||||
for (const auto& [score, itemPtr] : results) {
|
||||
auto* view = static_cast<CommandItemView*>(commandList()->at(resultCount));
|
||||
view->setVisible(true);
|
||||
view->setEnabled(score < kDisabledScore);
|
||||
view->setItem(itemPtr);
|
||||
view->setSelected(resultCount == 0);
|
||||
view->setChecked(itemPtr->command->isChecked(m_context));
|
||||
|
||||
resultCount++;
|
||||
|
||||
if (resultCount >= kMaxVisibleResults) {
|
||||
moreCount()->setText(Strings::run_command_more_result_count(results.size() - resultCount));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = resultCount; i <= kMaxVisibleResults; i++)
|
||||
commandList()->at(i)->setVisible(false);
|
||||
|
||||
commandList()->setVisible(resultCount > 0);
|
||||
moreResults()->setVisible(resultCount >= kMaxVisibleResults);
|
||||
|
||||
refit();
|
||||
}
|
||||
|
||||
void calculateExpression(const std::string_view& expression) const
|
||||
{
|
||||
if (expression.empty()) {
|
||||
expressionResult()->setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
int err = 0;
|
||||
double v = te_interp(expression.data(), &err);
|
||||
if (err == 0)
|
||||
expressionResult()->setText(fmt::format(" = {}", v));
|
||||
else
|
||||
expressionResult()->setText(" = NaN");
|
||||
expressionResult()->setVisible(true);
|
||||
}
|
||||
|
||||
RunnerDB* m_db;
|
||||
Context* m_context;
|
||||
Mode m_mode = Mode::Search;
|
||||
};
|
||||
} // Unnamed namespace
|
||||
|
||||
class RunCommandCommand final : public Command {
|
||||
public:
|
||||
RunCommandCommand();
|
||||
|
||||
void invalidateDatabase();
|
||||
|
||||
protected:
|
||||
bool onEnabled(Context* context) override;
|
||||
void onExecute(Context* context) override;
|
||||
|
||||
private:
|
||||
void loadDatabase();
|
||||
|
||||
std::unique_ptr<RunnerDB> m_db;
|
||||
obs::connection m_invalidateConn;
|
||||
};
|
||||
|
||||
RunCommandCommand::RunCommandCommand() : Command(CommandId::RunCommand())
|
||||
{
|
||||
}
|
||||
|
||||
void RunCommandCommand::invalidateDatabase()
|
||||
{
|
||||
m_db.reset(nullptr);
|
||||
}
|
||||
|
||||
bool RunCommandCommand::onEnabled(Context* context)
|
||||
{
|
||||
return context->isUIAvailable();
|
||||
}
|
||||
|
||||
void RunCommandCommand::onExecute(Context* context)
|
||||
{
|
||||
if (!m_db)
|
||||
loadDatabase();
|
||||
|
||||
RunnerWindow window(context, m_db.get());
|
||||
window.centerWindow();
|
||||
fit_bounds(window.display(),
|
||||
&window,
|
||||
gfx::Rect(window.bounds().x, 23 * guiscale(), window.bounds().w, window.bounds().h));
|
||||
window.openWindowInForeground();
|
||||
}
|
||||
|
||||
void RunCommandCommand::loadDatabase()
|
||||
{
|
||||
std::vector<std::string> allCommandIds;
|
||||
Commands::instance()->getAllIds(allCommandIds);
|
||||
|
||||
m_db.reset(new RunnerDB);
|
||||
m_db->items.reserve(allCommandIds.size());
|
||||
|
||||
{
|
||||
std::unordered_map<std::string, RunnerDB::Item> items;
|
||||
for (const KeyPtr& key : *KeyboardShortcuts::instance()) {
|
||||
if (const auto* cmd = key->command()) {
|
||||
std::string paramString;
|
||||
if (!key->params().empty()) {
|
||||
std::ostringstream stream;
|
||||
for (const auto& [k, v] : key->params()) {
|
||||
stream << k << ' ' << v << ' ';
|
||||
}
|
||||
paramString = stream.str();
|
||||
paramString.pop_back(); // Trailing space.
|
||||
}
|
||||
|
||||
std::string shortcutString;
|
||||
if (!key->shortcuts().empty())
|
||||
shortcutString = key->shortcuts()[0].toString();
|
||||
|
||||
// Detect any duplicates, since multiple commands with the same parameter set
|
||||
// can have both custom and default keyboard shortcuts.
|
||||
const std::string itemKey = cmd->id() + paramString;
|
||||
if (items.find(itemKey) != items.end()) {
|
||||
if (!shortcutString.empty())
|
||||
items[itemKey].shortcutString += ", " + shortcutString;
|
||||
|
||||
continue; // Ignore duplicate.
|
||||
}
|
||||
|
||||
// Ideally we'd want to have translatable "tags" of some kind for commands so they're easier
|
||||
// to find, for example if you search "Sprite New" you can't find "New File" (but you can
|
||||
// find New Sprite from Clipboard)
|
||||
std::ostringstream searchableStringStream;
|
||||
searchableStringStream << key->triggerString() << ' ' << cmd->id() << ' ' << shortcutString;
|
||||
|
||||
// Separate the labels into words for fuzzy matching
|
||||
std::vector<std::string> labelWords;
|
||||
base::split_string(base::string_to_lower(key->triggerString()), labelWords, " ");
|
||||
|
||||
const RunnerDB::Item item{
|
||||
key->triggerString(), labelWords, searchableStringStream.str(),
|
||||
shortcutString, key->command(), key->params()
|
||||
};
|
||||
items.try_emplace(itemKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any commands not covered by KeyboardShortcuts
|
||||
for (const std::string& id : allCommandIds) {
|
||||
// Ignore if it's already been added.
|
||||
if (items.find(id) != items.end())
|
||||
continue;
|
||||
|
||||
Command* command = Commands::instance()->byId(id.c_str());
|
||||
|
||||
// We can't run commands that need params without having them.
|
||||
if (command->needsParams())
|
||||
continue;
|
||||
|
||||
std::vector<std::string> labelWords;
|
||||
base::split_string(base::string_to_lower(command->friendlyName()), labelWords, " ");
|
||||
|
||||
const RunnerDB::Item item{ command->friendlyName(),
|
||||
labelWords,
|
||||
command->friendlyName() + " " + command->id(),
|
||||
{},
|
||||
command,
|
||||
{} };
|
||||
items.try_emplace(id, item);
|
||||
}
|
||||
|
||||
// Move from the result map to the item db vector
|
||||
std::transform(
|
||||
std::move_iterator(items.begin()),
|
||||
std::move_iterator(items.end()),
|
||||
std::back_inserter(m_db->items),
|
||||
[](std::pair<std::string, RunnerDB::Item>&& item) { return std::move(item.second); });
|
||||
}
|
||||
|
||||
// Sort alphabetically by label
|
||||
std::sort(m_db->items.begin(),
|
||||
m_db->items.end(),
|
||||
[](const RunnerDB::Item& a, const RunnerDB::Item& b) { return a.label < b.label; });
|
||||
|
||||
if (!m_invalidateConn)
|
||||
m_invalidateConn =
|
||||
AppMenus::instance()->MenusLoaded.connect(&RunCommandCommand::invalidateDatabase, this);
|
||||
}
|
||||
|
||||
Command* CommandFactory::createRunCommandCommand()
|
||||
{
|
||||
return new RunCommandCommand;
|
||||
}
|
||||
|
||||
} // namespace app
|
|
@ -34,6 +34,7 @@ public:
|
|||
|
||||
protected:
|
||||
void onLoadParams(const Params& params) override;
|
||||
bool onNeedsParams() const override { return true; };
|
||||
void onExecute(Context* context) override;
|
||||
std::string onGetFriendlyName() const override;
|
||||
bool isListed(const Params& params) const override { return !params.empty(); }
|
||||
|
|
|
@ -36,6 +36,7 @@ public:
|
|||
// Returns true if the command must be displayed in the Keyboard
|
||||
// Shortcuts list.
|
||||
virtual bool isListed(const Params& params) const { return true; }
|
||||
virtual bool isPlugin() { return false; }
|
||||
|
||||
protected:
|
||||
virtual bool onNeedsParams() const;
|
||||
|
|
|
@ -127,6 +127,7 @@ FOR_EACH_COMMAND(ReplaceColor)
|
|||
FOR_EACH_COMMAND(ReselectMask)
|
||||
FOR_EACH_COMMAND(ReverseFrames)
|
||||
FOR_EACH_COMMAND(Rotate)
|
||||
FOR_EACH_COMMAND(RunCommand)
|
||||
FOR_EACH_COMMAND(SaveFile)
|
||||
FOR_EACH_COMMAND(SaveFileAs)
|
||||
FOR_EACH_COMMAND(SaveFileCopyAs)
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
#include "base/split_string.h"
|
||||
#include "base/string.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
@ -22,11 +23,40 @@ public:
|
|||
MatchWords(const std::string& search = {})
|
||||
{
|
||||
base::split_string(base::string_to_lower(search), m_parts, " ");
|
||||
if (m_parts.size() > 1 && m_parts.back().empty())
|
||||
m_parts.pop_back(); // Avoid an empty part when the search text is something like "hello "
|
||||
}
|
||||
|
||||
// Finds a match with the given word using Levenshtein distance
|
||||
// Expects lower case input, unlike operator()
|
||||
bool fuzzy(const std::string& word, size_t threshold = 3) const
|
||||
{
|
||||
std::size_t matches = 0;
|
||||
for (const auto& part : m_parts) {
|
||||
if (levenshteinDistance(part, word) < threshold)
|
||||
++matches;
|
||||
}
|
||||
return (matches == m_parts.size());
|
||||
}
|
||||
|
||||
// Finds a match with any of the given words using Levenshtein distance
|
||||
// Expects lower case input, unlike operator()
|
||||
bool fuzzyWords(const std::vector<std::string>& words, size_t threshold = 3) const
|
||||
{
|
||||
std::size_t matches = 0;
|
||||
for (const auto& part : m_parts) {
|
||||
for (const auto& word : words) {
|
||||
if (levenshteinDistance(part, word) < threshold)
|
||||
++matches;
|
||||
}
|
||||
}
|
||||
|
||||
return (matches == m_parts.size());
|
||||
}
|
||||
|
||||
bool operator()(const std::string& item) const
|
||||
{
|
||||
std::string lowerItem = base::string_to_lower(item);
|
||||
const std::string& lowerItem = base::string_to_lower(item);
|
||||
std::size_t matches = 0;
|
||||
|
||||
for (const auto& part : m_parts) {
|
||||
|
@ -37,6 +67,33 @@ public:
|
|||
return (matches == m_parts.size());
|
||||
}
|
||||
|
||||
static size_t levenshteinDistance(const std::string_view& word1, const std::string_view& word2)
|
||||
{
|
||||
if (word1 == word2)
|
||||
return 0;
|
||||
|
||||
std::vector distance1(word2.size() + 1, 0);
|
||||
std::vector distance2(word2.size() + 1, 0);
|
||||
|
||||
for (int i = 0; i <= word2.size(); ++i)
|
||||
distance1[i] = i;
|
||||
|
||||
for (int i = 0; i < word1.size(); ++i) {
|
||||
distance2[0] = i + 1;
|
||||
|
||||
for (int j = 0; j < word2.size(); ++j) {
|
||||
const int deletionCost = distance1[j + 1] + 1;
|
||||
const int insertionCost = distance2[j] + 1;
|
||||
const int substitutionCost = (word1[i] == word2[j]) ? distance1[j] : distance1[j] + 1;
|
||||
distance2[j + 1] = std::min({ deletionCost, insertionCost, substitutionCost });
|
||||
}
|
||||
|
||||
std::swap(distance1, distance2);
|
||||
}
|
||||
|
||||
return distance1[word2.size()];
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::string> m_parts;
|
||||
};
|
||||
|
|
|
@ -55,6 +55,8 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
bool isPlugin() override { return true; }
|
||||
|
||||
protected:
|
||||
std::string onGetFriendlyName() const override { return m_title; }
|
||||
|
||||
|
|
|
@ -201,10 +201,10 @@ private:
|
|||
KeyContext m_keycontext;
|
||||
|
||||
// for KeyType::Command
|
||||
Command* m_command;
|
||||
Command* m_command = nullptr;
|
||||
Params m_params;
|
||||
|
||||
tools::Tool* m_tool; // for KeyType::Tool or Quicktool
|
||||
tools::Tool* m_tool = nullptr; // for KeyType::Tool or Quicktool
|
||||
KeyAction m_action; // for KeyType::Action
|
||||
WheelAction m_wheelAction; // for KeyType::WheelAction / DragAction
|
||||
DragVector m_dragVector; // for KeyType::DragAction
|
||||
|
|
|
@ -1599,6 +1599,10 @@ void Widget::processMnemonicFromText(const int escapeChar, const bool requireMod
|
|||
if (!chr) {
|
||||
break; // Ill-formed string (it ends with escape character)
|
||||
}
|
||||
if (std::isspace(chr)) {
|
||||
// Avoid mnemonics for space characters.
|
||||
newText.push_back(escapeChar);
|
||||
}
|
||||
else if (chr != escapeChar) {
|
||||
setMnemonic(chr, requireModifiers);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue