inital commit кек
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.cbe.android"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.cbe.android"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.11"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":cbe-core"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:name=".CbeApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="CBE Emulator"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.CBE.Emulator"
|
||||
tools:targetApi="35">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package com.cbe.android
|
||||
|
||||
import android.app.Application
|
||||
import com.cbe.android.engine.ModuleProvider
|
||||
|
||||
class CbeApp : Application() {
|
||||
lateinit var moduleProvider: ModuleProvider
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
moduleProvider = ModuleProvider(this)
|
||||
moduleProvider.setupBeep()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.cbe.android
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.cbe.android.engine.AndroidEngine
|
||||
import com.cbe.android.engine.ModuleProvider
|
||||
import com.cbe.android.engine.PluginConfig
|
||||
import com.cbe.android.engine.PluginEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class EmulatorUiState(
|
||||
val gpuText: String = "",
|
||||
val postCode: Int = 0,
|
||||
val ledStatus: Int = 0,
|
||||
val isRunning: Boolean = false,
|
||||
val isHalted: Boolean = false,
|
||||
val instructionCount: Long = 0,
|
||||
val systemInfo: String = "",
|
||||
val statusMessage: String = "",
|
||||
val pluginConfig: PluginConfig = PluginConfig(),
|
||||
val pluginEntries: List<PluginEntry> = emptyList(),
|
||||
val pluginPaths: Map<String, String> = emptyMap(),
|
||||
val speedIndex: Int = 3,
|
||||
val speedLabel: String = "1x",
|
||||
val speedIps: Long = 100_000
|
||||
)
|
||||
|
||||
class EmulatorViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val _uiState = MutableStateFlow(EmulatorUiState())
|
||||
val uiState: StateFlow<EmulatorUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val engine = AndroidEngine()
|
||||
private var runJob: Job? = null
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
private val moduleProvider get() = getApplication<CbeApp>().moduleProvider
|
||||
|
||||
init {
|
||||
engine.init()
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
viewModelScope.launch {
|
||||
refreshPluginList()
|
||||
reloadPlugins()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshPluginList() {
|
||||
viewModelScope.launch {
|
||||
val entries = moduleProvider.listAllPlugins()
|
||||
val paths = moduleProvider.getPluginPaths()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
pluginEntries = entries,
|
||||
pluginPaths = paths
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeed(index: Int) {
|
||||
engine.setSpeed(index)
|
||||
val speed = engine.speed
|
||||
_uiState.value = _uiState.value.copy(
|
||||
speedIndex = index,
|
||||
speedLabel = speed.label,
|
||||
speedIps = speed.ips
|
||||
)
|
||||
}
|
||||
|
||||
fun updatePluginConfig(slot: String, pluginName: String) {
|
||||
viewModelScope.launch {
|
||||
val cfg = _uiState.value.pluginConfig
|
||||
val newCfg = when (slot) {
|
||||
"cpu" -> cfg.copy(cpu = pluginName)
|
||||
"ram" -> cfg.copy(ram = pluginName)
|
||||
"gpu" -> cfg.copy(gpu = pluginName)
|
||||
"kbd" -> cfg.copy(kbd = pluginName)
|
||||
"snd" -> cfg.copy(snd = pluginName)
|
||||
"bios" -> cfg.copy(bios = pluginName)
|
||||
"disk" -> cfg.copy(disk = if (pluginName == "none") null else pluginName)
|
||||
else -> cfg
|
||||
}
|
||||
reloadPlugins(newCfg)
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadPlugins(config: PluginConfig? = null) {
|
||||
viewModelScope.launch {
|
||||
val cfg = config ?: _uiState.value.pluginConfig
|
||||
_uiState.value = _uiState.value.copy(pluginConfig = cfg)
|
||||
|
||||
try {
|
||||
val plugins = moduleProvider.extractPlugins(cfg)
|
||||
engine.loadModules(plugins)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
systemInfo = engine.systemInfo,
|
||||
statusMessage = ""
|
||||
)
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
statusMessage = "Failed to load: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
updateUiState()
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUiState() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
gpuText = engine.getGpuText() ?: "",
|
||||
postCode = engine.postCode,
|
||||
ledStatus = engine.ledStatus,
|
||||
isRunning = engine.isRunning,
|
||||
isHalted = engine.isHalted,
|
||||
instructionCount = engine.instructionsExecuted
|
||||
)
|
||||
engine.markGpuClean()
|
||||
}
|
||||
|
||||
fun runFull() {
|
||||
if (engine.isRunning || engine.isHalted) return
|
||||
runJob = viewModelScope.launch {
|
||||
engine.runFull()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
runJob?.cancel()
|
||||
}
|
||||
|
||||
fun pushKey(keyCode: Int) {
|
||||
engine.pushKey(keyCode)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
runJob?.cancel()
|
||||
engine.reset()
|
||||
updateUiState()
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { engine.engine.runDiagnostics() }
|
||||
updateUiState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
runJob?.cancel()
|
||||
refreshJob?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cbe.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.cbe.android.ui.MainScreen
|
||||
import com.cbe.android.ui.theme.CbeTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
CbeTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
val viewModel: EmulatorViewModel = viewModel()
|
||||
LaunchedEffect(Unit) { viewModel.initialize() }
|
||||
MainScreen(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.cbe.android.engine
|
||||
|
||||
import com.cbe.loader.Engine
|
||||
import com.cbe.loader.SimpleRegisters
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
data class SpeedConfig(
|
||||
val label: String = "1x",
|
||||
val batchSize: Int = 1000,
|
||||
val batchDelayMs: Long = 10
|
||||
) {
|
||||
/** Estimated instructions per second */
|
||||
val ips: Long get() = (batchSize * 1000L) / maxOf(batchDelayMs, 1)
|
||||
}
|
||||
|
||||
class AndroidEngine {
|
||||
|
||||
val engine = Engine()
|
||||
val registers = SimpleRegisters()
|
||||
|
||||
var postCode: Int = 0
|
||||
private set
|
||||
var ledStatus: Int = 0
|
||||
private set
|
||||
var postDescription: String = ""
|
||||
private set
|
||||
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
var isHalted: Boolean = false
|
||||
private set
|
||||
var instructionsExecuted: Long = 0
|
||||
private set
|
||||
var systemInfo: String = ""
|
||||
private set
|
||||
|
||||
var speed: SpeedConfig = SPEEDS[3] // default = 1x
|
||||
private set
|
||||
|
||||
private var postListener: Engine.PostListener? = null
|
||||
|
||||
companion object {
|
||||
val SPEEDS = listOf(
|
||||
SpeedConfig("0.25x", 100, 40),
|
||||
SpeedConfig("0.5x", 250, 20),
|
||||
SpeedConfig("1x", 1000, 10),
|
||||
SpeedConfig("2x", 2000, 5),
|
||||
SpeedConfig("4x", 5000, 2),
|
||||
SpeedConfig("MAX", 50000, 0)
|
||||
)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
postListener = Engine.PostListener { code, leds, desc ->
|
||||
postCode = code
|
||||
ledStatus = leds
|
||||
postDescription = desc ?: ""
|
||||
}
|
||||
engine.addPostListener(postListener!!)
|
||||
}
|
||||
|
||||
fun setSpeed(index: Int) {
|
||||
if (index in SPEEDS.indices) speed = SPEEDS[index]
|
||||
}
|
||||
|
||||
suspend fun loadModules(plugins: Map<String, File>) = withContext(Dispatchers.IO) {
|
||||
val cpu = plugins["tiny-cpu"]
|
||||
val ram = plugins["basic-ram"]
|
||||
val gpu = plugins["vga-display"]
|
||||
val kbd = plugins["basic-kbd"]
|
||||
val snd = plugins["basic-snd"]
|
||||
val bios = plugins["tiny-bios"]
|
||||
val disk = plugins["big-disk"] ?: plugins["system-disk"]
|
||||
|
||||
if (cpu != null) engine.loadCompiledCpu(cpu.toPath())
|
||||
if (ram != null) engine.loadCompiledRam(ram.toPath(), 0x00)
|
||||
if (gpu != null) engine.loadCompiledGpu(gpu.toPath())
|
||||
if (kbd != null) engine.loadCompiledKbd(kbd.toPath())
|
||||
if (snd != null) engine.loadCompiledSnd(snd.toPath())
|
||||
if (bios != null) engine.loadCompiledBios(bios.toPath())
|
||||
if (disk != null) engine.loadCompiledDisk(disk.toPath())
|
||||
|
||||
systemInfo = engine.runDiagnostics()
|
||||
}
|
||||
|
||||
suspend fun runFull() {
|
||||
if (isRunning || isHalted) return
|
||||
isRunning = true
|
||||
try {
|
||||
while (coroutineContext[Job]?.isActive != false && !isHalted) {
|
||||
val batchStart = instructionsExecuted
|
||||
val limit = speed.batchSize
|
||||
while (coroutineContext[Job]?.isActive != false && !isHalted
|
||||
&& instructionsExecuted - batchStart < limit) {
|
||||
val alive = engine.step(registers)
|
||||
instructionsExecuted++
|
||||
if (!alive) {
|
||||
isHalted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (speed.batchDelayMs > 0 && !isHalted && coroutineContext[Job]?.isActive != false) {
|
||||
delay(speed.batchDelayMs)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
fun step(): Boolean {
|
||||
if (isHalted) return false
|
||||
val alive = engine.step(registers)
|
||||
instructionsExecuted++
|
||||
if (!alive) isHalted = true
|
||||
return alive
|
||||
}
|
||||
|
||||
suspend fun runSteps(count: Int): Int {
|
||||
if (isRunning || isHalted) return 0
|
||||
isRunning = true
|
||||
try {
|
||||
var executed = 0
|
||||
for (i in 0 until count) {
|
||||
if (coroutineContext[Job]?.isActive == false) break
|
||||
if (!engine.step(registers)) {
|
||||
isHalted = true
|
||||
break
|
||||
}
|
||||
executed++
|
||||
}
|
||||
instructionsExecuted += executed
|
||||
return executed
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
fun pushKey(keyCode: Int) {
|
||||
engine.pushKey(keyCode)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
engine.reset()
|
||||
registers.write("pc", 0)
|
||||
registers.write("sp", 0x80)
|
||||
isHalted = false
|
||||
isRunning = false
|
||||
instructionsExecuted = 0
|
||||
postCode = 0
|
||||
ledStatus = 0
|
||||
}
|
||||
|
||||
fun getGpuText(): String? {
|
||||
return engine.sourceGpu?.readString()
|
||||
?: engine.compiledGpu?.readString()
|
||||
}
|
||||
|
||||
fun getGpuRows(): Int {
|
||||
return engine.sourceGpu?.getRows()
|
||||
?: engine.compiledGpu?.getRows() ?: 0
|
||||
}
|
||||
|
||||
fun getGpuCols(): Int {
|
||||
return engine.sourceGpu?.getCols()
|
||||
?: engine.compiledGpu?.getCols() ?: 0
|
||||
}
|
||||
|
||||
fun isGpuDirty(): Boolean {
|
||||
return engine.sourceGpu?.isDirty()
|
||||
?: engine.compiledGpu?.isDirty() ?: false
|
||||
}
|
||||
|
||||
fun markGpuClean() {
|
||||
engine.sourceGpu?.markClean()
|
||||
engine.compiledGpu?.markClean()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.cbe.android.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.cbe.loader.AudioBridge
|
||||
import com.cbe.loader.BeepHandler
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
data class PluginEntry(
|
||||
val name: String,
|
||||
val file: File,
|
||||
val source: String // "assets", "external", "media"
|
||||
)
|
||||
|
||||
data class PluginConfig(
|
||||
val cpu: String = "tiny-cpu.cbeplugin",
|
||||
val ram: String = "basic-ram.cbeplugin",
|
||||
val gpu: String = "vga-display.cbeplugin",
|
||||
val kbd: String = "basic-kbd.cbeplugin",
|
||||
val snd: String = "basic-snd.cbeplugin",
|
||||
val bios: String = "tiny-bios.cbeplugin",
|
||||
val disk: String? = "big-disk.cbeplugin"
|
||||
) {
|
||||
fun allPlugins(): List<String> {
|
||||
val list = mutableListOf(cpu, ram, gpu, kbd, snd, bios)
|
||||
if (disk != null) list.add(disk)
|
||||
return list
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SLOTS = listOf("cpu", "ram", "gpu", "kbd", "snd", "bios", "disk")
|
||||
}
|
||||
}
|
||||
|
||||
class ModuleProvider(private val context: Context) {
|
||||
|
||||
/** Internal directory where assets are extracted */
|
||||
private val pluginDir: File by lazy {
|
||||
File(context.filesDir, "plugins").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
/** App-specific external storage: /sdcard/Android/data/<pkg>/files/plugins/ */
|
||||
private val externalPluginDir: File by lazy {
|
||||
context.getExternalFilesDir("plugins")?.also { it.mkdirs() }
|
||||
?: File(context.filesDir, "external-plugins").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
/** Shared media path: /sdcard/Android/media/com.cbe/ */
|
||||
private val mediaPluginDir: File by lazy {
|
||||
try {
|
||||
File(Environment.getExternalStorageDirectory(), "Android/media/com.cbe")
|
||||
} catch (e: Exception) {
|
||||
File(context.filesDir, "media-plugins").also { it.mkdirs() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan all sources and return available plugins with their source info.
|
||||
* External files override internal ones with the same name. */
|
||||
fun listAllPlugins(): List<PluginEntry> {
|
||||
val seen = mutableSetOf<String>()
|
||||
val result = mutableListOf<PluginEntry>()
|
||||
|
||||
// External overrides (highest priority)
|
||||
for (dir in listOf(mediaPluginDir, externalPluginDir)) {
|
||||
if (dir.exists() && dir.isDirectory) {
|
||||
val source = if (dir == mediaPluginDir) "media" else "external"
|
||||
dir.listFiles { f -> f.name.endsWith(".cbeplugin") }?.forEach { f ->
|
||||
if (seen.add(f.name)) {
|
||||
result.add(PluginEntry(f.name, f, source))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal assets (lower priority)
|
||||
try {
|
||||
context.assets.list("plugins")?.forEach { name ->
|
||||
if (name.endsWith(".cbeplugin") && seen.add(name)) {
|
||||
val target = File(pluginDir, name)
|
||||
result.add(PluginEntry(name, target, "assets"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ModuleProvider", "Failed to list assets: ${e.message}")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Extract all needed plugins from assets to internal storage if not already there.
|
||||
* External files are used in-place (no copy needed). */
|
||||
fun extractPlugins(config: PluginConfig = PluginConfig()): Map<String, File> {
|
||||
val all = listAllPlugins()
|
||||
val byName = all.associateBy { it.name }
|
||||
val result = mutableMapOf<String, File>()
|
||||
|
||||
for (name in config.allPlugins()) {
|
||||
val entry = byName[name]
|
||||
if (entry != null && entry.file.exists()) {
|
||||
// External file found — use directly
|
||||
val key = name.removeSuffix(".cbeplugin")
|
||||
result[key] = entry.file
|
||||
} else {
|
||||
// Not found externally — extract from assets
|
||||
val target = File(pluginDir, name)
|
||||
if (!target.exists()) {
|
||||
try {
|
||||
context.assets.open("plugins/$name").use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
android.util.Log.i("ModuleProvider", "Extracted $name to $target")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ModuleProvider", "Failed to extract $name: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (target.exists()) {
|
||||
val key = name.removeSuffix(".cbeplugin")
|
||||
result[key] = target
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** List plugins grouped by their likely slot type based on filename patterns. */
|
||||
fun classifyPlugins(entries: List<PluginEntry>): Map<String, List<PluginEntry>> {
|
||||
val result = linkedMapOf<String, MutableList<PluginEntry>>()
|
||||
for (slot in PluginConfig.SLOTS) result[slot] = mutableListOf()
|
||||
result["other"] = mutableListOf()
|
||||
|
||||
for (e in entries) {
|
||||
val name = e.name.lowercase()
|
||||
val slot = when {
|
||||
name.contains("cpu") || name.contains("tiny") -> "cpu"
|
||||
name.contains("ram") || name.contains("memory") -> "ram"
|
||||
name.contains("gpu") || name.contains("vga") || name.contains("video") || name.contains("display") -> "gpu"
|
||||
name.contains("kbd") || name.contains("keyboard") || name.contains("key") -> "kbd"
|
||||
name.contains("snd") || name.contains("sound") || name.contains("audio") || name.contains("speaker") -> "snd"
|
||||
name.contains("bios") || name.contains("system") -> "bios"
|
||||
name.contains("disk") || name.contains("disc") || name.contains("storage") -> "disk"
|
||||
else -> "other"
|
||||
}
|
||||
result.getOrPut(slot) { mutableListOf() }.add(e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun getPluginPaths(): Map<String, String> {
|
||||
return mapOf(
|
||||
"Internal (assets)" to pluginDir.absolutePath,
|
||||
"External (app)" to externalPluginDir.absolutePath,
|
||||
"Media (shared)" to mediaPluginDir.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
fun setupBeep() {
|
||||
AudioBridge.setBeepHandler(BeepHandler {
|
||||
try {
|
||||
val toneGen = android.media.ToneGenerator(
|
||||
android.media.ToneGenerator.TONE_DTMF_0, 60
|
||||
)
|
||||
toneGen.startTone(android.media.ToneGenerator.TONE_DTMF_0, 80)
|
||||
toneGen.release()
|
||||
} catch (_: Exception) {}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.cbe.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun KeyboardSheet(
|
||||
onKey: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Row 1: 0-9
|
||||
KeyboardRow(keys = listOf(
|
||||
"0" to 0x30, "1" to 0x31, "2" to 0x32, "3" to 0x33,
|
||||
"4" to 0x34, "5" to 0x35, "6" to 0x36, "7" to 0x37,
|
||||
"8" to 0x38, "9" to 0x39
|
||||
), onKey)
|
||||
|
||||
// Row 2: A-Z top half
|
||||
KeyboardRow(keys = listOf(
|
||||
"Q" to 0x51, "W" to 0x57, "E" to 0x45, "R" to 0x52,
|
||||
"T" to 0x54, "Y" to 0x59, "U" to 0x55, "I" to 0x49,
|
||||
"O" to 0x4F, "P" to 0x50
|
||||
), onKey)
|
||||
|
||||
// Row 3: A-Z bottom half
|
||||
KeyboardRow(keys = listOf(
|
||||
"A" to 0x41, "S" to 0x53, "D" to 0x44, "F" to 0x46,
|
||||
"G" to 0x47, "H" to 0x48, "J" to 0x4A, "K" to 0x4B,
|
||||
"L" to 0x4C
|
||||
), onKey)
|
||||
|
||||
// Row 4: Shift, Z-M, Enter, Backspace
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
KeyButton("Shift", modifier = Modifier.weight(1.5f)) {
|
||||
onKey(0x10) // SHIFT
|
||||
}
|
||||
for ((label, code) in listOf(
|
||||
"Z" to 0x5A, "X" to 0x58, "C" to 0x43,
|
||||
"V" to 0x56, "B" to 0x42, "N" to 0x4E, "M" to 0x4D
|
||||
)) {
|
||||
KeyButton(label, modifier = Modifier.weight(1f)) { onKey(code) }
|
||||
}
|
||||
KeyButton("", modifier = Modifier.weight(1f)) {}
|
||||
}
|
||||
|
||||
// Row 5: Space, function keys
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
KeyButton("Enter", modifier = Modifier.weight(2f)) {
|
||||
onKey(0x0D) // CR
|
||||
}
|
||||
KeyButton("Space", modifier = Modifier.weight(4f)) {
|
||||
onKey(0x20) // Space
|
||||
}
|
||||
KeyButton("BS", modifier = Modifier.weight(1.5f)) {
|
||||
onKey(0x08) // Backspace
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardRow(
|
||||
keys: List<Pair<String, Int>>,
|
||||
onKey: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
for ((label, code) in keys) {
|
||||
KeyButton(label, modifier = Modifier.weight(1f)) { onKey(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyButton(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(44.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.cbe.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.cbe.android.EmulatorViewModel
|
||||
import com.cbe.android.engine.AndroidEngine
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(viewModel: EmulatorViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
var showPlugins by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "CBE Emulator",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
if (uiState.instructionCount > 0) {
|
||||
Text(
|
||||
text = formatCount(uiState.instructionCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (uiState.isRunning) viewModel.pause()
|
||||
else viewModel.runFull()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (uiState.isRunning) Icons.Default.Close
|
||||
else Icons.Default.PlayArrow,
|
||||
contentDescription = if (uiState.isRunning) "Pause" else "Run"
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { viewModel.reset() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reset"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = { showPlugins = !showPlugins },
|
||||
modifier = Modifier.height(48.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Plugins")
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { showKeyboard = !showKeyboard },
|
||||
modifier = Modifier.height(48.dp)
|
||||
) {
|
||||
Text(if (showKeyboard) "Hide KBD" else "Show KBD")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Speed controls
|
||||
SpeedControl(
|
||||
speedIndex = uiState.speedIndex,
|
||||
speedLabel = uiState.speedLabel,
|
||||
speedIps = uiState.speedIps,
|
||||
onSelectSpeed = { viewModel.setSpeed(it) }
|
||||
)
|
||||
|
||||
// GPU Screen
|
||||
ScreenPanel(
|
||||
text = uiState.gpuText,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// POST Panel
|
||||
PostPanel(
|
||||
postCode = uiState.postCode,
|
||||
ledStatus = uiState.ledStatus
|
||||
)
|
||||
|
||||
// Status info
|
||||
if (uiState.isHalted) {
|
||||
Text(
|
||||
text = "HALTED after ${formatCount(uiState.instructionCount)} instructions",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
)
|
||||
}
|
||||
if (uiState.statusMessage.isNotEmpty()) {
|
||||
Text(
|
||||
text = uiState.statusMessage,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showKeyboard) {
|
||||
KeyboardSheet(
|
||||
onKey = { viewModel.pushKey(it) },
|
||||
onDismiss = { showKeyboard = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showPlugins) {
|
||||
PluginSheet(
|
||||
entries = uiState.pluginEntries,
|
||||
pluginConfig = uiState.pluginConfig,
|
||||
pluginPaths = uiState.pluginPaths,
|
||||
onSelectPlugin = { slot, name -> viewModel.updatePluginConfig(slot, name) },
|
||||
onRefresh = { viewModel.refreshPluginList() },
|
||||
onDismiss = { showPlugins = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpeedControl(
|
||||
speedIndex: Int,
|
||||
speedLabel: String,
|
||||
speedIps: Long,
|
||||
onSelectSpeed: (Int) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Speed: $speedLabel",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = formatIps(speedIps),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
AndroidEngine.SPEEDS.forEachIndexed { index, speedCfg ->
|
||||
FilterChip(
|
||||
selected = index == speedIndex,
|
||||
onClick = { onSelectSpeed(index) },
|
||||
label = {
|
||||
Text(
|
||||
text = speedCfg.label,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCount(count: Long): String {
|
||||
return when {
|
||||
count < 1000 -> "$count steps"
|
||||
count < 1_000_000 -> "${count / 1000}K steps"
|
||||
else -> "${count / 1_000_000}M steps"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatIps(ips: Long): String {
|
||||
return when {
|
||||
ips < 1000 -> "$ips IPS"
|
||||
ips < 1_000_000 -> "${ips / 1000}K IPS"
|
||||
else -> "${ips / 1_000_000}M IPS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.cbe.android.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.cbe.android.engine.ModuleProvider
|
||||
import com.cbe.android.engine.PluginConfig
|
||||
import com.cbe.android.engine.PluginEntry
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PluginSheet(
|
||||
entries: List<PluginEntry>,
|
||||
pluginConfig: PluginConfig,
|
||||
pluginPaths: Map<String, String>,
|
||||
onSelectPlugin: (slot: String, pluginName: String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
val classified = remember(entries) {
|
||||
ModuleProvider.classifyPlugins(entries)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxHeight(0.85f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Plugin Configuration", style = MaterialTheme.typography.titleLarge)
|
||||
|
||||
// Current config summary
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text("Current Config", style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
PluginConfigSummary(pluginConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Paths
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text("Plugin Paths", style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
for ((label, path) in pluginPaths) {
|
||||
Text(
|
||||
text = "$label: $path",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin selectors by slot
|
||||
Text("Select Plugins", style = MaterialTheme.typography.titleMedium)
|
||||
for (slot in PluginConfig.SLOTS) {
|
||||
val slotEntries = classified[slot].orEmpty()
|
||||
if (slotEntries.isEmpty()) continue
|
||||
|
||||
val currentName = when (slot) {
|
||||
"cpu" -> pluginConfig.cpu
|
||||
"ram" -> pluginConfig.ram
|
||||
"gpu" -> pluginConfig.gpu
|
||||
"kbd" -> pluginConfig.kbd
|
||||
"snd" -> pluginConfig.snd
|
||||
"bios" -> pluginConfig.bios
|
||||
"disk" -> pluginConfig.disk ?: "none"
|
||||
else -> "?"
|
||||
}
|
||||
|
||||
SlotSelector(
|
||||
slot = slot,
|
||||
current = currentName,
|
||||
entries = slotEntries,
|
||||
onSelect = { name -> onSelectPlugin(slot, name) }
|
||||
)
|
||||
}
|
||||
|
||||
// Other (unclassified) plugins
|
||||
val other = classified["other"].orEmpty()
|
||||
if (other.isNotEmpty()) {
|
||||
Text("Other Plugins", style = MaterialTheme.typography.titleSmall)
|
||||
for (entry in other) {
|
||||
Text(
|
||||
text = " ${entry.name} (${entry.source})",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
OutlinedButton(onClick = onRefresh) {
|
||||
Text("Rescan Files")
|
||||
}
|
||||
FilledTonalButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginConfigSummary(config: PluginConfig) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = "CPU: ${config.cpu}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "RAM: ${config.ram}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "GPU: ${config.gpu}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "KBD: ${config.kbd}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "SND: ${config.snd}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "BIOS: ${config.bios}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
Text(
|
||||
text = "DISK: ${config.disk ?: "(none)"}",
|
||||
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SlotSelector(
|
||||
slot: String,
|
||||
current: String,
|
||||
entries: List<PluginEntry>,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = slot.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = current,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
if (slot == "disk") {
|
||||
DropdownMenuItem(
|
||||
text = { Text("(none)") },
|
||||
onClick = {
|
||||
onSelect("none")
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
for (entry in entries) {
|
||||
val label = "${entry.name} [${entry.source}]"
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontSize = 12.sp) },
|
||||
onClick = {
|
||||
onSelect(entry.name)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.cbe.android.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.cbe.android.ui.theme.*
|
||||
|
||||
data class LedSpec(val name: String, val bit: Int, val color: Color)
|
||||
|
||||
val LEDS = listOf(
|
||||
LedSpec("PWR", 0, LedGreen),
|
||||
LedSpec("CPU", 1, LedRed),
|
||||
LedSpec("MEM", 2, LedAmber),
|
||||
LedSpec("VID", 3, LedBlue),
|
||||
LedSpec("KBD", 4, LedGreen),
|
||||
LedSpec("SND", 5, LedAmber),
|
||||
LedSpec("DSK", 6, LedBlue),
|
||||
LedSpec("CLK", 7, LedRed)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PostPanel(
|
||||
postCode: Int,
|
||||
ledStatus: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 7-segment display
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(SegBackground, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%02X", postCode and 0xFF),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 36.sp,
|
||||
color = SegActive
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// LED row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
for (led in LEDS) {
|
||||
val isOn = (ledStatus shr led.bit) and 1 == 1
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isOn) led.color else LedOff)
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = led.name,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 8.sp,
|
||||
color = if (isOn) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.cbe.android.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.cbe.android.ui.theme.ScreenBezel
|
||||
import com.cbe.android.ui.theme.ScreenBg
|
||||
import com.cbe.android.ui.theme.ScreenText
|
||||
|
||||
@Composable
|
||||
fun ScreenPanel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = ScreenBezel,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(ScreenBg)
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 9.sp,
|
||||
color = ScreenText,
|
||||
lineHeight = 11.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
Canvas(modifier = Modifier.matchParentSize()) {
|
||||
val scanAlpha = 0.04f
|
||||
var y = 0f
|
||||
val step = 5f
|
||||
while (y < size.height) {
|
||||
drawLine(
|
||||
color = Color.Black.copy(alpha = scanAlpha),
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f
|
||||
)
|
||||
y += step
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.cbe.android.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Dark theme - retro terminal vibe with amber/teal accents
|
||||
val DarkBackground = Color(0xFF0D1117)
|
||||
val DarkSurface = Color(0xFF161B22)
|
||||
val DarkSurfaceVariant = Color(0xFF21262D)
|
||||
val DarkOutline = Color(0xFF30363D)
|
||||
|
||||
val Amber = Color(0xFFFFB000)
|
||||
val AmberDim = Color(0xFFB8860B)
|
||||
val AmberGlow = Color(0xFFFFD700)
|
||||
val Teal = Color(0xFF00BFA5)
|
||||
val TealDim = Color(0xFF00897B)
|
||||
val Green = Color(0xFF00FF41)
|
||||
val Red = Color(0xFFFF3333)
|
||||
val Blue = Color(0xFF58A6FF)
|
||||
val White = Color(0xFFE6EDF3)
|
||||
val WhiteDim = Color(0xFF8B949E)
|
||||
|
||||
// LED colors
|
||||
val LedRed = Color(0xFFFF3333)
|
||||
val LedGreen = Color(0xFF00FF41)
|
||||
val LedAmber = Color(0xFFFFB000)
|
||||
val LedBlue = Color(0xFF58A6FF)
|
||||
val LedOff = Color(0xFF1A1A2E)
|
||||
|
||||
// 7-segment display colors
|
||||
val SegBackground = Color(0xFF0A0A0F)
|
||||
val SegActive = Color(0xFFFF3333)
|
||||
val SegInactive = Color(0xFF331111)
|
||||
|
||||
// GPU screen colors
|
||||
val ScreenBg = Color(0xFF000000)
|
||||
val ScreenText = Color(0xFFC0C0C0)
|
||||
val ScreenScanline = Color(0x08000000)
|
||||
val ScreenBezel = Color(0xFF2A2A2A)
|
||||
|
||||
// Material3 light (used only for system chrome)
|
||||
val LightBackground = Color(0xFFF6F8FA)
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.cbe.android.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Amber,
|
||||
onPrimary = DarkBackground,
|
||||
primaryContainer = AmberDim,
|
||||
secondary = Teal,
|
||||
onSecondary = DarkBackground,
|
||||
secondaryContainer = TealDim,
|
||||
tertiary = Green,
|
||||
background = DarkBackground,
|
||||
onBackground = White,
|
||||
surface = DarkSurface,
|
||||
onSurface = White,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = WhiteDim,
|
||||
outline = DarkOutline,
|
||||
error = Red,
|
||||
onError = DarkBackground
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CbeTheme(content: @Composable () -> Unit) {
|
||||
val colorScheme = DarkColorScheme
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = DarkBackground.toArgb()
|
||||
window.navigationBarColor = DarkBackground.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.cbe.android.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val MonospaceFamily = FontFamily.Monospace
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = MonospaceFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group android:translateX="12" android:translateY="12">
|
||||
<!-- Retro monitor body -->
|
||||
<path
|
||||
android:fillColor="#FF21262D"
|
||||
android:pathData="M8,8h68v52H8z" />
|
||||
<!-- Screen -->
|
||||
<path
|
||||
android:fillColor="#FF0D1117"
|
||||
android:pathData="M14,14h56v36H14z" />
|
||||
<!-- "CBE" text on screen -->
|
||||
<path
|
||||
android:fillColor="#FF00FF41"
|
||||
android:pathData="M20,24h4v18h-4z" />
|
||||
<path
|
||||
android:fillColor="#FF00FF41"
|
||||
android:pathData="M24,24h14v4H24z" />
|
||||
<path
|
||||
android:fillColor="#FF00FF41"
|
||||
android:pathData="M24,31h12v4H24z" />
|
||||
<path
|
||||
android:fillColor="#FF00FF41"
|
||||
android:pathData="M24,38h14v4H24z" />
|
||||
<!-- Prompt cursor -->
|
||||
<path
|
||||
android:fillColor="#FFFFB000"
|
||||
android:pathData="M62,38h4v4h-4z" />
|
||||
<!-- Monitor stand -->
|
||||
<path
|
||||
android:fillColor="#FF30363D"
|
||||
android:pathData="M30,60h24v6H30z" />
|
||||
<path
|
||||
android:fillColor="#FF30363D"
|
||||
android:pathData="M38,66h8v10H38z" />
|
||||
<!-- Stand base -->
|
||||
<path
|
||||
android:fillColor="#FF30363D"
|
||||
android:pathData="M28,76h28v4H28z" />
|
||||
<!-- LEDs -->
|
||||
<path
|
||||
android:fillColor="#FF00FF41"
|
||||
android:pathData="M58,56a3,3,0,1,1,0,6z" />
|
||||
<path
|
||||
android:fillColor="#FFFF3333"
|
||||
android:pathData="M66,56a3,3,0,1,1,0,6z" />
|
||||
<path
|
||||
android:fillColor="#FFFFB000"
|
||||
android:pathData="M74,56a3,3,0,1,1,0,6z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_background">#0D1117</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.CBE.Emulator" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user