diff --git a/README.md b/README.md index 3a5a0b6..9be5b2c 100644 --- a/README.md +++ b/README.md @@ -81,13 +81,18 @@ bottom. UI updates pass through the versioned document API rather than maintaini Java with the parsers of the Kotlin embeddable compiler and Groovy with the IntelliJ Groovy parser, and returns the syntax errors and a declaration outline of a text. Visual X# has its own PSI language with a structural parser: it recognizes namespaces, `using` directives, types and members, keeps bodies opaque, and reports no - errors, because Visual X# errors come from the compiler. The editor does not show the outline or the PSI syntax - errors yet, and there is no resolution or type information. + errors, because Visual X# errors come from the compiler. The editor does not show the PSI syntax errors yet, and + there is no resolution or type information. +- **Structure tool window.** Lists the declarations of the active Visual X#, Kotlin, Java or Groovy document as an + indented list, from the outline of `SourceAnalyzer`. Selecting a declaration moves the caret to its start, and + the declaration around the caret is marked. The list is read again a moment after an edit, on a background + thread, and only while the tool window is open; a row of an older text does not navigate. Python has no parser + and therefore no structure. The window is closed when Xide starts. - **Problems and status.** The Problems tool window lists the active document's diagnostics with their severity, code and location. The status bar shows the current activity, problem counts, the caret position, the line separator, the encoding, and the language. -- **Shortcuts.** `Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+W`/`Ctrl+F4` act on files and tabs; `Alt+1` and `Alt+6` toggle - the Project and Problems tool windows. +- **Shortcuts.** `Ctrl+N`, `Ctrl+O`, `Ctrl+S`, `Ctrl+W`/`Ctrl+F4` act on files and tabs; `Alt+1`, `Alt+7` and + `Alt+6` toggle the Project, Structure and Problems tool windows. Semantic code analysis for Kotlin, Java and Groovy, resizable tool windows, and file operations in the Project tool window are not implemented yet. diff --git a/modules/app/src/main/kotlin/com/progmasoft/xide/app/ShellModel.kt b/modules/app/src/main/kotlin/com/progmasoft/xide/app/ShellModel.kt index 6213c69..3b7b76f 100644 --- a/modules/app/src/main/kotlin/com/progmasoft/xide/app/ShellModel.kt +++ b/modules/app/src/main/kotlin/com/progmasoft/xide/app/ShellModel.kt @@ -22,6 +22,7 @@ enum class ToolWindowAnchor { */ enum class ToolWindowId(val title: String, val anchor: ToolWindowAnchor) { PROJECT("Project", ToolWindowAnchor.LEFT), + STRUCTURE("Structure", ToolWindowAnchor.LEFT), PROBLEMS("Problems", ToolWindowAnchor.BOTTOM), } diff --git a/modules/app/src/main/kotlin/com/progmasoft/xide/app/Structure.kt b/modules/app/src/main/kotlin/com/progmasoft/xide/app/Structure.kt new file mode 100644 index 0000000..18ec27d --- /dev/null +++ b/modules/app/src/main/kotlin/com/progmasoft/xide/app/Structure.kt @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + */ + +package com.progmasoft.xide.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.progmasoft.xide.document.DocumentSnapshot +import com.progmasoft.xide.psi.OutlineEntry +import com.progmasoft.xide.psi.OutlineKind +import com.progmasoft.xide.psi.PsiLanguage +import com.progmasoft.xide.psi.SourceAnalyzer +import java.net.URI + +/** + * The longest text, in UTF-16 code units, whose structure is computed. + * + * The structure is parsed again after every edit. The bound is the one for colouring, for the same reason: beyond + * it the work is no longer unnoticeable. + */ +internal const val MAXIMUM_STRUCTURED_LENGTH: Int = MAXIMUM_HIGHLIGHTED_LENGTH + +/** + * The PSI language that parses this language, or null when no parser for it is connected. + * + * Python has a tokenizer for colouring and no parser, so a Python source has no structure. + */ +internal val SourceLanguage.psiLanguage: PsiLanguage? + get() = + when (this) { + SourceLanguage.VISUAL_XSHARP -> PsiLanguage.VISUAL_XSHARP + SourceLanguage.KOTLIN -> PsiLanguage.KOTLIN + SourceLanguage.JAVA -> PsiLanguage.JAVA + SourceLanguage.GROOVY -> PsiLanguage.GROOVY + SourceLanguage.PYTHON -> null + } + +/** + * One line of the Structure tool window: a declaration of the document. + * + * @property depth how many declarations enclose this one. + * @property kind what the declaration is. + * @property name the declared name. + * @property start the UTF-16 offset where the declaration starts. + * @property end the UTF-16 offset just after the declaration. + */ +data class StructureRow(val depth: Int, val kind: OutlineKind, val name: String, val start: Int, val end: Int) + +/** + * The declarations of one version of one document. + * + * The offsets of the rows address exactly that version. A consumer that uses them must confirm that the document + * still has it; see [isCurrentFor]. + * + * @property uri the identity of the document. + * @property version the snapshot version the rows were computed for. + * @property rows the declarations in source order, each after the declaration that encloses it. + */ +data class DocumentStructure(val uri: URI, val version: Long, val rows: List) { + /** Whether the rows describe the text of [snapshot]. */ + fun isCurrentFor(snapshot: DocumentSnapshot): Boolean = uri == snapshot.uri && version == snapshot.version +} + +/** The rows of an outline: every entry in source order, followed by the entries it encloses. */ +fun structureRows(outline: List): List { + val rows = ArrayList() + + fun add(entries: List, depth: Int) { + for (entry in entries) { + rows += StructureRow(depth, entry.kind, entry.name, entry.start, entry.end) + add(entry.children, depth + 1) + } + } + + add(outline, 0) + return rows +} + +/** + * The index of the innermost row whose declaration contains [offset], or null when none does. + * + * A declaration contains the offsets from its start up to and including its end, so a caret directly after a + * declaration still belongs to it. Of two declarations that both contain the offset, the one that starts later is + * the inner one, because the rows are in source order. + */ +fun enclosingRow(rows: List, offset: Int): Int? { + var found: Int? = null + for ((index, row) in rows.withIndex()) { + if (row.start > offset) break + if (offset <= row.end) found = index + } + return found +} + +/** + * Parses the text of [snapshot] and returns its declarations, or null when [language] has no parser or the text is + * longer than [MAXIMUM_STRUCTURED_LENGTH]. + * + * The call parses the whole text and waits for any other parse to finish, so it belongs on a background thread. + */ +fun documentStructure(language: SourceLanguage?, snapshot: DocumentSnapshot): DocumentStructure? { + val psiLanguage = language?.psiLanguage ?: return null + if (snapshot.text.length > MAXIMUM_STRUCTURED_LENGTH) return null + val analysis = SourceAnalyzer.analyze(psiLanguage, snapshot.text) + return DocumentStructure(snapshot.uri, snapshot.version, structureRows(analysis.outline)) +} + +/** The word that names a kind of declaration to a screen reader and in tests. */ +internal fun OutlineKind.label(): String = + when (this) { + OutlineKind.NAMESPACE -> "namespace" + OutlineKind.TYPE -> "type" + OutlineKind.CALLABLE -> "function" + OutlineKind.PROPERTY -> "property" + OutlineKind.INITIALIZER -> "initializer" + } + +/** The one-letter mark in front of a declaration's name. */ +private fun OutlineKind.mark(): String = + when (this) { + OutlineKind.NAMESPACE -> "N" + OutlineKind.TYPE -> "T" + OutlineKind.CALLABLE -> "f" + OutlineKind.PROPERTY -> "p" + OutlineKind.INITIALIZER -> "i" + } + +private fun OutlineKind.color(): Color = + when (this) { + OutlineKind.NAMESPACE -> XideColors.mutedText + OutlineKind.TYPE -> SyntaxColors.keyword + OutlineKind.CALLABLE -> SyntaxColors.interpolation + OutlineKind.PROPERTY -> SyntaxColors.number + OutlineKind.INITIALIZER -> SyntaxColors.annotation + } + +/** + * The Structure tool window: the declarations of the active document as an indented list. + * + * A row asks the shell to show its declaration. The row that contains the caret is marked. While the document is + * being edited the list shows the declarations of the last parsed version until the next parse replaces them; the + * shell declines to navigate from rows that are not current. + * + * @param structure the declarations to list, or null when none were computed for the active document. + * @param caretOffset the caret of the active document, used to mark the row that contains it. + */ +@Composable +internal fun StructureToolWindow( + document: OpenDocument?, + structure: DocumentStructure?, + caretOffset: Int, + enabled: Boolean, + onShowRow: (StructureRow) -> Unit, + onHide: () -> Unit, +) { + Column(Modifier.width(XideMetrics.projectWidth).fillMaxSize().background(XideColors.panel)) { + ToolWindowHeader(ToolWindowId.STRUCTURE.title, document?.title, onHide) + val language = document?.language + val message = + when { + document == null -> "No document is open." + language?.psiLanguage == null -> + "Structure is not available for ${language?.displayName ?: "this file"}." + document.snapshot.text.length > MAXIMUM_STRUCTURED_LENGTH -> "${document.title} is too long to show its structure." + structure == null || structure.uri != document.snapshot.uri -> "Reading ${document.title}…" + structure.rows.isEmpty() -> "${document.title} declares nothing." + else -> null + } + if (message != null || structure == null) { + BasicText(message.orEmpty(), style = XideType.uiMuted, modifier = Modifier.padding(12.dp)) + return@Column + } + val current = if (structure.isCurrentFor(document!!.snapshot)) enclosingRow(structure.rows, caretOffset) else null + LazyColumn(Modifier.fillMaxSize()) { + items(structure.rows.size) { index -> + val row = structure.rows[index] + Row( + Modifier.fillMaxWidth() + .background(if (index == current) XideColors.selection else Color.Transparent) + .semantics { + contentDescription = "${row.kind.label()} ${row.name}" + selected = index == current + } + .clickable(enabled = enabled) { onShowRow(row) } + .padding(start = 12.dp + XideMetrics.treeIndent * row.depth, end = 8.dp, top = 3.dp, bottom = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicText( + row.kind.mark(), + style = XideType.small.copy(color = row.kind.color()), + modifier = Modifier.width(XideMetrics.treeIndent), + ) + BasicText(row.name, style = XideType.ui, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} diff --git a/modules/app/src/main/kotlin/com/progmasoft/xide/app/ToolWindows.kt b/modules/app/src/main/kotlin/com/progmasoft/xide/app/ToolWindows.kt index 772e595..5a195bb 100644 --- a/modules/app/src/main/kotlin/com/progmasoft/xide/app/ToolWindows.kt +++ b/modules/app/src/main/kotlin/com/progmasoft/xide/app/ToolWindows.kt @@ -126,6 +126,7 @@ private fun StripeButton(id: ToolWindowId, layout: ToolWindowLayout, onToggle: ( Canvas(Modifier.size(16.dp)) { when (id) { ToolWindowId.PROJECT -> drawFolderGlyph(XideColors.text) + ToolWindowId.STRUCTURE -> drawStructureGlyph(XideColors.text) ToolWindowId.PROBLEMS -> drawProblemGlyph(XideColors.text) } } @@ -151,6 +152,20 @@ private fun DrawScope.drawFolderGlyph(color: Color) { ) } +/** Three lines, the lower two indented: a declaration with what it encloses. */ +private fun DrawScope.drawStructureGlyph(color: Color) { + val stroke = size.minDimension / 10f + val lines = listOf(0.10f to 0.24f, 0.34f to 0.50f, 0.34f to 0.76f) + for ((left, height) in lines) { + drawLine( + color, + Offset(size.width * left, size.height * height), + Offset(size.width * 0.92f, size.height * height), + strokeWidth = stroke, + ) + } +} + /** A circle with an exclamation mark, the usual sign for a list of problems. */ private fun DrawScope.drawProblemGlyph(color: Color) { val stroke = size.minDimension / 10f @@ -166,7 +181,7 @@ private fun DrawScope.drawProblemGlyph(color: Color) { /** The title row every tool window starts with: its name, optional detail, its own actions and a hide control. */ @Composable -private fun ToolWindowHeader(title: String, detail: String?, onHide: () -> Unit, actions: @Composable () -> Unit = {}) { +internal fun ToolWindowHeader(title: String, detail: String?, onHide: () -> Unit, actions: @Composable () -> Unit = {}) { Row( Modifier.fillMaxWidth().height(XideMetrics.headerHeight).padding(start = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -216,7 +231,7 @@ internal fun ProjectToolWindow( onRefresh: () -> Unit, onHide: () -> Unit, ) { - Column(Modifier.width(XideMetrics.projectWidth).fillMaxHeight().background(XideColors.panel)) { + Column(Modifier.width(XideMetrics.projectWidth).fillMaxSize().background(XideColors.panel)) { ToolWindowHeader(ToolWindowId.PROJECT.title, tree?.rootName, onHide) { if (tree != null) { BasicText( diff --git a/modules/app/src/main/kotlin/com/progmasoft/xide/app/XideApplication.kt b/modules/app/src/main/kotlin/com/progmasoft/xide/app/XideApplication.kt index 32d2b66..8446000 100644 --- a/modules/app/src/main/kotlin/com/progmasoft/xide/app/XideApplication.kt +++ b/modules/app/src/main/kotlin/com/progmasoft/xide/app/XideApplication.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -34,11 +35,13 @@ import androidx.compose.ui.unit.dp import com.progmasoft.xide.compiler.CompilerRequest import com.progmasoft.xide.compiler.SourceLocation import com.progmasoft.xide.compiler.VisualXSharpCompilerClient +import com.progmasoft.xide.document.TextRange import java.awt.FileDialog import java.awt.Frame import java.nio.file.Path import javax.swing.JFileChooser import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,7 +49,7 @@ import kotlinx.coroutines.withContext * The Xide desktop shell. * * The window follows the familiar IDE arrangement: a toolbar on top, a stripe of tool-window buttons on the left - * edge, the Project tool window beside it, the tabbed editor in the centre with the Problems tool window below it, + * edge, the Project and Structure tool windows beside it, the tabbed editor in the centre with the Problems tool window below it, * and a status bar at the bottom. All document state lives in [session]; the shell only renders immutable * snapshots and forwards user intent. */ @@ -70,9 +73,22 @@ fun XideApplication( var caretOffset by remember { mutableIntStateOf(0) } // The tab whose close was requested while it had unsaved changes, identified by its document URI. var pendingClose by remember { mutableStateOf(null) } + // The declarations of the active document, for the version they were last read from. + var structure by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val activeDocument = workspace.activeDocument + val structureVisible = layout.isVisible(ToolWindowId.STRUCTURE) + // The structure follows the text only while its tool window is open. An edit cancels the read that was waiting + // for it, so a burst of typing parses once, after it pauses. + LaunchedEffect(structureVisible, activeDocument?.snapshot?.uri, activeDocument?.snapshot?.version) { + val document = activeDocument + if (!structureVisible || document == null) return@LaunchedEffect + if (structure?.isCurrentFor(document.snapshot) == true) return@LaunchedEffect + if (structure?.uri == document.snapshot.uri) delay(STRUCTURE_DELAY_MILLIS) + val read = withContext(Dispatchers.Default) { documentStructure(document.language, document.snapshot) } + structure = read + } val projectRows = remember(project, projectRevision) { project?.rows().orEmpty() } val markers = remember(workspace) { workspace.activeIndex?.let(session::lineMarkers).orEmpty() } @@ -88,6 +104,19 @@ fun XideApplication( activity = "Showing ${workspace.activeDocument?.title}" } + fun showStructureRow(row: StructureRow) { + val index = workspace.activeIndex ?: return + val snapshot = workspace.activeDocument?.snapshot ?: return + if (structure?.isCurrentFor(snapshot) != true) { + // The offsets of the row belong to an earlier text; the list is replaced as soon as this one is read. + activity = "The structure is being read again" + return + } + val target = NavigationTarget(index, snapshot.uri, snapshot.version, TextRange(row.start, row.start)) + navigation = NavigationRequest((navigation?.serial ?: 0L) + 1L, target) + activity = "Showing ${row.name}" + } + fun openPath(path: Path) { scope.launch { busy = true @@ -239,6 +268,7 @@ fun XideApplication( if (event.isAltPressed && !event.isCtrlPressed) { when (event.key) { Key.One -> layout = layout.toggle(ToolWindowId.PROJECT) + Key.Seven -> layout = layout.toggle(ToolWindowId.STRUCTURE) Key.Six -> layout = layout.toggle(ToolWindowId.PROBLEMS) else -> return false } @@ -272,18 +302,39 @@ fun XideApplication( Row(Modifier.weight(1f).fillMaxWidth()) { ToolWindowStripe(layout, onToggle = { layout = layout.toggle(it) }) VerticalBorder() - if (layout.isVisible(ToolWindowId.PROJECT)) { - ProjectToolWindow( - tree = project, - rows = projectRows, - activeFile = activeDocument?.filePath, - enabled = !busy, - onToggleDirectory = ::toggleDirectory, - onOpenFile = ::openProjectEntry, - onOpenFolder = ::openFolder, - onRefresh = ::refreshProject, - onHide = { layout = layout.toggle(ToolWindowId.PROJECT) }, - ) + val projectVisible = layout.isVisible(ToolWindowId.PROJECT) + if (projectVisible || structureVisible) { + // The left tool windows share one column; two open ones divide its height equally. + Column(Modifier.width(XideMetrics.projectWidth).fillMaxHeight()) { + if (projectVisible) { + Box(Modifier.weight(1f)) { + ProjectToolWindow( + tree = project, + rows = projectRows, + activeFile = activeDocument?.filePath, + enabled = !busy, + onToggleDirectory = ::toggleDirectory, + onOpenFile = ::openProjectEntry, + onOpenFolder = ::openFolder, + onRefresh = ::refreshProject, + onHide = { layout = layout.toggle(ToolWindowId.PROJECT) }, + ) + } + } + if (projectVisible && structureVisible) HorizontalBorder() + if (structureVisible) { + Box(Modifier.weight(1f)) { + StructureToolWindow( + document = activeDocument, + structure = structure, + caretOffset = caretOffset, + enabled = !busy, + onShowRow = ::showStructureRow, + onHide = { layout = layout.toggle(ToolWindowId.STRUCTURE) }, + ) + } + } + } VerticalBorder() } Column(Modifier.weight(1f).fillMaxHeight()) { @@ -345,6 +396,9 @@ fun XideApplication( } } +/** How long the structure waits after an edit before it is read again, so that typing does not parse per key. */ +private const val STRUCTURE_DELAY_MILLIS: Long = 200 + @Composable private fun HorizontalBorder() { Box(Modifier.fillMaxWidth().height(1.dp).background(XideColors.border)) diff --git a/modules/app/src/test/kotlin/com/progmasoft/xide/app/ShellModelTest.kt b/modules/app/src/test/kotlin/com/progmasoft/xide/app/ShellModelTest.kt index 2f2b155..5062355 100644 --- a/modules/app/src/test/kotlin/com/progmasoft/xide/app/ShellModelTest.kt +++ b/modules/app/src/test/kotlin/com/progmasoft/xide/app/ShellModelTest.kt @@ -59,7 +59,10 @@ class ShellModelTest { fun everyToolWindowHasAnEdgeAndATitle() { assertEquals(ToolWindowAnchor.LEFT, ToolWindowId.PROJECT.anchor) assertEquals(ToolWindowAnchor.BOTTOM, ToolWindowId.PROBLEMS.anchor) - assertEquals(listOf("Project", "Problems"), ToolWindowId.entries.map { it.title }) + assertEquals(ToolWindowAnchor.LEFT, ToolWindowId.STRUCTURE.anchor) + assertEquals(listOf("Project", "Structure", "Problems"), ToolWindowId.entries.map { it.title }) + // Structure is opened on demand; the shell starts with the two windows it always had. + assertEquals(setOf(ToolWindowId.PROJECT, ToolWindowId.PROBLEMS), ToolWindowLayout().visible) } @Test diff --git a/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureTest.kt b/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureTest.kt new file mode 100644 index 0000000..242b45b --- /dev/null +++ b/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureTest.kt @@ -0,0 +1,155 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + */ + +package com.progmasoft.xide.app + +import com.progmasoft.xide.document.DocumentSnapshot +import com.progmasoft.xide.psi.OutlineEntry +import com.progmasoft.xide.psi.OutlineKind +import com.progmasoft.xide.psi.PsiLanguage +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StructureTest { + private val uri = URI("file:///work/Source") + + private fun snapshot(text: String, version: Long = 0) = DocumentSnapshot(uri, version, text) + + @Test + fun everyLanguageWithAParserHasAStructure() { + assertEquals(PsiLanguage.VISUAL_XSHARP, SourceLanguage.VISUAL_XSHARP.psiLanguage) + assertEquals(PsiLanguage.KOTLIN, SourceLanguage.KOTLIN.psiLanguage) + assertEquals(PsiLanguage.JAVA, SourceLanguage.JAVA.psiLanguage) + assertEquals(PsiLanguage.GROOVY, SourceLanguage.GROOVY.psiLanguage) + // Python is coloured by a tokenizer and has no parser. + assertNull(SourceLanguage.PYTHON.psiLanguage) + } + + @Test + fun rowsFollowSourceOrderWithEnclosedDeclarationsAfterTheirOwner() { + val outline = + listOf( + OutlineEntry(OutlineKind.NAMESPACE, "Demo", 0, 10), + OutlineEntry( + OutlineKind.TYPE, + "Outer", + 12, + 90, + listOf( + OutlineEntry(OutlineKind.PROPERTY, "count", 20, 30), + OutlineEntry(OutlineKind.TYPE, "Inner", 32, 70, listOf(OutlineEntry(OutlineKind.CALLABLE, "Run", 40, 60))), + OutlineEntry(OutlineKind.INITIALIZER, "init", 72, 88), + ), + ), + ) + assertEquals( + listOf( + StructureRow(0, OutlineKind.NAMESPACE, "Demo", 0, 10), + StructureRow(0, OutlineKind.TYPE, "Outer", 12, 90), + StructureRow(1, OutlineKind.PROPERTY, "count", 20, 30), + StructureRow(1, OutlineKind.TYPE, "Inner", 32, 70), + StructureRow(2, OutlineKind.CALLABLE, "Run", 40, 60), + StructureRow(1, OutlineKind.INITIALIZER, "init", 72, 88), + ), + structureRows(outline), + ) + assertEquals(emptyList(), structureRows(emptyList())) + } + + @Test + fun theEnclosingRowIsTheInnermostDeclarationAroundAnOffset() { + val rows = + listOf( + StructureRow(0, OutlineKind.TYPE, "Outer", 12, 90), + StructureRow(1, OutlineKind.PROPERTY, "count", 20, 30), + StructureRow(1, OutlineKind.TYPE, "Inner", 32, 70), + StructureRow(2, OutlineKind.CALLABLE, "Run", 40, 60), + StructureRow(0, OutlineKind.TYPE, "Later", 100, 120), + ) + // Before the first declaration, between two, and after the last one nothing encloses the offset. + assertNull(enclosingRow(rows, 0)) + assertNull(enclosingRow(rows, 95)) + assertNull(enclosingRow(rows, 121)) + assertNull(enclosingRow(emptyList(), 5)) + + assertEquals(0, enclosingRow(rows, 12)) + assertEquals(0, enclosingRow(rows, 15)) + assertEquals(1, enclosingRow(rows, 20)) + assertEquals(1, enclosingRow(rows, 30)) + // Between two members the owner encloses the offset. + assertEquals(0, enclosingRow(rows, 31)) + assertEquals(2, enclosingRow(rows, 35)) + assertEquals(3, enclosingRow(rows, 50)) + assertEquals(2, enclosingRow(rows, 65)) + assertEquals(0, enclosingRow(rows, 80)) + assertEquals(0, enclosingRow(rows, 90)) + assertEquals(4, enclosingRow(rows, 100)) + assertEquals(4, enclosingRow(rows, 120)) + } + + @Test + fun aStructureIsCurrentOnlyForTheVersionItWasReadFrom() { + val structure = DocumentStructure(uri, 3, emptyList()) + assertTrue(structure.isCurrentFor(snapshot("", 3))) + assertFalse(structure.isCurrentFor(snapshot("", 4))) + assertFalse(structure.isCurrentFor(DocumentSnapshot(URI("file:///work/Other"), 3, ""))) + } + + @Test + fun theStructureOfAVisualXSharpSourceListsItsDeclarations() { + val text = "namespace Demo;\npublic class Program {\n int count;\n public static void Main() { }\n}\n" + val structure = documentStructure(SourceLanguage.VISUAL_XSHARP, snapshot(text, 7))!! + assertEquals(uri, structure.uri) + assertEquals(7, structure.version) + assertEquals( + listOf( + Triple(0, OutlineKind.NAMESPACE, "Demo"), + Triple(0, OutlineKind.TYPE, "Program"), + Triple(1, OutlineKind.PROPERTY, "count"), + Triple(1, OutlineKind.CALLABLE, "Main"), + ), + structure.rows.map { Triple(it.depth, it.kind, it.name) }, + ) + // Every row addresses the text it was read from, and a member lies inside its owner. + val program = structure.rows[1] + assertEquals("public class Program", text.substring(program.start, program.start + 20)) + for (row in structure.rows) assertTrue(row.start in 0..row.end && row.end <= text.length) + for (member in structure.rows.drop(2)) assertTrue(member.start >= program.start && member.end <= program.end) + } + + @Test + fun kotlinJavaAndGroovySourcesHaveAStructure() { + val kotlin = documentStructure(SourceLanguage.KOTLIN, snapshot("class A { fun run() {} }\nval top = 1\n"))!! + assertEquals(listOf("A", "run", "top"), kotlin.rows.map { it.name }) + val java = documentStructure(SourceLanguage.JAVA, snapshot("class A { int n; void run() {} }\n"))!! + assertEquals(listOf("A", "n", "run"), java.rows.map { it.name }) + val groovy = documentStructure(SourceLanguage.GROOVY, snapshot("class A { def run() {} }\n"))!! + assertEquals(listOf("A", "run"), groovy.rows.map { it.name }) + } + + @Test + fun aTextThatDoesNotParseStillHasTheStructureThatWasRecognized() { + val structure = documentStructure(SourceLanguage.KOTLIN, snapshot("class A { fun run( }\nclass B\n"))!! + assertTrue("A" in structure.rows.map { it.name }) + } + + @Test + fun noStructureIsReadWithoutAParserOrForAVeryLongText() { + assertNull(documentStructure(SourceLanguage.PYTHON, snapshot("def run():\n pass\n"))) + assertNull(documentStructure(null, snapshot("anything"))) + val long = "-- filler\n".repeat(MAXIMUM_STRUCTURED_LENGTH / 10 + 1) + assertNull(documentStructure(SourceLanguage.VISUAL_XSHARP, snapshot(long))) + assertEquals(emptyList(), documentStructure(SourceLanguage.VISUAL_XSHARP, snapshot(""))!!.rows) + } + + @Test + fun everyKindHasAWordOfItsOwn() { + assertEquals(OutlineKind.entries.size, OutlineKind.entries.map { it.label() }.distinct().size) + } +} diff --git a/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureUiTest.kt b/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureUiTest.kt new file mode 100644 index 0000000..1053816 --- /dev/null +++ b/modules/app/src/test/kotlin/com/progmasoft/xide/app/StructureUiTest.kt @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2026 Progmasoft + * SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1 + */ + +package com.progmasoft.xide.app + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.test.withKeyDown +import androidx.compose.ui.text.TextRange +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Drives the Structure tool window of the real Compose shell. + * + * The model tests prove how an outline becomes rows; these prove that the tool window is wired to the document: + * it lists the declarations of the active document, follows an edit, moves the caret to a clicked declaration and + * marks the declaration around the caret. + */ +@OptIn(ExperimentalTestApi::class) +class StructureUiTest { + private val source = "namespace Demo;\npublic class Program {\n int count;\n public static void Main() { }\n}\n" + + private fun session(name: String, text: String): WorkspaceSession { + val file = createTempDirectory("xide-structure").resolve(name) + Files.writeString(file, text) + return WorkspaceSession().also { it.openFile(file) } + } + + private fun ComposeUiTest.awaitDescription(description: String) { + waitUntil(timeoutMillis = 20_000) { onAllNodesWithContentDescription(description).fetchSemanticsNodes().isNotEmpty() } + } + + private fun ComposeUiTest.awaitNoDescription(description: String) { + waitUntil(timeoutMillis = 20_000) { onAllNodesWithContentDescription(description).fetchSemanticsNodes().isEmpty() } + } + + @Test + fun theStructureToolWindowListsTheDeclarationsOfTheActiveDocument() = runComposeUiTest { + setContent { XideApplication(owner = null, session = session("Main.vxs", source)) } + + // The tool window is closed until it is asked for. + onNodeWithContentDescription("type Program").assertDoesNotExist() + onNodeWithContentDescription("Structure tool window").performClick() + + awaitDescription("type Program") + onNodeWithContentDescription("namespace Demo").assertExists() + onNodeWithContentDescription("property count").assertExists() + onNodeWithContentDescription("function Main").assertExists() + + onNodeWithContentDescription("Hide Structure").performClick() + waitForIdle() + onNodeWithContentDescription("type Program").assertDoesNotExist() + } + + @Test + fun clickingADeclarationMovesTheCaretToItAndMarksIt() = runComposeUiTest { + setContent { XideApplication(owner = null, session = session("Main.vxs", source)) } + val editor = onNode(hasSetTextAction()) + fun selection(): TextRange? = editor.fetchSemanticsNode().config.getOrNull(SemanticsProperties.TextSelectionRange) + + onNodeWithContentDescription("Structure tool window").performClick() + awaitDescription("function Main") + // The caret starts in the namespace declaration. + onNodeWithContentDescription("namespace Demo").assertIsSelected() + onNodeWithContentDescription("function Main").assertIsNotSelected() + + onNodeWithContentDescription("function Main").performClick() + waitForIdle() + val main = source.indexOf("public static void Main") + assertEquals(TextRange(main, main), selection()) + onNodeWithText("Showing Main").assertExists() + onNodeWithContentDescription("function Main").assertIsSelected() + onNodeWithContentDescription("namespace Demo").assertIsNotSelected() + + onNodeWithContentDescription("property count").performClick() + waitForIdle() + val count = source.indexOf("int count") + assertEquals(TextRange(count, count), selection()) + onNodeWithContentDescription("property count").assertIsSelected() + } + + @Test + fun theStructureFollowsAnEdit() = runComposeUiTest { + setContent { XideApplication(owner = null, session = session("Main.vxs", source)) } + onNodeWithContentDescription("Structure tool window").performClick() + awaitDescription("function Main") + + onNode(hasSetTextAction()).performTextReplacement("namespace Other;\nclass Renamed { void Run() { } }\n") + awaitDescription("type Renamed") + awaitNoDescription("type Program") + onNodeWithContentDescription("namespace Other").assertExists() + onNodeWithContentDescription("function Run").assertExists() + onNodeWithContentDescription("function Main").assertDoesNotExist() + } + + @Test + fun kotlinSourcesHaveAStructure() = runComposeUiTest { + val session = session("App.kt", "class App { fun start() {} }\n") + setContent { XideApplication(owner = null, session = session) } + onNodeWithContentDescription("Structure tool window").performClick() + awaitDescription("type App") + onNodeWithContentDescription("function start").assertExists() + } + + @Test + fun aLanguageWithoutAParserSaysThatItHasNoStructure() = runComposeUiTest { + setContent { XideApplication(owner = null, session = session("tool.py", "def run():\n pass\n")) } + onNodeWithContentDescription("Structure tool window").performClick() + waitForIdle() + onNodeWithText("Structure is not available for Python.").assertExists() + } + + @Test + fun altSevenOpensAndClosesTheToolWindow() = runComposeUiTest { + setContent { XideApplication(owner = null, session = session("Main.vxs", source)) } + // Key events are delivered to the focused node, so give the editor focus first. + onNode(hasSetTextAction()).performClick() + onNode(hasSetTextAction()).performKeyInput { withKeyDown(Key.AltLeft) { pressKey(Key.Seven) } } + awaitDescription("function Main") + onNode(hasSetTextAction()).performKeyInput { withKeyDown(Key.AltLeft) { pressKey(Key.Seven) } } + waitForIdle() + onNodeWithContentDescription("function Main").assertDoesNotExist() + } + + @Test + fun withoutADocumentTheToolWindowSaysSo() = runComposeUiTest { + setContent { XideApplication(owner = null, session = WorkspaceSession()) } + onNodeWithContentDescription("Structure tool window").performClick() + waitForIdle() + // The Problems tool window shows the same sentence. + assertEquals(2, onAllNodesWithText("No document is open.").fetchSemanticsNodes().size) + } +}