Skip to content
Merged
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,42 @@ class Query(Method):
plugin.add_method(Query())
plugin.run()
```

### Context menus

`Plugin` ships with a built-in `context_menu` method: put `Result` objects in a
result's `context_data` and they become that result's context menu — no handler
code required.

```py
from pyflowlauncher import Plugin, Result

plugin = Plugin()


@plugin.on_method
def query(query: str):
yield Result(
title="This is a title!",
subtitle="Right-click me for a context menu.",
context_data=[
Result(title="This is a context menu item!"),
Result(title="So is this!"),
],
)


plugin.run()
```

Menu items are full `Result` objects, so they can carry actions via
`add_action()` just like query results.

If you need more control (for example, building the menu lazily when it is
opened), register your own `context_menu` — it replaces the built-in one:

```py
@plugin.on_method
def context_menu(context_data):
yield Result(title=f"Menu for {context_data[0]}")
```
18 changes: 18 additions & 0 deletions docs/examples/guide/plugin_methods/example3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pyflowlauncher import Plugin, Result

plugin = Plugin()


@plugin.on_method
def query(query: str):
yield Result(
title="This is a title!",
subtitle="Right-click me for a context menu.",
context_data=[
Result(title="This is a context menu item!"),
Result(title="So is this!"),
],
)


plugin.run()
2 changes: 2 additions & 0 deletions pyflowlauncher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .plugin import Plugin
from .result import Result, send_results
from .method import Method
from .command import Command
from .response import handle_response
from .launcher import FlowLauncherV1, FlowLauncherV2

Expand All @@ -15,6 +16,7 @@
"send_results",
"Result",
"Method",
"Command",
"handle_response",
"FlowLauncherV1",
"FlowLauncherV2",
Expand Down
172 changes: 96 additions & 76 deletions pyflowlauncher/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,112 +2,99 @@

from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional

from .models.json_rpc import JsonRPCRequest
from .command import Command

if TYPE_CHECKING:
from .string_matcher import MatchData

NAME_SPACE = 'Flow.Launcher'


def _send_action(method: str, *parameters) -> JsonRPCRequest:
return {"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)}
class NotSupportedError(Exception):
"""Raised when an operation is not supported by the active protocol version."""


def change_query(query: str, requery: bool = False) -> JsonRPCRequest:
"""Change the query in Flow Launcher."""
return _send_action("ChangeQuery", query, requery)
def _send_action(method: str, *parameters) -> Command:
return Command({"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)})


def shell_run(command: str, filename: str = 'cmd.exe') -> JsonRPCRequest:
"""Run a shell command."""
return _send_action("ShellRun", command, filename)


def close_app() -> JsonRPCRequest:
"""Close Flow Launcher."""
return _send_action("CloseApp")


def hide_app() -> JsonRPCRequest:
"""Hide Flow Launcher."""
return _send_action("HideApp")


def show_app() -> JsonRPCRequest:
"""Show Flow Launcher."""
return _send_action("ShowApp")


def show_msg(title: str, sub_title: str, ico_path: str = "") -> JsonRPCRequest:
"""Show a message in Flow Launcher."""
return _send_action("ShowMsg", title, sub_title, ico_path)


def open_setting_dialog() -> JsonRPCRequest:
"""Open the settings window in Flow Launcher."""
return _send_action("OpenSettingDialog")
_FuzzySearchFn = Callable[[str, str], Coroutine[Any, Any, "MatchData"]]


def start_loading_bar() -> JsonRPCRequest:
"""Start the loading bar in Flow Launcher."""
return _send_action("StartLoadingBar")
class Api:
"""Flow Launcher API, accessible via ``plugin.launcher.api``.

Builder methods return inert ``Command`` values; ``fuzzy_search`` and
``invoke`` are the launcher-bound calls.
"""

def stop_loading_bar() -> JsonRPCRequest:
"""Stop the loading bar in Flow Launcher."""
return _send_action("StopLoadingBar")
def __init__(
self,
fuzzy_search_fn: Optional[_FuzzySearchFn] = None,
invoke_fn: Optional[Callable[["Command"], Coroutine[Any, Any, Any]]] = None,
) -> None:
self._fuzzy_search_fn = fuzzy_search_fn
self._invoke_fn = invoke_fn

def change_query(self, query: str, requery: bool = False) -> Command:
"""Change the query in Flow Launcher."""
return _send_action("ChangeQuery", query, requery)

def reload_plugins() -> JsonRPCRequest:
"""Reload the plugins in Flow Launcher."""
return _send_action("ReloadPlugins")
def shell_run(self, command: str, filename: str = 'cmd.exe') -> Command:
"""Run a shell command."""
return _send_action("ShellRun", command, filename)

def close_app(self) -> Command:
"""Close Flow Launcher."""
return _send_action("CloseApp")

def copy_to_clipboard(text: str, direct_copy: bool = False, show_default_notification=True) -> JsonRPCRequest:
"""Copy text to the clipboard."""
return _send_action("CopyToClipboard", text, direct_copy, show_default_notification)
def hide_app(self) -> Command:
"""Hide Flow Launcher."""
return _send_action("HideApp")

def show_app(self) -> Command:
"""Show Flow Launcher."""
return _send_action("ShowApp")

def open_directory(directory_path: str, filename_or_filepath: Optional[str] = None) -> JsonRPCRequest:
"""Open a directory."""
return _send_action("OpenDirectory", directory_path, filename_or_filepath)
def show_msg(self, title: str, sub_title: str, ico_path: str = "") -> Command:
"""Show a message in Flow Launcher."""
return _send_action("ShowMsg", title, sub_title, ico_path)

def open_setting_dialog(self) -> Command:
"""Open the settings window in Flow Launcher."""
return _send_action("OpenSettingDialog")

def open_url(url: str, in_private: bool = False) -> JsonRPCRequest:
"""Open a URL."""
return _send_action("OpenUrl", url, in_private)
def start_loading_bar(self) -> Command:
"""Start the loading bar in Flow Launcher."""
return _send_action("StartLoadingBar")

def stop_loading_bar(self) -> Command:
"""Stop the loading bar in Flow Launcher."""
return _send_action("StopLoadingBar")

def open_uri(uri: str) -> JsonRPCRequest:
"""Open a URI."""
return _send_action("OpenAppUri", uri)
def reload_plugins(self) -> Command:
"""Reload the plugins in Flow Launcher."""
return _send_action("ReloadPlugins")

def copy_to_clipboard(self, text: str, direct_copy: bool = False,
show_default_notification: bool = True) -> Command:
"""Copy text to the clipboard."""
return _send_action("CopyToClipboard", text, direct_copy, show_default_notification)

_FuzzySearchFn = Callable[[str, str], Coroutine[Any, Any, "MatchData"]]
def open_directory(self, directory_path: str,
filename_or_filepath: Optional[str] = None) -> Command:
"""Open a directory."""
return _send_action("OpenDirectory", directory_path, filename_or_filepath)

def open_url(self, url: str, in_private: bool = False) -> Command:
"""Open a URL."""
return _send_action("OpenUrl", url, in_private)

class Api:
"""Flow Launcher API calls, accessible via plugin.launcher.api."""
change_query = staticmethod(change_query)
shell_run = staticmethod(shell_run)
close_app = staticmethod(close_app)
hide_app = staticmethod(hide_app)
show_app = staticmethod(show_app)
show_msg = staticmethod(show_msg)
open_setting_dialog = staticmethod(open_setting_dialog)
start_loading_bar = staticmethod(start_loading_bar)
stop_loading_bar = staticmethod(stop_loading_bar)
reload_plugins = staticmethod(reload_plugins)
copy_to_clipboard = staticmethod(copy_to_clipboard)
open_directory = staticmethod(open_directory)
open_url = staticmethod(open_url)
open_uri = staticmethod(open_uri)

def __init__(self, fuzzy_search_fn: Optional[_FuzzySearchFn] = None) -> None:
self._fuzzy_search_fn = fuzzy_search_fn
def open_uri(self, uri: str) -> Command:
"""Open a URI."""
return _send_action("OpenAppUri", uri)

async def fuzzy_search(self, query: str, text: str) -> MatchData:
async def fuzzy_search(self, query: str, text: str) -> "MatchData":
"""Match query against text.

On V2 delegates to Flow Launcher's own FuzzySearch over JSON-RPC so
Expand All @@ -121,3 +108,36 @@ async def fuzzy_search(self, query: str, text: str) -> MatchData:
"plugin.launcher.api instead of constructing Api() directly."
)
return await self._fuzzy_search_fn(query, text)

async def invoke(self, command: Command) -> Any:
"""Send a command to Flow Launcher now and return its response.

Supported on Flow Launcher V2 only; on V1 the launcher-provided
``invoke_fn`` raises ``NotSupportedError``.
"""
if self._invoke_fn is None:
raise RuntimeError(
"invoke is unavailable: this Api was created without an "
"invoke_fn. Use the launcher-provided instance via "
"plugin.launcher.api instead of constructing Api() directly."
)
return await self._invoke_fn(command)


# Backwards-compatible module-level builders (pyflowlauncher.api.change_query, ...).
# Bound to a default Api instance; builders are pure and ignore instance state.
_default_api = Api()
change_query = _default_api.change_query
shell_run = _default_api.shell_run
close_app = _default_api.close_app
hide_app = _default_api.hide_app
show_app = _default_api.show_app
show_msg = _default_api.show_msg
open_setting_dialog = _default_api.open_setting_dialog
start_loading_bar = _default_api.start_loading_bar
stop_loading_bar = _default_api.stop_loading_bar
reload_plugins = _default_api.reload_plugins
copy_to_clipboard = _default_api.copy_to_clipboard
open_directory = _default_api.open_directory
open_url = _default_api.open_url
open_uri = _default_api.open_uri
11 changes: 11 additions & 0 deletions pyflowlauncher/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations


class Command(dict):
"""An inert Flow Launcher action (Method + Parameters).

Built by ``pyflowlauncher.api`` builders. Subclasses ``dict`` so it
serializes directly to the JsonRPCAction shape and compares equal to the
plain dict, while remaining distinguishable via ``isinstance`` for
``Result.add_action`` and the response collectors.
"""
7 changes: 7 additions & 0 deletions pyflowlauncher/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
from typing import Any, Callable, Iterable, Type, Union

from .command import Command
from .result import Result, send_results
from .response import _collect_item

Expand Down Expand Up @@ -45,8 +46,14 @@ async def _await_maybe(self, result: Any) -> Any:
return await self._await_maybe(await result)
if inspect.isasyncgen(result):
results = []
command = None
async for item in result:
if isinstance(item, Command):
command = item
continue
results.extend(_collect_item(item))
if command is not None:
return command
return send_results(results)
if isinstance(result, Result):
return send_results([result])
Expand Down
11 changes: 9 additions & 2 deletions pyflowlauncher/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
_logger = logging.getLogger(__name__)


def _json_default(o: Any) -> Any:
to_json = getattr(o, 'to_json', None)
if callable(to_json):
return to_json()
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")


class JsonRPCRequest(TypedDict):
method: str
parameters: list
Expand All @@ -23,7 +30,7 @@ class JsonRPCRequest(TypedDict):
class JsonRPCClient:

def send(self, data: Mapping) -> None:
json.dump(data, sys.stdout)
json.dump(data, sys.stdout, default=_json_default)

def recieve(self) -> JsonRPCRequest:
try:
Expand Down Expand Up @@ -98,5 +105,5 @@ async def request(
self._pending.pop(req_id, None)

def send(self, data: dict) -> None:
sys.stdout.write(json.dumps(data) + '\n')
sys.stdout.write(json.dumps(data, default=_json_default) + '\n')
sys.stdout.flush()
Loading
Loading