Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down
218 changes: 218 additions & 0 deletions modules/app/src/main/kotlin/com/progmasoft/xide/app/Structure.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
* SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
* 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<StructureRow>) {
/** 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<OutlineEntry>): List<StructureRow> {
val rows = ArrayList<StructureRow>()

fun add(entries: List<OutlineEntry>, 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<StructureRow>, 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)
}
}
}
}
}
19 changes: 17 additions & 2 deletions modules/app/src/main/kotlin/com/progmasoft/xide/app/ToolWindows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading