| @@ -0,0 +1,338 @@ |
| 1 | + package cc.vectrum.code.ui.panels |
| 2 | + |
| 3 | + import androidx.compose.foundation.Canvas |
| 4 | + import androidx.compose.foundation.gestures.detectTapGestures |
| 5 | + import androidx.compose.foundation.layout.Arrangement |
| 6 | + import androidx.compose.foundation.layout.Box |
| 7 | + import androidx.compose.foundation.layout.BoxWithConstraints |
| 8 | + import androidx.compose.foundation.layout.Column |
| 9 | + import androidx.compose.foundation.layout.Row |
| 10 | + import androidx.compose.foundation.layout.fillMaxSize |
| 11 | + import androidx.compose.foundation.layout.fillMaxWidth |
| 12 | + import androidx.compose.foundation.layout.height |
| 13 | + import androidx.compose.foundation.layout.offset |
| 14 | + import androidx.compose.foundation.layout.padding |
| 15 | + import androidx.compose.foundation.layout.size |
| 16 | + import androidx.compose.foundation.layout.width |
| 17 | + import androidx.compose.foundation.rememberScrollState |
| 18 | + import androidx.compose.foundation.shape.CircleShape |
| 19 | + import androidx.compose.foundation.shape.RoundedCornerShape |
| 20 | + import androidx.compose.foundation.verticalScroll |
| 21 | + import androidx.compose.foundation.background |
| 22 | + import androidx.compose.runtime.Composable |
| 23 | + import androidx.compose.runtime.LaunchedEffect |
| 24 | + import androidx.compose.runtime.getValue |
| 25 | + import androidx.compose.runtime.mutableStateOf |
| 26 | + import androidx.compose.runtime.remember |
| 27 | + import androidx.compose.runtime.setValue |
| 28 | + import androidx.compose.ui.Alignment |
| 29 | + import androidx.compose.ui.Modifier |
| 30 | + import androidx.compose.ui.draw.clip |
| 31 | + import androidx.compose.ui.geometry.Offset |
| 32 | + import androidx.compose.ui.graphics.Color |
| 33 | + import androidx.compose.ui.graphics.Path |
| 34 | + import androidx.compose.ui.graphics.drawscope.Stroke |
| 35 | + import androidx.compose.ui.input.pointer.pointerInput |
| 36 | + import androidx.compose.ui.platform.LocalDensity |
| 37 | + import androidx.compose.ui.text.font.FontWeight |
| 38 | + import androidx.compose.ui.unit.Dp |
| 39 | + import androidx.compose.ui.unit.dp |
| 40 | + import androidx.compose.ui.unit.sp |
| 41 | + import cc.vectrum.code.data.AppState |
| 42 | + import cc.vectrum.code.data.ThreadRef |
| 43 | + import cc.vectrum.code.net.Genome |
| 44 | + import cc.vectrum.code.net.GenomeFile |
| 45 | + import cc.vectrum.code.theme.Mono |
| 46 | + import cc.vectrum.code.theme.Palette |
| 47 | + import cc.vectrum.code.theme.Rule |
| 48 | + import cc.vectrum.code.theme.Tag |
| 49 | + import cc.vectrum.code.theme.Txt |
| 50 | + import cc.vectrum.code.theme.palette |
| 51 | + import cc.vectrum.code.theme.well |
| 52 | + import cc.vectrum.code.ui.Lucide |
| 53 | + import cc.vectrum.code.ui.Nav |
| 54 | + import cc.vectrum.code.ui.sidebar.pressTint |
| 55 | + import kotlinx.coroutines.delay |
| 56 | + import kotlin.math.max |
| 57 | + import kotlin.math.sqrt |
| 58 | + |
| 59 | + // The Genome map (src/ui/workspace/genome.rs): Empryo's ranked map of the |
| 60 | + // thread's checkout, which the host reads and sends over. The graph shows |
| 61 | + // the highest ranked files, sized by rank and colored by folder, with a |
| 62 | + // line for each import; the list below ranks them, and a file opens to its |
| 63 | + // symbols. While it shows, it asks the host every few seconds whether |
| 64 | + // Empryo changed the map. |
| 65 | + |
| 66 | + /** How often an open map asks for Empryo's updates. */ |
| 67 | + private const val WATCH_EVERY = 3_000L |
| 68 | + |
| 69 | + /** How many files the map labels by name. */ |
| 70 | + private const val LABELED = 6 |
| 71 | + |
| 72 | + private val MapHeight = 260.dp |
| 73 | + |
| 74 | + /** What the panel shows. */ |
| 75 | + private sealed interface GenomeLoad { |
| 76 | + data object Loading : GenomeLoad |
| 77 | + |
| 78 | + /** Empryo hasn't mapped this checkout yet. */ |
| 79 | + data object Missing : GenomeLoad |
| 80 | + |
| 81 | + data class Failed(val error: String) : GenomeLoad |
| 82 | + |
| 83 | + data class Ready(val genome: Genome) : GenomeLoad |
| 84 | + } |
| 85 | + |
| 86 | + /** Whether a thread runs on Empryo, which keeps a Genome. */ |
| 87 | + fun hasGenome(app: AppState, ref: ThreadRef) = app.thread(ref)?.provider == "empryo" |
| 88 | + |
| 89 | + @Composable |
| 90 | + fun GenomePanel(app: AppState, nav: Nav, ref: ThreadRef) { |
| 91 | + val p = palette |
| 92 | + val host = app.host(ref.host) |
| 93 | + val project = app.thread(ref)?.project.orEmpty() |
| 94 | + var load by remember(ref) { mutableStateOf<GenomeLoad>(GenomeLoad.Loading) } |
| 95 | + var written by remember(ref) { mutableStateOf<Long?>(null) } |
| 96 | + var selected by remember(ref) { mutableStateOf<String?>(null) } |
| 97 | + |
| 98 | + LaunchedEffect(ref, host) { |
| 99 | + if (host == null) return@LaunchedEffect |
| 100 | + while (true) { |
| 101 | + val answer = host.genome(ref.thread, project, written) |
| 102 | + written = answer.written |
| 103 | + if (!answer.unchanged) { |
| 104 | + load = when { |
| 105 | + answer.error != null -> GenomeLoad.Failed(answer.error) |
| 106 | + answer.genome != null -> GenomeLoad.Ready(answer.genome) |
| 107 | + else -> GenomeLoad.Missing |
| 108 | + } |
| 109 | + } |
| 110 | + delay(WATCH_EVERY) |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + val genome = (load as? GenomeLoad.Ready)?.genome |
| 115 | + Column(Modifier.fillMaxSize()) { |
| 116 | + PanelHeader( |
| 117 | + Lucide.Dna, |
| 118 | + "Genome", |
| 119 | + tag = genome?.let { "${it.totalFiles} files · ${it.totalSymbols} symbols" }, |
| 120 | + ) { nav.surface = null } |
| 121 | + Box(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) { Rule() } |
| 122 | + when (val state = load) { |
| 123 | + GenomeLoad.Loading -> Message("Reading the Genome…", null) |
| 124 | + GenomeLoad.Missing -> Message( |
| 125 | + "No Genome here yet", |
| 126 | + "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here.", |
| 127 | + ) |
| 128 | + is GenomeLoad.Failed -> Message("Couldn't read the Genome", state.error) |
| 129 | + is GenomeLoad.Ready -> { |
| 130 | + val selectedIx = state.genome.files.indexOfFirst { it.path == selected }.takeIf { it >= 0 } |
| 131 | + Column( |
| 132 | + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 6.dp, vertical = 4.dp), |
| 133 | + verticalArrangement = Arrangement.spacedBy(2.dp), |
| 134 | + ) { |
| 135 | + GenomeMap(state.genome, selectedIx) { ix -> |
| 136 | + val path = state.genome.files[ix].path |
| 137 | + selected = if (selected == path) null else path |
| 138 | + } |
| 139 | + Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { |
| 140 | + Txt( |
| 141 | + "Top ${state.genome.files.size} of ${state.genome.totalFiles} files by rank · ${state.genome.totalEdges} imports", |
| 142 | + Modifier.weight(1f), |
| 143 | + color = p.ink3, |
| 144 | + size = 11.5.sp, |
| 145 | + ) |
| 146 | + written?.let { Txt("updated ${timeOfDay(it)}", color = p.ink3, size = 11.5.sp) } |
| 147 | + } |
| 148 | + Box(Modifier.padding(horizontal = 8.dp)) { Rule() } |
| 149 | + state.genome.files.forEachIndexed { ix, file -> |
| 150 | + FileRow(ix, file, open = selectedIx == ix) { |
| 151 | + selected = if (selected == file.path) null else file.path |
| 152 | + } |
| 153 | + } |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + @Composable |
| 161 | + private fun Message(title: String, detail: String?) { |
| 162 | + val p = palette |
| 163 | + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 164 | + Txt(title, color = p.ink2, size = 13.sp) |
| 165 | + if (!detail.isNullOrBlank()) Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp) |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * The import graph: a dot per file, sized by rank and colored by folder, |
| 171 | + * with a line for each import. The file opened in the list lights up with |
| 172 | + * its neighbors; tapping a dot opens its file. |
| 173 | + */ |
| 174 | + @Composable |
| 175 | + private fun GenomeMap(genome: Genome, focus: Int?, onTap: (Int) -> Unit) { |
| 176 | + val p = palette |
| 177 | + val density = LocalDensity.current |
| 178 | + val maxRank = max(genome.files.maxOfOrNull { it.rank } ?: 0.0, 1e-9) |
| 179 | + fun radius(file: GenomeFile): Dp = (3.5 + 8.5 * sqrt(file.rank / maxRank)).dp |
| 180 | + val neighbors = focus?.let { f -> genome.edges.mapNotNull { if (it.from == f) it.to else if (it.to == f) it.from else null }.toSet() }.orEmpty() |
| 181 | + BoxWithConstraints( |
| 182 | + Modifier |
| 183 | + .fillMaxWidth() |
| 184 | + .height(MapHeight) |
| 185 | + .padding(horizontal = 4.dp) |
| 186 | + .well(p, RoundedCornerShape(12.dp)) |
| 187 | + .clip(RoundedCornerShape(12.dp)), |
| 188 | + ) { |
| 189 | + val inset = 16.dp |
| 190 | + val width = maxWidth |
| 191 | + fun spot(ix: Int): Pair<Dp, Dp> { |
| 192 | + val xy = genome.layout.getOrNull(ix) ?: listOf(0.5f, 0.5f) |
| 193 | + return Pair(inset + (width - inset * 2) * xy[0], inset + (MapHeight - inset * 2) * xy.getOrElse(1) { 0.5f }) |
| 194 | + } |
| 195 | + val dim = p.ink3.copy(alpha = 0.3f) |
| 196 | + val lit = p.tints[2 % p.tints.size].copy(alpha = 0.9f) |
| 197 | + Canvas( |
| 198 | + Modifier.fillMaxSize().pointerInput(genome) { |
| 199 | + detectTapGestures { tap -> |
| 200 | + val hit = genome.files.indices.minByOrNull { ix -> |
| 201 | + val (x, y) = spot(ix) |
| 202 | + val dx = x.toPx() - tap.x |
| 203 | + val dy = y.toPx() - tap.y |
| 204 | + dx * dx + dy * dy |
| 205 | + } ?: return@detectTapGestures |
| 206 | + val (x, y) = spot(hit) |
| 207 | + val reach = radius(genome.files[hit]).toPx() + 14.dp.toPx() |
| 208 | + val dx = x.toPx() - tap.x |
| 209 | + val dy = y.toPx() - tap.y |
| 210 | + if (dx * dx + dy * dy <= reach * reach) onTap(hit) |
| 211 | + } |
| 212 | + }, |
| 213 | + ) { |
| 214 | + for (edge in genome.edges) { |
| 215 | + if (edge.from !in genome.files.indices || edge.to !in genome.files.indices) continue |
| 216 | + val on = focus != null && (edge.from == focus || edge.to == focus) |
| 217 | + val (x1, y1) = spot(edge.from) |
| 218 | + val (x2, y2) = spot(edge.to) |
| 219 | + val from = Offset(x1.toPx(), y1.toPx()) |
| 220 | + val to = Offset(x2.toPx(), y2.toPx()) |
| 221 | + // A slight bend tells crossing lines apart. |
| 222 | + val mid = Offset((from.x + to.x) / 2 + (to.y - from.y) * 0.12f, (from.y + to.y) / 2 - (to.x - from.x) * 0.12f) |
| 223 | + val path = Path().apply { |
| 224 | + moveTo(from.x, from.y) |
| 225 | + quadraticTo(mid.x, mid.y, to.x, to.y) |
| 226 | + } |
| 227 | + drawPath(path, if (on) lit else dim, style = Stroke(width = (if (on) 1.6f else 1f) * density.density)) |
| 228 | + } |
| 229 | + genome.files.forEachIndexed { ix, file -> |
| 230 | + val (x, y) = spot(ix) |
| 231 | + val center = Offset(x.toPx(), y.toPx()) |
| 232 | + val r = radius(file).toPx() |
| 233 | + val faded = focus != null && ix != focus && ix !in neighbors |
| 234 | + val color = folderColor(file.path, p) |
| 235 | + drawCircle(if (faded) color.copy(alpha = 0.3f) else color, r, center) |
| 236 | + if (ix == focus) drawCircle(p.ink, r, center, style = Stroke(width = 2 * density.density)) |
| 237 | + else if (ix in neighbors) drawCircle(lit, r, center, style = Stroke(width = density.density)) |
| 238 | + } |
| 239 | + } |
| 240 | + // Names for the top files, and for the one in focus. |
| 241 | + val labeled = ((0 until minOf(LABELED, genome.files.size)) + listOfNotNull(focus)).distinct() |
| 242 | + for (ix in labeled) { |
| 243 | + val file = genome.files[ix] |
| 244 | + val (x, y) = spot(ix) |
| 245 | + val name = file.path.substringAfterLast('/') |
| 246 | + val labelWidth = minOf((name.length * 6.2 + 10).dp, 150.dp) |
| 247 | + val left = (x - labelWidth / 2).coerceIn(2.dp, width - labelWidth - 2.dp) |
| 248 | + val r = radius(file) |
| 249 | + val below = y + r + 18.dp < MapHeight |
| 250 | + Box( |
| 251 | + Modifier |
| 252 | + .offset(left, if (below) y + r + 2.dp else y - r - 17.dp) |
| 253 | + .width(labelWidth), |
| 254 | + contentAlignment = Alignment.Center, |
| 255 | + ) { |
| 256 | + Txt( |
| 257 | + name, |
| 258 | + Modifier.background(p.page.copy(alpha = 0.75f), RoundedCornerShape(4.dp)).padding(horizontal = 4.dp), |
| 259 | + color = if (ix == focus) p.ink else p.ink2, |
| 260 | + size = 10.5.sp, |
| 261 | + family = Mono, |
| 262 | + maxLines = 1, |
| 263 | + ) |
| 264 | + } |
| 265 | + } |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + /** A ranked file: its rank, folder color, path, whether it's new and how many files import it; open, its symbols. */ |
| 270 | + @Composable |
| 271 | + private fun FileRow(ix: Int, file: GenomeFile, open: Boolean, onClick: () -> Unit) { |
| 272 | + val p = palette |
| 273 | + val dir = file.path.substringBeforeLast('/', "").let { if (it.isEmpty()) "" else "$it/" } |
| 274 | + val name = file.path.substringAfterLast('/') |
| 275 | + Column { |
| 276 | + Row( |
| 277 | + Modifier |
| 278 | + .fillMaxWidth() |
| 279 | + .height(40.dp) |
| 280 | + .then(if (open) Modifier.background(p.rowHover, RoundedCornerShape(8.dp)) else Modifier) |
| 281 | + .pressTint(8.dp, onClick) |
| 282 | + .padding(horizontal = 10.dp), |
| 283 | + horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 284 | + verticalAlignment = Alignment.CenterVertically, |
| 285 | + ) { |
| 286 | + Txt("${ix + 1}", Modifier.width(20.dp), color = p.ink3, size = 10.5.sp, family = Mono) |
| 287 | + Box(Modifier.size(8.dp).background(folderColor(file.path, p), CircleShape)) |
| 288 | + Row(Modifier.weight(1f)) { |
| 289 | + Txt(dir, Modifier.weight(1f, fill = false), color = p.ink3, size = 13.sp, maxLines = 1) |
| 290 | + Txt(name, color = p.ink, size = 13.sp, maxLines = 1) |
| 291 | + } |
| 292 | + if (file.recent) Tag("new") |
| 293 | + if (file.dependents > 0) Txt("→${file.dependents}", color = p.ink2, size = 11.sp, family = Mono) |
| 294 | + } |
| 295 | + if (open) { |
| 296 | + Column( |
| 297 | + Modifier.fillMaxWidth().padding(start = 46.dp, end = 10.dp, bottom = 8.dp), |
| 298 | + verticalArrangement = Arrangement.spacedBy(3.dp), |
| 299 | + ) { |
| 300 | + Txt( |
| 301 | + "${file.language} · ${file.lines} lines · imported by ${file.dependents} · imports ${file.dependencies}", |
| 302 | + color = p.ink3, |
| 303 | + size = 11.5.sp, |
| 304 | + ) |
| 305 | + if (file.symbols.isEmpty()) Txt("No symbols", color = p.ink3, size = 12.sp) |
| 306 | + for (symbol in file.symbols) { |
| 307 | + val signature = symbol.signature?.lineSequence()?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } |
| 308 | + ?: "${symbol.kind} ${symbol.name}" |
| 309 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { |
| 310 | + Txt( |
| 311 | + if (symbol.exported) "+" else " ", |
| 312 | + Modifier.width(10.dp), |
| 313 | + color = if (symbol.exported) p.greenInk else p.ink3, |
| 314 | + size = 11.5.sp, |
| 315 | + family = Mono, |
| 316 | + ) |
| 317 | + Txt(signature, Modifier.weight(1f), color = p.ink2, size = 11.5.sp, family = Mono, maxLines = 1) |
| 318 | + Txt(":${symbol.line}", color = p.ink3, size = 11.5.sp, family = Mono) |
| 319 | + } |
| 320 | + } |
| 321 | + val more = file.symbolCount - file.symbols.size |
| 322 | + if (more > 0) Txt("… $more more", color = p.ink3, size = 11.5.sp) |
| 323 | + } |
| 324 | + } |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + /** A file's color in the map: one of the palette's tints, the same for every file in a folder. */ |
| 329 | + private fun folderColor(path: String, p: Palette): Color { |
| 330 | + val folder = path.substringBeforeLast('/', "") |
| 331 | + // The desktop's hash, so a folder has the same color on both. |
| 332 | + var hash = 7u |
| 333 | + for (byte in folder.encodeToByteArray()) hash = hash * 31u + byte.toUByte().toUInt() |
| 334 | + return p.tints[(hash % p.tints.size.toUInt()).toInt()] |
| 335 | + } |
| 336 | + |
| 337 | + private fun timeOfDay(millis: Long): String = |
| 338 | + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(millis)) |