Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.utils.viewLifecycleScope
import com.itsaky.androidide.viewmodel.EmptyStateFragmentViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand All @@ -31,22 +32,28 @@ abstract class EmptyStateFragment<T : ViewBinding> : FragmentWithBinding<T> {
protected val emptyStateViewModel by viewModels<EmptyStateFragmentViewModel>()

private var gestureDetector: GestureDetector? = null

// Cache the last known empty state to avoid returning incorrect default when detached
// Volatile ensures thread-safe visibility and atomicity for boolean reads/writes
@Volatile
private var cachedIsEmpty: Boolean = true

@Volatile
private var cachedIsSourceEmpty: Boolean = true

open val currentEditor: IDEEditor? get() = null

/**
* Called when a long press is detected on the fragment's root view.
* Subclasses must implement this to define the action (e.g., show a tooltip).
*/
open fun onFragmentLongPressed(x: Float = -1f, y: Float = -1f) {
open fun onFragmentLongPressed(
x: Float = -1f,
y: Float = -1f,
) {
currentEditor?.let { editor ->
if (x >= 0 && y >= 0) {
editor.setSelectionFromPoint(x, y)
if (x >= 0 && y >= 0) {
editor.setSelectionFromPoint(x, y)
}
}
onFragmentLongPressed()
Expand All @@ -65,6 +72,29 @@ abstract class EmptyStateFragment<T : ViewBinding> : FragmentWithBinding<T> {
}
}

/**
* Whether the underlying data source has no content at all, independent of any filter UI.
* This is the signal to gate content-dependent actions (share, clear, search, filter) on;
* [isEmpty] only says which layout the [android.widget.ViewFlipper] shows.
*/
internal val isSourceEmptyFlow: StateFlow<Boolean>?
get() {
return if (isAdded && !isDetached) {
emptyStateViewModel.isSourceEmpty
} else {
null
}
}

internal val isSourceEmpty: Boolean
get() {
return if (isAdded && !isDetached) {
emptyStateViewModel.isSourceEmpty.value.also { cachedIsSourceEmpty = it }
} else {
cachedIsSourceEmpty
}
}

internal var isEmpty: Boolean
get() {
return if (isAdded && !isDetached) {
Expand All @@ -78,12 +108,34 @@ abstract class EmptyStateFragment<T : ViewBinding> : FragmentWithBinding<T> {
set(value) {
// Always update cache to preserve intended state even when detached
cachedIsEmpty = value
cachedIsSourceEmpty = value
// Update ViewModel only when attached
if (isAdded && !isDetached) {
emptyStateViewModel.setEmpty(value)
}
}

/**
* Centralized empty-state updater for log and output fragments.
*
* Empty state is set to `true` only when the underlying data source has no content AT ALL
* and no filter / filter bar is active. When an active filter query returns zero matches for
* non-empty source history, empty state remains `false` so the content layout (with the filter bar)
* stays visible. [isSourceEmpty] is tracked separately so action buttons can still be gated
* on actual content.
*/
fun updateEmptyState(
isSourceEmpty: Boolean,
isFilterActive: Boolean,
) {
val isEmpty = isSourceEmpty && !isFilterActive
cachedIsEmpty = isEmpty
cachedIsSourceEmpty = isSourceEmpty
if (isAdded && !isDetached) {
emptyStateViewModel.setEmptyState(isEmpty = isEmpty, isSourceEmpty = isSourceEmpty)
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
Expand Down Expand Up @@ -115,10 +167,13 @@ abstract class EmptyStateFragment<T : ViewBinding> : FragmentWithBinding<T> {
}

// Sync ViewModel with cache when view is created (in case cache was updated while detached)
// Read cached value into local variable to ensure atomic read
// Read cached values into local variables to ensure atomic reads
val cachedValue = cachedIsEmpty
if (emptyStateViewModel.isEmpty.value != cachedValue) {
emptyStateViewModel.setEmpty(cachedValue)
val cachedSourceValue = cachedIsSourceEmpty
if (emptyStateViewModel.isEmpty.value != cachedValue ||
emptyStateViewModel.isSourceEmpty.value != cachedSourceValue
) {
emptyStateViewModel.setEmptyState(isEmpty = cachedValue, isSourceEmpty = cachedSourceValue)
}

viewLifecycleScope.launch {
Expand Down Expand Up @@ -156,5 +211,4 @@ abstract class EmptyStateFragment<T : ViewBinding> : FragmentWithBinding<T> {
tag = tooltipTag,
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.itsaky.androidide.editor.ui.IDEEditor
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.models.LogFilter
import com.itsaky.androidide.utils.BasicBuildInfo
import com.itsaky.androidide.utils.flashInfo
import com.itsaky.androidide.viewmodel.BuildOutputViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
Expand Down Expand Up @@ -61,6 +62,11 @@ class BuildOutputFragment :
private val editorContentMutex = Mutex()

private var editorContentGeneration = 0
private val noMatchTracker = FilterNoMatchTracker()

// Reads view state (bar visibility), so evaluate it on the main thread.
private val isFilterActive: Boolean
get() = buildOutputViewModel.filterText.value.isNotEmpty() || filterBar?.isVisible == true

override fun onViewCreated(
view: View,
Expand Down Expand Up @@ -97,7 +103,11 @@ class BuildOutputFragment :
}
withContext(Dispatchers.Main) {
editor?.setText(filtered)
emptyStateViewModel.setEmpty(filtered.isBlank())
val isSourceEmpty = window.isBlank()
updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
if (noMatchTracker.onRender(isSourceEmpty = isSourceEmpty, isFilteredEmpty = filtered.isBlank())) {
flashInfo(R.string.msg_no_filter_matches)
}
onContentReplaced()
}
}
Expand Down Expand Up @@ -151,32 +161,57 @@ class BuildOutputFragment :
showLevelChips = false,
initialText = buildOutputViewModel.filterText.value,
initialLevels = LogFilter.ALL_LEVELS,
onVisibilityChanged = {
// The cached snapshot is an O(1) stand-in for the session file's emptiness.
updateEmptyState(
isSourceEmpty = buildOutputViewModel.getCachedContentSnapshot().isEmpty(),
isFilterActive = isFilterActive,
)
},
) { _, text ->
buildOutputViewModel.filterText.value = text.trim()
}.also { filterBar = it }
}

private suspend fun restoreWindowFromViewModel() {
val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
val content = BuildOutputViewModel.filterLines(window, buildOutputViewModel.filterText.value)
val query = buildOutputViewModel.filterText.value
val content = BuildOutputViewModel.filterLines(window, query)
val isSourceEmpty = window.isBlank()
val isFilteredEmpty = content.isBlank()

withContext(Dispatchers.Main) {
updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
noMatchTracker.prime(isFilteredEmpty)
if (!isSourceEmpty && isFilteredEmpty) {
editor?.setText("")
onContentReplaced()
}
}

if (content.isEmpty()) return
withContext(Dispatchers.Main) {
val editor = this@BuildOutputFragment.editor ?: return@withContext
val layoutCompleted =
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
editor.awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
}
if (layoutCompleted != null) {
editor.appendBatch(content)
emptyStateViewModel.setEmpty(false)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
} else {
// Timeout: defer append until layout is ready so content is not lost
val generationAtRestore = editorContentGeneration
val job =
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor.run {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
appendBatch(content)
emptyStateViewModel.setEmpty(false)
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
editorContentMutex.withLock {
if (editorContentGeneration == generationAtRestore) {
appendBatch(content)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
}
}
job.join()
Expand All @@ -196,8 +231,12 @@ class BuildOutputFragment :
// Avoid forcing the activityViewModels lazy init (which calls requireActivity())
// when the fragment is detached, otherwise an IllegalStateException is thrown.
if (!isAdded || activity == null) return
noMatchTracker.reset()
buildOutputViewModel.clear()
super.clearOutput()
// super sets the empty state unconditionally; re-apply the invariant so an
// active filter keeps the content layout (and the filter bar) reachable.
updateEmptyState(isSourceEmpty = true, isFilterActive = isFilterActive)
}

/** Returns the shareable build output, or an empty string when the fragment is detached. */
Expand Down Expand Up @@ -273,29 +312,30 @@ class BuildOutputFragment :
// The session file always gets the full text; the editor only shows matching lines
val visibleText =
BuildOutputViewModel.filterLines(text, buildOutputViewModel.filterText.value)
if (visibleText.isEmpty()) {
return
}

withContext(Dispatchers.Main) {
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
if (visibleText.isEmpty()) {
return@withContext
}
editor?.run {
val layoutCompleted =
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
}
if (layoutCompleted != null) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
} else {
// Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
val generationAtFlush = editorContentGeneration
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
editor?.run {
awaitLayout(onForceVisible = { emptyStateViewModel.setEmpty(false) })
awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
editorContentMutex.withLock {
if (editorContentGeneration == generationAtFlush) {
appendBatch(visibleText)
emptyStateViewModel.setEmpty(false)
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* This file is part of AndroidIDE.
*
* AndroidIDE is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* AndroidIDE is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>.
*/

package com.itsaky.androidide.fragments.output

/**
* Decides when a filtered re-render deserves a "no matches" flash: only on a fresh
* transition into no-matches for a non-empty source, never on the initial render.
*/
class FilterNoMatchTracker {
private var hasRendered = false
private var wasLastFilteredResultEmpty = false

fun reset() {
hasRendered = false
wasLastFilteredResultEmpty = false
}

/** Marks the initial render (e.g. window restore after rotation) without ever flashing. */
fun prime(isFilteredEmpty: Boolean) {
hasRendered = true
wasLastFilteredResultEmpty = isFilteredEmpty
}

/** Records a full re-render; returns `true` when it newly transitioned to "no matches". */
fun onRender(
isSourceEmpty: Boolean,
isFilteredEmpty: Boolean,
): Boolean {
if (!hasRendered) {
prime(isFilteredEmpty)
return false
}
if (!isSourceEmpty && isFilteredEmpty) {
val shouldFlash = !wasLastFilteredResultEmpty
wasLastFilteredResultEmpty = true
return shouldFlash
}
wasLastFilteredResultEmpty = false
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import com.itsaky.androidide.databinding.LayoutLogFilterBarBinding
import com.itsaky.androidide.utils.ILogger
import com.itsaky.androidide.utils.KeyboardUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
Expand All @@ -34,13 +35,15 @@ import java.util.EnumSet
* @param showLevelChips Whether the level chip row is shown (outputs without level
* metadata, like the build output, only get the text filter).
* @param onFilterChanged Called with the enabled levels and the (untrimmed) filter text.
* @param onVisibilityChanged Called after the bar is shown or hidden with the new visibility.
*/
class LogFilterBarController(
private val binding: LayoutLogFilterBarBinding,
coroutineScope: CoroutineScope,
showLevelChips: Boolean,
initialText: String,
initialLevels: Set<ILogger.Level>,
private val onVisibilityChanged: ((Boolean) -> Unit)? = null,
private val onFilterChanged: (levels: Set<ILogger.Level>, text: String) -> Unit,
) {
companion object {
Expand Down Expand Up @@ -76,12 +79,22 @@ class LogFilterBarController(
binding.closeFilterBar.setOnClickListener { hide() }
}

val isVisible: Boolean
get() = binding.root.isVisible

fun toggle() {
binding.root.isVisible = !binding.root.isVisible
if (binding.root.isVisible) {
hide()
} else {
binding.root.isVisible = true
onVisibilityChanged?.invoke(true)
}
}

fun hide() {
KeyboardUtils.hideSoftInput(binding.filterInput)
binding.root.isVisible = false
onVisibilityChanged?.invoke(false)
}

private fun notifyFilterChanged() {
Expand Down
Loading
Loading