inital commit кек

This commit is contained in:
SashegDev
2026-06-04 03:12:17 +00:00
parent 82675f402d
commit f2888dea3a
190 changed files with 18421 additions and 21 deletions
+57
View File
@@ -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)
}
+30
View File
@@ -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>
@@ -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>