From eec02a1642517fb74d0320f94489d16a546c62bb Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Thu, 20 Aug 2026 18:57:24 +0100 Subject: [PATCH] ADFA-4432 | Add tooltips for the Python/Flask plugin commands python-tools now implements DocumentationExtension with tier 1/2/3 docs for its four build actions, and binds the long-press on its own toolbar buttons so the tooltip shows without a host change. --- python-tools/README.md | 15 +++ .../src/main/assets/docs/css/walkthrough.css | 70 +++++++++++ python-tools/src/main/assets/docs/index.html | 96 ++++++++++++++ .../python/plugin/PythonToolsDocumentation.kt | 85 +++++++++++++ .../python/plugin/PythonToolsPlugin.kt | 118 +++++++++++++++--- 5 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 python-tools/src/main/assets/docs/css/walkthrough.css create mode 100644 python-tools/src/main/assets/docs/index.html create mode 100644 python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsDocumentation.kt diff --git a/python-tools/README.md b/python-tools/README.md index d186c65f..6098bcdc 100644 --- a/python-tools/README.md +++ b/python-tools/README.md @@ -13,6 +13,21 @@ Two Python project templates: It also installs Python on-device (via Termux) the first time it is needed, and provides built-in run, install-dependencies, and test actions for Python projects. +## In-app help + +Long-pressing a Python command in the editor toolbar shows its documentation, shipped with the +plugin and written into the IDE's `documentation.db` at install time. The plugin binds the +long-press on its own toolbar buttons, so no host change is needed: + +- **Tier 1** - one-line summary of what the command runs. +- **Tier 2** - "See more" detail: entry-point order, dependency auto-install, timeouts. +- **Tier 3** - `src/main/assets/docs/index.html`, served offline in the IDE's help viewer. + +Tooltip tags are `.` (for example +`com.appdevforall.python.plugin.python.run.app`) under the category +`plugin_com.appdevforall.python.plugin`, which is what the IDE derives when it resolves a tooltip +for a plugin-contributed action. + ## Disclaimer Only light testing has been done; use at your own risk. Customer support cannot provide help with this plugin. diff --git a/python-tools/src/main/assets/docs/css/walkthrough.css b/python-tools/src/main/assets/docs/css/walkthrough.css new file mode 100644 index 00000000..ec3bcf7a --- /dev/null +++ b/python-tools/src/main/assets/docs/css/walkthrough.css @@ -0,0 +1,70 @@ +/* Tier 3 walkthrough styles - kept tiny so the doc loads instantly even + * on slow devices. Dark-mode friendly via CSS color-scheme + media + * query. No external resources / fonts. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1b1b1f; + --muted: #5e5e6c; + --accent: #485d92; + --code-bg: #f5f6fa; + --code-fg: #1b1b1f; + --border: #e3e3ea; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121215; + --fg: #e6e1e5; + --muted: #b8b8c0; + --accent: #b1c5ff; + --code-bg: #1a1a1f; + --code-fg: #e6e1e5; + --border: #303038; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.55; + padding: 24px 20px 64px; + max-width: 760px; + margin: 0 auto; +} +h1 { font-size: 1.6rem; margin: 0 0 4px; } +h2 { font-size: 1.2rem; margin: 32px 0 8px; color: var(--accent); } +h3 { font-size: 1.05rem; margin: 24px 0 6px; } +p, li { font-size: 0.95rem; } +.lede { color: var(--muted); margin: 0 0 24px; } +a { color: var(--accent); } +ul, ol { padding-left: 20px; } +hr { border: 0; border-top: 1px solid var(--border); margin: 24px 0; } + +pre { + background: var(--code-bg); + color: var(--code-fg); + padding: 12px 14px; + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 0.85rem; + line-height: 1.45; + font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; +} +code { font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; font-size: 0.9em; } +.callout { + background: rgba(72, 93, 146, 0.08); + border-left: 3px solid var(--accent); + padding: 10px 14px; + margin: 16px 0; + border-radius: 4px; +} +.muted { color: var(--muted); font-size: 0.85rem; } +table { border-collapse: collapse; width: 100%; margin: 12px 0; } +th, td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; font-size: 0.9rem; } +th { color: var(--muted); font-weight: 600; } diff --git a/python-tools/src/main/assets/docs/index.html b/python-tools/src/main/assets/docs/index.html new file mode 100644 index 00000000..cf9570a3 --- /dev/null +++ b/python-tools/src/main/assets/docs/index.html @@ -0,0 +1,96 @@ + + + + + +Python Tools - Commands + + + + +

Python Tools

+

Run Python and Flask projects on the device, straight from the editor toolbar.

+ +

Python Tools adds two project templates and four toolbar commands. The commands appear only +while a Python project is open; in a Java, Kotlin, or Android project the toolbar is left alone. +Because Gradle plays no part in a Python project, the Gradle actions (Quick Run, Sync, Debug, Run +Tasks, Launch App) are hidden while you work in one.

+ +
+

A project counts as Python when its root holds a Python entry point (app.py, +main.py, manage.py, __main__.py, or wsgi.py), a +requirements.txt, or any .py file.

+
+ +

Getting Python

+

The first time the plugin activates it checks for a Python interpreter and, when none is found, +installs one with the bundled Termux package manager (pkg install python). That +download runs in the background and reports its result as a message; give it a few minutes on a +fresh device before running anything.

+ +

The commands

+ + + + + + +
CommandWhat it runsStopped after
Run appthe project entry point30 minutes
Run current filethe open .py file30 minutes
Install requirementspip install -r requirements.txt5 minutes
Run testspython -m pytest -q10 minutes
+

Every command streams its output into the Build Output panel at the bottom of the editor, +and the panel opens itself when a command starts. While a command is running its toolbar button +turns into a Cancel button: tap it to stop the process.

+ +

Run app

+

Looks for an entry point in the project root and runs the first one it finds:

+
    +
  1. app.py - a Flask app
  2. +
  3. main.py - a plain Python project
  4. +
  5. manage.py - started with runserver
  6. +
  7. __main__.py
  8. +
  9. wsgi.py
  10. +
+

When none of those exist the command says so and stops, rather than guessing.

+

Output is unbuffered, so print() lines and a Flask request log appear as they +happen instead of arriving in a block at the end. A Flask app keeps running until you cancel it; +open the port it prints (5000 by default) in a browser on the device to use the app.

+

If the run fails because a module is missing and the project has a requirements.txt, +the plugin installs the dependencies for you, then asks you to tap Run again.

+ +

Run current file

+

Appears only while a .py file is open, and runs exactly that file by its full path. +Use it for a script that is not the project entry point - a data fixture, a one-off check, a +scratch file.

+

Missing dependencies are installed from requirements.txt the same way as Run app.

+ +

Install requirements

+

Runs pip install -r requirements.txt in the project root and streams pip's output +into Build Output.

+

The same install runs on its own when a run fails because a module is missing, so reach for this +button after you edit requirements.txt yourself. When the file is absent, a failed run +says so instead of installing anything.

+ +

Run tests

+

Runs python -m pytest -q in the project root. When pytest is not installed yet it +is fetched with pip before the tests start, so the first run takes longer than later ones.

+ +

The project templates

+

Both templates appear on the New Project screen beside the built-in ones:

+
    +
  • Python Flask App - routes, HTML templates, static CSS, a config file, and a 404 page. + The port is a template parameter and defaults to 5000.
  • +
  • Python Starter - a single main.py entry point, a + requirements.txt, and a .gitignore.
  • +
+ +

Stopping a runaway process

+

A Python process started by the plugin is tied to the IDE: if the IDE is killed, the child +process is killed with it, so a forgotten Flask server cannot keep holding a port after the IDE is +gone. Each command also has its own time limit, listed in the table above.

+ +

Turning it off

+

Disable Python Tools in the Plugin Manager. The Python commands disappear, the Gradle actions +come back for every project, and the two templates are removed from the New Project screen. Any +Python packages already installed on the device stay where they are.

+ + + diff --git a/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsDocumentation.kt b/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsDocumentation.kt new file mode 100644 index 00000000..c0da3390 --- /dev/null +++ b/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsDocumentation.kt @@ -0,0 +1,85 @@ +package com.appdevforall.python.plugin + +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry + +internal object PythonToolsDocumentation { + + const val CATEGORY = "plugin_${PythonToolsPlugin.PLUGIN_ID}" + const val DOCS_ASSET_PATH = "docs" + + fun entries(): List = listOf( + PluginTooltipEntry( + tag = tagFor(PythonToolsPlugin.ACTION_RUN_APP), + summary = "Runs the project. For a plain Python project main.py runs; " + + "for a Flask project app.py runs.", + detail = """ +

Looks for an entry point in the project root and runs the first one it finds:

+
    +
  1. app.py - a Flask app
  2. +
  3. main.py - a plain Python project
  4. +
  5. manage.py - started with runserver
  6. +
  7. __main__.py
  8. +
  9. wsgi.py
  10. +
+

Output streams unbuffered into Build Output. While the process is alive the + toolbar button becomes Cancel Run app; tap it to stop the process.

+

If the run fails because a module is missing and the project has a + requirements.txt, the dependencies are installed for you and you are asked + to tap Run again. A run is stopped after 30 minutes.

+ """.trimIndent(), + buttons = buttons("run-app"), + ), + PluginTooltipEntry( + tag = tagFor(PythonToolsPlugin.ACTION_RUN_CURRENT_FILE), + summary = "Runs the Python file open in the editor, leaving the project entry point alone.", + detail = """ +

Appears only while a .py file is open, and runs exactly that file by its + full path - useful for a script that is not the project entry point.

+

Output streams into Build Output and the button becomes + Cancel Run current file while the file runs. Missing dependencies are installed + from requirements.txt just as they are for Run app. A run is stopped after + 30 minutes.

+ """.trimIndent(), + buttons = buttons("run-current-file"), + ), + PluginTooltipEntry( + tag = tagFor(PythonToolsPlugin.ACTION_SYNC_DEPS), + summary = "Downloads and installs dependencies from requirements.txt (if present).", + detail = """ +

Runs pip install -r requirements.txt in the project root and streams + pip's output into Build Output.

+

The same install runs on its own when a run fails because a module is missing, so + reach for this button after you edit requirements.txt yourself. The install + is stopped after 5 minutes.

+ """.trimIndent(), + buttons = buttons("install-requirements"), + ), + PluginTooltipEntry( + tag = tagFor(PythonToolsPlugin.ACTION_TEST), + summary = "Runs the test suite with pytest, installing pytest first if it is missing.", + detail = """ +

Runs python -m pytest -q in the project root. When pytest is not + installed yet it is fetched with pip before the tests start.

+

Results stream into Build Output. The run is stopped after 10 minutes.

+ """.trimIndent(), + buttons = buttons("run-tests"), + ), + ) + + fun tagFor(actionId: String): String = "${PythonToolsPlugin.PLUGIN_ID}.$actionId" + + private fun buttons(anchor: String): List = listOf( + PluginTooltipButton( + description = "How this command works", + uri = "index.html#$anchor", + order = 0, + ), + PluginTooltipButton( + description = "About Code On The Go plugins", + uri = "i/plugins-adfa.html", + order = 1, + directPath = true, + ), + ) +} diff --git a/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt b/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt index 36fc1d4a..3b401d48 100644 --- a/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt +++ b/python-tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt @@ -1,6 +1,9 @@ package com.appdevforall.python.plugin import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton import android.widget.Toast import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext @@ -8,12 +11,15 @@ import com.itsaky.androidide.plugins.extensions.BuildActionCategory import com.itsaky.androidide.plugins.extensions.BuildActionExtension import com.itsaky.androidide.plugins.extensions.CommandResult import com.itsaky.androidide.plugins.extensions.CommandSpec +import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginBuildAction +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.extensions.ToolbarActionIds import com.itsaky.androidide.plugins.services.IdeCommandService import com.itsaky.androidide.plugins.services.IdeEditorService import com.itsaky.androidide.plugins.services.IdeProjectService import com.itsaky.androidide.plugins.services.IdeTemplateService +import com.itsaky.androidide.plugins.services.IdeTooltipService import com.itsaky.androidide.plugins.services.IdeUIService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,6 +28,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.io.File +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean /** * Python Tools plugin. @@ -36,7 +44,7 @@ import java.io.File * plugin must not steal the toolbar from Java/Kotlin/Android projects, and it must be usable on a * fresh device without Python pre-installed. */ -class PythonToolsPlugin : IPlugin, BuildActionExtension { +class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension { private var pluginContext: PluginContext? = null private var templateService: IdeTemplateService? = null @@ -44,9 +52,12 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { private var editorService: IdeEditorService? = null private var commandService: IdeCommandService? = null private var uiService: IdeUIService? = null + private var tooltipService: IdeTooltipService? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var installJob: Job? = null + private var toolbarContainer: WeakReference? = null + private val tooltipBindingScheduled = AtomicBoolean(false) override fun initialize(context: PluginContext): Boolean { pluginContext = context @@ -55,6 +66,7 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { editorService = context.services.get(IdeEditorService::class.java) commandService = context.services.get(IdeCommandService::class.java) uiService = context.services.get(IdeUIService::class.java) + tooltipService = context.services.get(IdeTooltipService::class.java) Log.i(TAG, "Python Tools initialized") return true } @@ -85,20 +97,25 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { editorService = null commandService = null uiService = null + tooltipService = null } // region Build toolbar (Python domain only) - override fun toolbarActionsToHide(): Set = - if (isPythonProjectOpen()) ToolbarActionIds.BUILD_HIDEABLE else emptySet() + override fun toolbarActionsToHide(): Set { + if (!isPythonProjectOpen()) return emptySet() + scheduleTooltipBinding() + return ToolbarActionIds.BUILD_HIDEABLE + } override fun getBuildActions(): List { if (!isPythonProjectOpen()) return emptyList() + scheduleTooltipBinding() val actions = mutableListOf( PluginBuildAction( - id = "python.run.app", - name = "Run app", + id = ACTION_RUN_APP, + name = ACTION_LABELS.getValue(ACTION_RUN_APP), description = "Run the Python app (app.py, main.py, manage.py, __main__.py, or wsgi.py)", icon = R.drawable.ic_run_server, category = BuildActionCategory.BUILD, @@ -119,8 +136,8 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { if (current != null && current.name.endsWith(".py")) { actions.add( PluginBuildAction( - id = "python.run.currentFile", - name = "Run current file", + id = ACTION_RUN_CURRENT_FILE, + name = ACTION_LABELS.getValue(ACTION_RUN_CURRENT_FILE), description = "Run ${current.name}", icon = R.drawable.ic_run_python, category = BuildActionCategory.BUILD, @@ -132,8 +149,8 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { actions.add( PluginBuildAction( - id = "python.sync.deps", - name = "Install requirements", + id = ACTION_SYNC_DEPS, + name = ACTION_LABELS.getValue(ACTION_SYNC_DEPS), description = "Install dependencies from requirements.txt", icon = R.drawable.ic_sync_deps, category = BuildActionCategory.BUILD, @@ -143,8 +160,8 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { ) actions.add( PluginBuildAction( - id = "python.test", - name = "Run tests", + id = ACTION_TEST, + name = ACTION_LABELS.getValue(ACTION_TEST), description = "Run the test suite with pytest", icon = R.drawable.ic_run_tests, category = BuildActionCategory.TEST, @@ -179,16 +196,10 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { } } - private fun actionLabel(actionId: String): String = when (actionId) { - "python.run.app" -> "Run app" - "python.run.currentFile" -> "Run current file" - "python.sync.deps" -> "Install requirements" - "python.test" -> "Run tests" - else -> actionId - } + private fun actionLabel(actionId: String): String = ACTION_LABELS[actionId] ?: actionId private fun isRunAction(actionId: String): Boolean = - actionId == "python.run.app" || actionId == "python.run.currentFile" + actionId == ACTION_RUN_APP || actionId == ACTION_RUN_CURRENT_FILE private fun isMissingDependencyFailure(result: CommandResult.Failure): Boolean { val output = result.stderr + "\n" + result.stdout @@ -290,6 +301,58 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { // endregion + override fun getTooltipCategory(): String = PythonToolsDocumentation.CATEGORY + + override fun getTooltipEntries(): List = PythonToolsDocumentation.entries() + + override fun getTier3DocsAssetPath(): String = PythonToolsDocumentation.DOCS_ASSET_PATH + + private fun scheduleTooltipBinding() { + if (tooltipService == null) return + val activity = uiService?.takeIf { it.isUIAvailable() }?.getCurrentActivity() ?: return + if (!tooltipBindingScheduled.compareAndSet(false, true)) return + activity.runOnUiThread { + val decor = activity.window?.decorView + if (decor == null) { + tooltipBindingScheduled.set(false) + return@runOnUiThread + } + decor.post { + tooltipBindingScheduled.set(false) + bindActionTooltips(decor) + } + } + } + + private fun bindActionTooltips(decor: View) { + val tooltips = tooltipService ?: return + val container = toolbarContainer?.get() + if (container != null && container.isAttachedToWindow && bindTooltipsIn(container, tooltips)) return + (decor as? ViewGroup)?.let { bindTooltipsIn(it, tooltips) } + } + + private fun bindTooltipsIn(group: ViewGroup, tooltips: IdeTooltipService): Boolean { + var bound = false + for (index in 0 until group.childCount) { + when (val child = group.getChildAt(index)) { + is ImageButton -> if (bindTooltip(child, tooltips)) bound = true + is ViewGroup -> if (bindTooltipsIn(child, tooltips)) bound = true + } + } + return bound + } + + private fun bindTooltip(button: ImageButton, tooltips: IdeTooltipService): Boolean { + val actionId = TOOLTIP_TARGETS[button.contentDescription?.toString()] ?: return false + val tag = PythonToolsDocumentation.tagFor(actionId) + button.setOnLongClickListener { view -> + tooltips.showTooltip(view, PythonToolsDocumentation.CATEGORY, tag) + true + } + (button.parent as? ViewGroup)?.let { toolbarContainer = WeakReference(it) } + return true + } + // region Python interpreter bootstrap private suspend fun ensurePython() { @@ -344,6 +407,23 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension { companion object { private const val TAG = "PythonToolsPlugin" + + internal const val PLUGIN_ID = "com.appdevforall.python.plugin" + internal const val ACTION_RUN_APP = "python.run.app" + internal const val ACTION_RUN_CURRENT_FILE = "python.run.currentFile" + internal const val ACTION_SYNC_DEPS = "python.sync.deps" + internal const val ACTION_TEST = "python.test" + + internal val ACTION_LABELS: Map = mapOf( + ACTION_RUN_APP to "Run app", + ACTION_RUN_CURRENT_FILE to "Run current file", + ACTION_SYNC_DEPS to "Install requirements", + ACTION_TEST to "Run tests", + ) + + private val TOOLTIP_TARGETS: Map = + ACTION_LABELS.entries.flatMap { (id, label) -> listOf(label to id, "Cancel " + label to id) }.toMap() + private const val FLASK_CGT = "PythonFlaskApp.cgt" private const val STARTER_CGT = "PythonStarter.cgt"