use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::path::PathBuf;
pub fn run(name: &str, path: Option<&str>) -> Result<()> {
let dir = PathBuf::from(path.unwrap_or(name));
let pkg_name = dir
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!(
"Could not determine package name from `{}`", dir.display()
))?
.to_string();
if pkg_name.is_empty() || pkg_name.starts_with(|c: char| !c.is_alphabetic() && c != '_') {
bail!("Invalid package name `{}`. Must start with a letter or underscore.", pkg_name);
}
if dir.exists() {
bail!("Directory `{}` already exists.", dir.display());
}
println!("{} Scaffolding `{}`...", "->".cyan(), pkg_name.bold());
create_dirs(&dir)?;
write_cargo_toml(&dir, &pkg_name)?;
write_lib(&dir)?;
write_screens(&dir)?;
write_assets(&dir)?;
write_manifest(&dir, &pkg_name)?;
write_java_bridge(&dir)?;
write_build_dex(&dir)?;
write_gitignore(&dir)?;
println!("\n{} Created `{}` successfully!\n", "ok".green().bold(), pkg_name.bold());
println!(" {} cd {}", "->".dimmed(), dir.display());
println!(" {} cargo bubba doctor", "->".dimmed());
println!(" {} cargo bubba build --target armv7-linux-androideabi", "->".dimmed());
println!(" {} bash android/build_dex.sh && adb install dist/app-debug.apk\n", "->".dimmed());
Ok(())
}
fn create_dirs(base: &PathBuf) -> Result<()> {
for sub in &["src/screens", "assets", "android/app/src/main", "android/java/rs/bubba"] {
std::fs::create_dir_all(base.join(sub))
.with_context(|| format!("Failed to create {}", base.join(sub).display()))?;
}
Ok(())
}
fn write_cargo_toml(base: &PathBuf, name: &str) -> Result<()> {
let content = format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[lib]\nname = \"app\"\ncrate-type = [\"cdylib\"]\npath = \"src/lib.rs\"\n\n\
[dependencies]\nbubba-core = \"0.2\"\nenv_logger = \"0.11\"\njni = \"0.21\"\n\n\
[profile.release]\nopt-level = \"z\"\nlto = true\ncodegen-units = 1\npanic = \"abort\"\nstrip = true\n",
name = name
);
write(base.join("Cargo.toml"), &content)
}
fn write_lib(base: &PathBuf) -> Result<()> {
let content = concat!(
"#![allow(non_snake_case)]\n\n",
"mod screens;\n",
"use bubba_core::runtime::Runtime;\n",
"use screens::home::Home;\n\n",
"#[no_mangle]\n",
"pub extern \"C\" fn ANativeActivity_onCreate(\n",
" _activity: *mut std::ffi::c_void,\n",
" _saved_state: *mut std::ffi::c_void,\n",
" _saved_state_size: usize,\n",
") {\n",
" env_logger::init();\n",
" let rt = Runtime::new().expect(\"Failed to boot Bubba runtime\");\n",
" rt.launch(\"Home\", Home);\n",
"}\n"
);
write(base.join("src/lib.rs"), content)
}
fn write_screens(base: &PathBuf) -> Result<()> {
write(base.join("src/screens/mod.rs"), "pub mod home;\npub mod profile;\n")?;
let home = concat!(
"#![allow(non_snake_case)]\n\n",
"use bubba_core::prelude::*;\n",
"#[allow(unused_imports)]\n",
"use crate::screens::profile::Profile;\n\n",
"pub fn Home() -> Screen {\n",
" view! {\n",
" <h1 class=\"title\">\"Welcome to Bubba\"</h1>\n",
" <button class=\"primary-btn\" onclick=alert(\"You tapped the button!\")>\n",
" \"Tap me\"\n",
" </button>\n",
" <button class=\"link-btn\" onclick=navigate(Profile)>\n",
" \"Go to Profile\"\n",
" </button>\n",
" <input class=\"text-input\"\n",
" oninput=log(\"Typing...\")\n",
" onkeypress=log(\"Key pressed\") />\n",
" }\n",
"}\n"
);
write(base.join("src/screens/home.rs"), home)?;
let profile = concat!(
"#![allow(non_snake_case)]\n\n",
"use bubba_core::prelude::*;\n",
"#[allow(unused_imports)]\n",
"use crate::screens::home::Home;\n\n",
"pub fn Profile() -> Screen {\n",
" view! {\n",
" <img src=\"avatar.png\" class=\"avatar\" />\n",
" <p class=\"label\">\"Username: Alice\"</p>\n",
" <button class=\"danger-btn\" onclick=navigate(Home)>\n",
" \"Log out\"\n",
" </button>\n",
" }\n",
"}\n"
);
write(base.join("src/screens/profile.rs"), profile)?;
Ok(())
}
fn write_assets(base: &PathBuf) -> Result<()> {
let css = concat!(
".title { font-size: 28px; font-weight: bold; color: #1a1a2e; margin-bottom: 20px; text-align: center; }\n",
".label { font-size: 16px; color: #555555; margin-bottom: 8px; }\n",
".primary-btn { background: #4CAF50; color: white; padding: 14px 28px; border-radius: 10px; font-size: 16px; font-weight: 600; margin-top: 16px; }\n",
".link-btn { background: transparent; color: #4CAF50; padding: 12px 24px; border-radius: 10px; font-size: 15px; margin-top: 10px; border: 1.5px solid #4CAF50; }\n",
".danger-btn { background: #e53935; color: white; padding: 14px 28px; border-radius: 10px; font-size: 16px; font-weight: 600; margin-top: 20px; }\n",
".text-input { background: white; border: 1.5px solid #dddddd; border-radius: 10px; padding: 14px 16px; font-size: 16px; width: 100%; margin-top: 20px; }\n",
".avatar { width: 88px; height: 88px; border-radius: 44px; margin-bottom: 16px; border: 3px solid #4CAF50; }\n"
);
write(base.join("assets/main.css"), css)
}
fn write_manifest(base: &PathBuf, name: &str) -> Result<()> {
let pkg = name.replace('-', "_");
let content = format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n\
package=\"rs.bubba.{pkg}\">\n\
<uses-sdk android:minSdkVersion=\"21\" android:targetSdkVersion=\"34\" />\n\
<application\n\
android:label=\"{name}\"\n\
android:allowBackup=\"true\"\n\
android:extractNativeLibs=\"true\">\n\
<activity\n\
android:name=\"rs.bubba.BubbaActivity\"\n\
android:exported=\"true\"\n\
android:configChanges=\"orientation|screenSize|keyboardHidden\">\n\
<intent-filter>\n\
<action android:name=\"android.intent.action.MAIN\" />\n\
<category android:name=\"android.intent.category.LAUNCHER\" />\n\
</intent-filter>\n\
</activity>\n\
</application>\n\
</manifest>\n",
name = name,
pkg = pkg
);
write(base.join("android/app/src/main/AndroidManifest.xml"), &content)
}
fn write_java_bridge(base: &PathBuf) -> Result<()> {
let java_dir = base.join("android/java/rs/bubba");
let bridge = concat!(
"package rs.bubba;\n\n",
"public final class BubbaBridge {\n",
" private BubbaBridge() {}\n",
" public static native void nativeInit();\n",
" public static native void nativeOnLifecycle(String event);\n",
" public static native String nativeRender();\n",
" public static native void nativeOnEvent(int elementId, String eventKind, String value);\n",
" public static native boolean nativeDidNavigate();\n",
" public static native boolean nativeOnBack();\n",
"}\n"
);
write(java_dir.join("BubbaBridge.java"), bridge)?;
let activity = concat!(
"package rs.bubba;\n",
"import android.app.Activity;\n",
"import android.os.Bundle;\n",
"import android.os.Handler;\n",
"import android.os.Looper;\n",
"import android.util.Log;\n",
"import android.widget.LinearLayout;\n",
"import android.widget.ScrollView;\n",
"import org.json.JSONException;\n",
"import org.json.JSONObject;\n\n",
"public class BubbaActivity extends Activity {\n",
" private static final String TAG = \"Bubba\";\n",
" static { System.loadLibrary(\"app\"); }\n",
" private LinearLayout rootLayout;\n",
" private final Handler mainHandler = new Handler(Looper.getMainLooper());\n\n",
" @Override\n",
" protected void onCreate(Bundle savedInstanceState) {\n",
" super.onCreate(savedInstanceState);\n",
" ScrollView scroll = new ScrollView(this);\n",
" scroll.setFillViewport(true);\n",
" rootLayout = new LinearLayout(this);\n",
" rootLayout.setOrientation(LinearLayout.VERTICAL);\n",
" rootLayout.setPadding(24, 48, 24, 24);\n",
" scroll.addView(rootLayout);\n",
" setContentView(scroll);\n",
" BubbaBridge.nativeInit();\n",
" render();\n",
" }\n\n",
" @Override protected void onResume() { super.onResume(); BubbaBridge.nativeOnLifecycle(\"resume\"); }\n",
" @Override protected void onPause() { super.onPause(); BubbaBridge.nativeOnLifecycle(\"pause\"); }\n",
" @Override protected void onDestroy() { super.onDestroy(); BubbaBridge.nativeOnLifecycle(\"destroy\"); }\n\n",
" @Override\n",
" public void onBackPressed() {\n",
" if (BubbaBridge.nativeOnBack()) { render(); } else { super.onBackPressed(); }\n",
" }\n\n",
" private void render() {\n",
" String json = BubbaBridge.nativeRender();\n",
" Log.d(TAG, \"render() \" + json.length() + \" bytes\");\n",
" mainHandler.post(() -> {\n",
" rootLayout.removeAllViews();\n",
" try {\n",
" new ViewInflater(this, rootLayout, this::onEvent).inflate(new JSONObject(json));\n",
" } catch (JSONException e) {\n",
" Log.e(TAG, \"JSON parse error: \" + e.getMessage());\n",
" }\n",
" });\n",
" }\n\n",
" private void onEvent(int id, String kind, String value) {\n",
" BubbaBridge.nativeOnEvent(id, kind, value);\n",
" if (BubbaBridge.nativeDidNavigate()) render();\n",
" }\n",
"}\n"
);
write(java_dir.join("BubbaActivity.java"), activity)?;
let inflater = concat!(
"package rs.bubba;\n",
"import android.content.Context;\n",
"import android.graphics.Color;\n",
"import android.graphics.Typeface;\n",
"import android.graphics.drawable.GradientDrawable;\n",
"import android.text.Editable;\n",
"import android.text.TextWatcher;\n",
"import android.util.Log;\n",
"import android.util.TypedValue;\n",
"import android.view.Gravity;\n",
"import android.view.View;\n",
"import android.view.ViewGroup;\n",
"import android.widget.*;\n",
"import org.json.JSONArray;\n",
"import org.json.JSONException;\n",
"import org.json.JSONObject;\n\n",
"public class ViewInflater {\n",
" private static final String TAG = \"BubbaInflater\";\n",
" public interface EventCallback { void onEvent(int elementId, String eventKind, String value); }\n",
" private final Context context;\n",
" private final ViewGroup parent;\n",
" private final EventCallback callback;\n\n",
" public ViewInflater(Context context, ViewGroup parent, EventCallback callback) {\n",
" this.context = context; this.parent = parent; this.callback = callback;\n",
" }\n\n",
" public void inflate(JSONObject root) throws JSONException {\n",
" View v = inflateNode(root);\n",
" if (v != null) parent.addView(v);\n",
" }\n\n",
" private View inflateNode(JSONObject node) throws JSONException {\n",
" String tag = node.optString(\"tag\", \"div\");\n",
" int id = node.optInt(\"id\", 0);\n",
" String cls = node.optString(\"class\", \"\");\n",
" String text = node.optString(\"text\", \"\");\n",
" JSONArray handlers = node.optJSONArray(\"handlers\");\n",
" JSONArray children = node.optJSONArray(\"children\");\n",
" JSONObject attrs = node.optJSONObject(\"attrs\");\n",
" View view;\n",
" switch (tag) {\n",
" case \"h1\": view = tv(text, 28f, true); break;\n",
" case \"h2\": view = tv(text, 22f, true); break;\n",
" case \"h3\": view = tv(text, 18f, true); break;\n",
" case \"p\": case \"span\": view = tv(text, 16f, false); break;\n",
" case \"button\": view = btn(text, id); break;\n",
" case \"input\": view = input(attrs, id); break;\n",
" case \"img\": view = img(attrs); break;\n",
" case \"a\": view = link(text, id); break;\n",
" default: view = container(children); break;\n",
" }\n",
" if (view == null) return null;\n",
" applyClass(view, cls);\n",
" if (handlers != null) wireHandlers(view, handlers, id);\n",
" view.setLayoutParams(new LinearLayout.LayoutParams(\n",
" LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));\n",
" return view;\n",
" }\n\n",
" private TextView tv(String text, float sp, boolean bold) {\n",
" TextView v = new TextView(context);\n",
" v.setText(text); v.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp);\n",
" if (bold) v.setTypeface(null, Typeface.BOLD);\n",
" v.setTextColor(Color.parseColor(\"#1a1a2e\"));\n",
" v.setPadding(0, dp(4), 0, dp(4)); return v;\n",
" }\n\n",
" private Button btn(String text, int id) {\n",
" Button b = new Button(context);\n",
" b.setText(text); b.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f);\n",
" b.setOnClickListener(v -> callback.onEvent(id, \"click\", \"\"));\n",
" return b;\n",
" }\n\n",
" private EditText input(JSONObject attrs, int id) {\n",
" EditText et = new EditText(context);\n",
" if (attrs != null) et.setHint(attrs.optString(\"placeholder\", \"\"));\n",
" et.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f);\n",
" et.addTextChangedListener(new TextWatcher() {\n",
" public void beforeTextChanged(CharSequence s, int a, int b, int c) {}\n",
" public void afterTextChanged(Editable s) {}\n",
" public void onTextChanged(CharSequence s, int a, int b, int c) {\n",
" callback.onEvent(id, \"input\", s.toString());\n",
" }\n",
" });\n",
" et.setOnFocusChangeListener((v, f) -> callback.onEvent(id, f ? \"focus\" : \"blur\", \"\"));\n",
" return et;\n",
" }\n\n",
" private ImageView img(JSONObject attrs) {\n",
" ImageView iv = new ImageView(context);\n",
" if (attrs != null) {\n",
" String src = attrs.optString(\"src\", \"\");\n",
" if (!src.isEmpty()) try {\n",
" iv.setImageDrawable(android.graphics.drawable.Drawable\n",
" .createFromStream(context.getAssets().open(src), null));\n",
" } catch (Exception e) { Log.w(TAG, \"img load failed: \" + src); }\n",
" }\n",
" return iv;\n",
" }\n\n",
" private TextView link(String text, int id) {\n",
" TextView v = tv(text, 16f, false);\n",
" v.setTextColor(Color.parseColor(\"#4CAF50\"));\n",
" v.setOnClickListener(x -> callback.onEvent(id, \"click\", \"\"));\n",
" return v;\n",
" }\n\n",
" private LinearLayout container(JSONArray children) throws JSONException {\n",
" LinearLayout ll = new LinearLayout(context);\n",
" ll.setOrientation(LinearLayout.VERTICAL);\n",
" ll.setGravity(Gravity.CENTER_HORIZONTAL);\n",
" if (children != null) for (int i = 0; i < children.length(); i++) {\n",
" View child = inflateNode(children.getJSONObject(i));\n",
" if (child != null) ll.addView(child);\n",
" }\n",
" return ll;\n",
" }\n\n",
" private void wireHandlers(View view, JSONArray handlers, int id) {\n",
" for (int i = 0; i < handlers.length(); i++) {\n",
" try {\n",
" String event = handlers.getString(i);\n",
" if (\"click\".equals(event) && !(view instanceof Button))\n",
" view.setOnClickListener(v -> callback.onEvent(id, \"click\", \"\"));\n",
" } catch (Exception e) { Log.w(TAG, e.getMessage()); }\n",
" }\n",
" }\n\n",
" private void applyClass(View view, String className) {\n",
" if (className == null || className.isEmpty()) return;\n",
" for (String cls : className.split(\"\\\\s+\")) switch (cls.trim()) {\n",
" case \"primary-btn\":\n",
" roundedBg(view, \"#4CAF50\", 10);\n",
" if (view instanceof TextView) ((TextView)view).setTextColor(Color.WHITE);\n",
" view.setPadding(dp(28), dp(14), dp(28), dp(14)); break;\n",
" case \"link-btn\":\n",
" roundedStroke(view, \"#4CAF50\", 10, 2);\n",
" if (view instanceof TextView) ((TextView)view).setTextColor(Color.parseColor(\"#4CAF50\"));\n",
" view.setPadding(dp(24), dp(12), dp(24), dp(12)); break;\n",
" case \"danger-btn\":\n",
" roundedBg(view, \"#e53935\", 10);\n",
" if (view instanceof TextView) ((TextView)view).setTextColor(Color.WHITE);\n",
" view.setPadding(dp(28), dp(14), dp(28), dp(14)); break;\n",
" case \"title\":\n",
" if (view instanceof TextView) {\n",
" TextView tv = (TextView)view;\n",
" tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28f);\n",
" tv.setTypeface(null, Typeface.BOLD);\n",
" tv.setGravity(Gravity.CENTER);\n",
" tv.setPadding(0, 0, 0, dp(16));\n",
" } break;\n",
" case \"label\":\n",
" if (view instanceof TextView) view.setPadding(0, dp(4), 0, dp(8)); break;\n",
" case \"text-input\":\n",
" roundedStroke(view, \"#dddddd\", 10, 2);\n",
" view.setBackgroundColor(Color.WHITE);\n",
" view.setPadding(dp(16), dp(14), dp(16), dp(14)); break;\n",
" case \"avatar\":\n",
" LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(dp(88), dp(88));\n",
" lp.setMargins(0, 0, 0, dp(16)); view.setLayoutParams(lp); break;\n",
" }\n",
" }\n\n",
" private void roundedBg(View v, String color, int r) {\n",
" GradientDrawable g = new GradientDrawable();\n",
" g.setColor(Color.parseColor(color)); g.setCornerRadius(dp(r)); v.setBackground(g);\n",
" }\n\n",
" private void roundedStroke(View v, String color, int r, int w) {\n",
" GradientDrawable g = new GradientDrawable();\n",
" g.setColor(Color.TRANSPARENT);\n",
" g.setStroke(dp(w), Color.parseColor(color)); g.setCornerRadius(dp(r)); v.setBackground(g);\n",
" }\n\n",
" private int dp(int dp) {\n",
" return Math.round(dp * context.getResources().getDisplayMetrics().density);\n",
" }\n",
"}\n"
);
write(java_dir.join("ViewInflater.java"), inflater)?;
Ok(())
}
fn write_build_dex(base: &PathBuf) -> Result<()> {
let script = concat!(
"#!/usr/bin/env bash\n",
"# Compile Bubba Java bridge -> classes.dex -> add to APK\n",
"set -e\n",
"SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n",
"PROJECT_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n\n",
"find_tool() {\n",
" command -v \"$1\" 2>/dev/null && return\n",
" local sdk=\"${ANDROID_SDK_ROOT:-$ANDROID_HOME}\"\n",
" find \"$sdk/build-tools\" -name \"$1\" -type f 2>/dev/null | sort -rV | head -1\n",
"}\n\n",
"ANDROID_JAR=$(find \"${ANDROID_SDK_ROOT:-$ANDROID_HOME}/platforms\" \\\n",
" -name \"android.jar\" 2>/dev/null | sort -rV | head -1)\n",
"[ -z \"$ANDROID_JAR\" ] && { echo \"ERROR: android.jar not found\"; exit 1; }\n\n",
"D8=$(find_tool d8)\n",
"[ -z \"$D8\" ] && { echo \"ERROR: d8 not found in build-tools\"; exit 1; }\n\n",
"JAVA_SRC=\"$SCRIPT_DIR/java/rs/bubba\"\n",
"CLASSES=\"$SCRIPT_DIR/classes_out\"\n",
"DEX=\"$SCRIPT_DIR/dex_out\"\n",
"APK=\"$PROJECT_ROOT/dist/base.apk\"\n\n",
"echo \" [dex] Compiling Java...\"\n",
"rm -rf \"$CLASSES\" && mkdir -p \"$CLASSES\"\n",
"javac -source 8 -target 8 -cp \"$ANDROID_JAR\" -d \"$CLASSES\" \\\n",
" \"$JAVA_SRC/BubbaBridge.java\" \\\n",
" \"$JAVA_SRC/ViewInflater.java\" \\\n",
" \"$JAVA_SRC/BubbaActivity.java\"\n\n",
"echo \" [dex] Converting to DEX...\"\n",
"rm -rf \"$DEX\" && mkdir -p \"$DEX\"\n",
"\"$D8\" --classpath \"$ANDROID_JAR\" --output \"$DEX\" \\\n",
" $(find \"$CLASSES\" -name \"*.class\")\n\n",
"echo \" [dex] Packing classes.dex into APK...\"\n",
"cd \"$DEX\" && zip \"$APK\" classes.dex\n",
"echo \" [dex] Done.\"\n"
);
write(base.join("android/build_dex.sh"), script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let path = base.join("android/build_dex.sh");
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms)?;
}
Ok(())
}
fn write_gitignore(base: &PathBuf) -> Result<()> {
write(base.join(".gitignore"), "/target\n/dist\n*.apk\n.env\n")
}
fn write(path: PathBuf, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
println!(" {} {}", "create".green(), path.display());
Ok(())
}