diff --git a/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt index 6754ef416b..26ff153b19 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt @@ -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 @@ -31,22 +32,28 @@ abstract class EmptyStateFragment : FragmentWithBinding { protected val emptyStateViewModel by viewModels() 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() @@ -65,6 +72,29 @@ abstract class EmptyStateFragment : FragmentWithBinding { } } + /** + * 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? + 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) { @@ -78,12 +108,34 @@ abstract class EmptyStateFragment : FragmentWithBinding { 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?, @@ -115,10 +167,13 @@ abstract class EmptyStateFragment : FragmentWithBinding { } // 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 { @@ -156,5 +211,4 @@ abstract class EmptyStateFragment : FragmentWithBinding { tag = tooltipTag, ) } - } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index c14dda88ca..b5316bb8e1 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -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 @@ -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, @@ -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() } } @@ -151,6 +161,13 @@ 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 } @@ -158,25 +175,43 @@ class BuildOutputFragment : 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() @@ -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. */ @@ -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) } } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/FilterNoMatchTracker.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/FilterNoMatchTracker.kt new file mode 100644 index 0000000000..9c547c5cce --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/FilterNoMatchTracker.kt @@ -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 . + */ + +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 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt index 6c27f22a34..895d1da671 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogFilterBarController.kt @@ -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 @@ -34,6 +35,7 @@ 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, @@ -41,6 +43,7 @@ class LogFilterBarController( showLevelChips: Boolean, initialText: String, initialLevels: Set, + private val onVisibilityChanged: ((Boolean) -> Unit)? = null, private val onFilterChanged: (levels: Set, text: String) -> Unit, ) { companion object { @@ -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() { diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt index d062702748..b38ea58fb8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/LogViewFragment.kt @@ -38,6 +38,7 @@ import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.models.LogLine import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.utils.BasicBuildInfo +import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.jetbrainsMono import com.itsaky.androidide.utils.viewLifecycleScope @@ -130,6 +131,9 @@ abstract class LogViewFragment : showLevelChips = true, initialText = currentFilter.text, initialLevels = currentFilter.enabledLevels, + onVisibilityChanged = { + updateEmptyState(isSourceEmpty = viewModel.isBufferEmpty, isFilterActive = isFilterActive) + }, ) { levels, text -> viewModel.setFilter(LogFilter(levels, text.trim())) }.also { filterBar = it } @@ -141,10 +145,18 @@ abstract class LogViewFragment : return "${BasicBuildInfo.shareableBuildInfo()}${System.lineSeparator()}$logText" } + private val noMatchTracker = FilterNoMatchTracker() + + // Reads view state (bar visibility), so evaluate it on the main thread. + private val isFilterActive: Boolean + get() = viewModel.filter.value != LogFilter.NONE || filterBar?.isVisible == true + override fun clearOutput() { + noMatchTracker.reset() viewModel.clear() _binding?.editor?.setText("")?.also { - emptyStateViewModel.setEmpty(true) + // An active filter keeps the content layout (and the filter bar) reachable. + updateEmptyState(isSourceEmpty = true, isFilterActive = isFilterActive) } } @@ -257,7 +269,11 @@ abstract class LogViewFragment : private fun setText(text: String) { val editor = _binding?.editor ?: return editor.setText(text) - emptyStateViewModel.setEmpty(text.isBlank()) + val isSourceEmpty = viewModel.isBufferEmpty + updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive) + if (noMatchTracker.onRender(isSourceEmpty = isSourceEmpty, isFilteredEmpty = text.isBlank())) { + flashInfo(R.string.msg_no_filter_matches) + } onContentReplaced() } diff --git a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt index 15312d4630..dfeb793c4e 100644 --- a/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt +++ b/app/src/main/java/com/itsaky/androidide/logs/LogBuffer.kt @@ -98,6 +98,9 @@ class LogBuffer( } } + val isEmpty: Boolean + @Synchronized get() = entries.isEmpty() + @Synchronized fun clear() { entries.clear() diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 1bacde1e81..89b094a02e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -52,6 +52,7 @@ import com.itsaky.androidide.adapters.DiagnosticsAdapter import com.itsaky.androidide.adapters.EditorBottomSheetTabAdapter import com.itsaky.androidide.adapters.SearchListAdapter import com.itsaky.androidide.databinding.LayoutEditorBottomSheetBinding +import com.itsaky.androidide.fragments.EmptyStateFragment import com.itsaky.androidide.fragments.output.SearchableOutputFragment import com.itsaky.androidide.fragments.output.ShareableOutputFragment import com.itsaky.androidide.fragments.output.WrappableOutputFragment @@ -127,6 +128,8 @@ class EditorBottomSheet private val buildOutputViewModel by (context as FragmentActivity).viewModels() private lateinit var mediator: TabLayoutMediator private var shareJob: Job? = null + private var fragmentEmptyStateJob: Job? = null + private var currentObservedFragment: Fragment? = null // BottomSheetBehavior repositions the sheet after layout without triggering onSlide, // so refresh the FABs afterward @@ -235,8 +238,14 @@ class EditorBottomSheet } } finally { if (isAttachedToWindow) { - binding.shareOutputAction.isEnabled = true - binding.clearOutputAction.isEnabled = true + // The share job is still active while its own finally runs, so it must be + // cleared first or updateActionButtonsEnabledState would keep the share and + // clear actions disabled. Also re-read the current tab: it may have changed + // since the share started. + shareJob = null + val current = pagerAdapter.getFragmentAtIndex(binding.tabs.selectedTabPosition) + val isCurrentEmpty = (current as? EmptyStateFragment<*>)?.isSourceEmpty == true + updateActionButtonsEnabledState(isSourceEmpty = isCurrentEmpty) } } } @@ -305,6 +314,9 @@ class EditorBottomSheet override fun onDetachedFromWindow() { shareJob?.cancel() shareJob = null + currentObservedFragment = null + fragmentEmptyStateJob?.cancel() + fragmentEmptyStateJob = null if (this::mediator.isInitialized) { mediator.detach() } @@ -678,6 +690,53 @@ class EditorBottomSheet } updateWordWrapButtonState(isEnabled) } + + observeCurrentFragmentEmptyState(currentFragment) + } + + private fun observeCurrentFragmentEmptyState(fragment: Fragment?) { + if (fragment === currentObservedFragment && fragmentEmptyStateJob?.isActive == true) { + return + } + + fragmentEmptyStateJob?.cancel() + fragmentEmptyStateJob = null + currentObservedFragment = fragment + + if (fragment !is EmptyStateFragment<*> || !fragment.isAdded || fragment.isDetached || fragment.host == null) { + updateActionButtonsEnabledState(isSourceEmpty = false) + return + } + + val flow = + fragment.isSourceEmptyFlow ?: run { + updateActionButtonsEnabledState(isSourceEmpty = false) + return + } + + val activity = context as FragmentActivity + fragmentEmptyStateJob = + activity.lifecycleScope.launch { + activity.repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collectLatest { isSourceEmpty -> + if (fragment.isAdded && !fragment.isDetached) { + updateActionButtonsEnabledState(isSourceEmpty = isSourceEmpty) + } + } + } + } + } + + // Gates on source content, not the fragment's isEmpty: that flag stays false while a + // filter UI is active even when there is nothing to share, clear, or search. + private fun updateActionButtonsEnabledState(isSourceEmpty: Boolean) { + val hasContent = !isSourceEmpty + val isSharing = shareJob?.isActive == true + val canShareOrClear = hasContent && !isSharing + binding.searchOutputAction.isEnabled = hasContent + binding.filterOutputAction.isEnabled = hasContent + binding.shareOutputAction.isEnabled = canShareOrClear + binding.clearOutputAction.isEnabled = canShareOrClear } // The bottom-anchored FAB goes off-screen when the bottom sheet is collapsed. diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModel.kt index 92b187428f..5016e3ed01 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModel.kt @@ -27,13 +27,31 @@ import kotlinx.coroutines.flow.update */ class EmptyStateFragmentViewModel : ViewModel() { private val _isEmpty = MutableStateFlow(true) + private val _isSourceEmpty = MutableStateFlow(true) private val _emptyMessage = MutableStateFlow("") + /** Whether the empty-state layout (not the content layout) should be shown. */ val isEmpty = _isEmpty.asStateFlow() + + /** + * Whether the underlying data source has no content at all. Unlike [isEmpty], this is + * independent of any filter UI, so it is the signal to gate content-dependent actions on. + */ + val isSourceEmpty = _isSourceEmpty.asStateFlow() + val emptyMessage = _emptyMessage.asStateFlow() + /** Sets both [isEmpty] and [isSourceEmpty]; they only diverge when a filter UI is active. */ fun setEmpty(isEmpty: Boolean) { + setEmptyState(isEmpty = isEmpty, isSourceEmpty = isEmpty) + } + + fun setEmptyState( + isEmpty: Boolean, + isSourceEmpty: Boolean, + ) { _isEmpty.update { isEmpty } + _isSourceEmpty.update { isSourceEmpty } } fun setEmptyMessage(emptyMessage: CharSequence) { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt index b3a68857d4..6195ffbe2e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/LogViewModel.kt @@ -183,6 +183,10 @@ abstract class LogViewModel : ViewModel() { } } + /** Whether the retained log buffer contains no entries. O(1) check. */ + val isBufferEmpty: Boolean + get() = buffer.isEmpty + /** The full retained history, ignoring the active filter. */ fun snapshotUnfiltered(): String = buffer.snapshotAll() } diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/FilterNoMatchTrackerTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/FilterNoMatchTrackerTest.kt new file mode 100644 index 0000000000..c5af7ba104 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/output/FilterNoMatchTrackerTest.kt @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +package com.itsaky.androidide.fragments.output + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterNoMatchTrackerTest { + @Test + fun `initial render never flashes even with no matches`() { + val tracker = FilterNoMatchTracker() + assertFalse(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } + + @Test + fun `transition into no-matches flashes exactly once`() { + val tracker = FilterNoMatchTracker() + tracker.onRender(isSourceEmpty = false, isFilteredEmpty = false) + + assertTrue(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + assertFalse(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } + + @Test + fun `recovering matches re-arms the flash`() { + val tracker = FilterNoMatchTracker() + tracker.onRender(isSourceEmpty = false, isFilteredEmpty = false) + assertTrue(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + + assertFalse(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = false)) + assertTrue(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } + + @Test + fun `empty source never flashes`() { + val tracker = FilterNoMatchTracker() + tracker.onRender(isSourceEmpty = false, isFilteredEmpty = false) + assertFalse(tracker.onRender(isSourceEmpty = true, isFilteredEmpty = true)) + } + + @Test + fun `prime suppresses the flash for a restored no-match render`() { + val tracker = FilterNoMatchTracker() + tracker.prime(isFilteredEmpty = true) + // e.g. after rotation with an active no-match filter, the re-render must stay silent + assertFalse(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } + + @Test + fun `prime with matches still flashes on a later transition to no-matches`() { + val tracker = FilterNoMatchTracker() + tracker.prime(isFilteredEmpty = false) + assertTrue(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } + + @Test + fun `reset restores initial-render behavior`() { + val tracker = FilterNoMatchTracker() + tracker.onRender(isSourceEmpty = false, isFilteredEmpty = false) + tracker.reset() + + assertFalse(tracker.onRender(isSourceEmpty = false, isFilteredEmpty = true)) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt index 3c18615aae..2eec65c367 100644 --- a/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt +++ b/app/src/test/java/com/itsaky/androidide/logs/LogBufferTest.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.logs import com.itsaky.androidide.models.LogFilter import com.itsaky.androidide.utils.ILogger import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -93,4 +94,16 @@ class LogBufferTest { buffer.append(ILogger.Level.ERROR, "error\n") assertEquals("debug\nerror\n", buffer.snapshotAll()) } + + @Test + fun `isEmpty reflects appends and clears`() { + val buffer = LogBuffer(trimOnEntryCount = 10, maxEntryCount = 5) + assertTrue(buffer.isEmpty) + + buffer.append(null, "a\n") + assertFalse(buffer.isEmpty) + + buffer.clear() + assertTrue(buffer.isEmpty) + } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModelTest.kt new file mode 100644 index 0000000000..377a0452a2 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/EmptyStateFragmentViewModelTest.kt @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +package com.itsaky.androidide.viewmodel + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmptyStateFragmentViewModelTest { + @Test + fun `both states start empty`() { + val viewModel = EmptyStateFragmentViewModel() + assertTrue(viewModel.isEmpty.value) + assertTrue(viewModel.isSourceEmpty.value) + } + + @Test + fun `setEmpty sets both states together`() { + val viewModel = EmptyStateFragmentViewModel() + + viewModel.setEmpty(false) + assertFalse(viewModel.isEmpty.value) + assertFalse(viewModel.isSourceEmpty.value) + + viewModel.setEmpty(true) + assertTrue(viewModel.isEmpty.value) + assertTrue(viewModel.isSourceEmpty.value) + } + + @Test + fun `setEmptyState sets the states independently`() { + val viewModel = EmptyStateFragmentViewModel() + + // Empty source with an active filter UI: content layout stays visible, + // but content-dependent actions must still see an empty source. + viewModel.setEmptyState(isEmpty = false, isSourceEmpty = true) + assertFalse(viewModel.isEmpty.value) + assertTrue(viewModel.isSourceEmpty.value) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt index ba72d57c83..0b51948f20 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/LogViewModelTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -180,6 +181,18 @@ class LogViewModelTest { } } + @Test + fun `isBufferEmpty reflects submits and clears`() { + val viewModel = TestLogViewModel() + assertTrue(viewModel.isBufferEmpty) + + viewModel.submit(null, "line") + assertFalse(viewModel.isBufferEmpty) + + viewModel.clear() + assertTrue(viewModel.isBufferEmpty) + } + @Test fun `LogLine level and text are captured before the instance is recycled`() { val viewModel = TestLogViewModel() diff --git a/common/src/main/java/com/itsaky/androidide/utils/KeyboardUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/KeyboardUtils.kt index 38fb4c1f09..db3ebc4c0e 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/KeyboardUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/KeyboardUtils.kt @@ -20,26 +20,35 @@ package com.itsaky.androidide.utils import android.content.Context import android.content.res.Configuration import android.inputmethodservice.InputMethodService +import android.view.View +import android.view.inputmethod.InputMethodManager /** * @author Akash Yadav */ object KeyboardUtils { + /** + * Check if hardware keyboard is connected. + * Based on default implementation of [InputMethodService.onEvaluateInputViewShown]. + * + * https://developer.android.com/guide/topics/resources/providing-resources#ImeQualifier + * + * @param context The Context for operations. + * @return Returns `true` if device has hardware keys for text input or an external hardware + * keyboard is connected, otherwise `false`. + */ + fun isHardKeyboardConnected(context: Context?): Boolean { + if (context == null) return false + val config = context.resources.configuration + return ( + config.keyboard != Configuration.KEYBOARD_NOKEYS || + config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO + ) + } - /** - * Check if hardware keyboard is connected. - * Based on default implementation of [InputMethodService.onEvaluateInputViewShown]. - * - * https://developer.android.com/guide/topics/resources/providing-resources#ImeQualifier - * - * @param context The Context for operations. - * @return Returns `true` if device has hardware keys for text input or an external hardware - * keyboard is connected, otherwise `false`. - */ - fun isHardKeyboardConnected(context: Context?): Boolean { - if (context == null) return false - val config = context.resources.configuration - return (config.keyboard != Configuration.KEYBOARD_NOKEYS - || config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) - } -} \ No newline at end of file + /** Hide the soft keyboard associated with [view]'s window. */ + fun hideSoftInput(view: View) { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt index 12e08c012f..4351d6f403 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt @@ -152,33 +152,43 @@ open class EditorActionsMenu( getInstance().unregisterActionExecListener(this) } + private fun shouldSuppressActionsMenu(): Boolean { + val searcher = editor.searcher + return searcher.isSearching || searcher.hasQuery() + } + protected open fun onSelectionChanged(event: SelectionChangeEvent) { - if (touchHandler.hasAnyHeldHandle()) { + if (touchHandler.hasAnyHeldHandle()) return + + if (shouldSuppressActionsMenu()) { + dismiss() return } + if (event.isSelected) { editor.post { displayWindow(isShowing) } mLastPosition = -1 - } else { - var show = false - if ( - event.cause == SelectionChangeEvent.CAUSE_TAP && + return + } + + val shouldShow = + event.cause == SelectionChangeEvent.CAUSE_TAP && event.left.index == mLastPosition && !isShowing && !editor.text.isInBatchEdit - ) { - editor.post(::displayWindow) - show = true + + if (shouldShow) { + editor.post(::displayWindow) + } else { + dismiss() + } + + mLastPosition = + if (event.cause == SelectionChangeEvent.CAUSE_TAP && !shouldShow) { + event.left.index } else { - dismiss() + -1 } - mLastPosition = - if (event.cause == SelectionChangeEvent.CAUSE_TAP && !show) { - event.left.index - } else { - -1 - } - } } protected open fun onScrollEvent() { @@ -209,7 +219,7 @@ open class EditorActionsMenu( return } dismiss() - if (!editor.cursor.isSelected) { + if (!editor.cursor.isSelected || shouldSuppressActionsMenu()) { return } editor.postDelayed( @@ -243,6 +253,10 @@ open class EditorActionsMenu( @JvmOverloads open fun displayWindow(update: Boolean = false) { + if (shouldSuppressActionsMenu()) { + dismiss() + return + } var top: Int val cursor = editor.cursor top = diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt index 59885284c9..24d754fbd9 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorSearchLayout.kt @@ -38,8 +38,10 @@ import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.resources.R import com.itsaky.androidide.resources.databinding.SearchOptionsPopupMenuBinding +import com.itsaky.androidide.utils.KeyboardUtils import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.applyLongPressRecursively +import com.itsaky.androidide.utils.flashInfo import io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions import java.util.regex.Pattern import java.util.regex.PatternSyntaxException @@ -280,6 +282,7 @@ class EditorSearchLayout( private fun onSearchActionClick(v: View) { val searcher = editor.searcher if (v.id == findInFileBinding.close.id) { + KeyboardUtils.hideSoftInput(findInFileBinding.searchInput) if (this.searchInputTextWatcher == null) { return } @@ -293,11 +296,15 @@ class EditorSearchLayout( return } if (v.id == findInFileBinding.prev.id) { - searcher.gotoPrevious() + if (!searcher.gotoPrevious() && searcher.isSearchCompleteWithNoMatches()) { + flashInfo(R.string.msg_no_search_matches) + } return } if (v.id == findInFileBinding.next.id) { - searcher.gotoNext() + if (!searcher.gotoNext() && searcher.isSearchCompleteWithNoMatches()) { + flashInfo(R.string.msg_no_search_matches) + } return } if (v.id == findInFileBinding.replace.id) { diff --git a/editor/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt b/editor/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt index 6f4914c5e7..627b8da76d 100644 --- a/editor/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt +++ b/editor/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt @@ -1,79 +1,102 @@ -/* - * 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 . - */ - -package io.github.rosemoe.sora.widget - -import com.itsaky.androidide.editor.ui.IDEEditor - -/** - * Search text in editor. As the constructor of [EditorSearcher] is package private, we cannot - * extend it in another package. So we put this class in the same package. - * - * @author Akash Yadav - */ -open class IDEEditorSearcher(editor: IDEEditor) : EditorSearcher(editor) { - - var isSearching = false - private set - - protected fun getEditor(): CodeEditor { - try { - val field = EditorSearcher::class.java.getDeclaredField("editor") - field.isAccessible = true - return field.get(this) as CodeEditor - } catch (error: Throwable) { - throw RuntimeException("Unable get instance of editor", error) - } - } - - fun updateSearchOptions(searchOptions: SearchOptions) { - this.searchOptions = searchOptions - } - - override fun replaceAll(replacement: String, whenFinished: Runnable?) { - markSearching() - super.replaceAll(replacement, whenFinished) - } - - override fun replaceThis(replacement: String) { - markSearching() - super.replaceThis(replacement) - } - - override fun gotoNext(): Boolean { - markSearching() - return super.gotoNext() - } - - override fun gotoPrevious(): Boolean { - markSearching() - return super.gotoPrevious() - } - - override fun stopSearch() { - isSearching = false - super.stopSearch() - } - - fun onClose() { - isSearching = false - } - - private fun markSearching() { - isSearching = true - } -} +/* + * 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 . + */ + +package io.github.rosemoe.sora.widget + +import com.itsaky.androidide.editor.ui.IDEEditor + +/** + * Search text in editor. As the constructor of [EditorSearcher] is package private, we cannot + * extend it in another package. So we put this class in the same package. + * + * @author Akash Yadav + */ +open class IDEEditorSearcher( + editor: IDEEditor, +) : EditorSearcher(editor) { + var isSearching = false + private set + + protected fun getEditor(): CodeEditor { + try { + val field = EditorSearcher::class.java.getDeclaredField("editor") + field.isAccessible = true + return field.get(this) as CodeEditor + } catch (error: Throwable) { + throw RuntimeException("Unable get instance of editor", error) + } + } + + fun updateSearchOptions(searchOptions: SearchOptions) { + this.searchOptions = searchOptions + } + + override fun search( + query: String, + searchOptions: SearchOptions, + ) { + if (query.isNotEmpty()) { + markSearching() + } else { + isSearching = false + } + super.search(query, searchOptions) + } + + override fun replaceAll( + replacement: String, + whenFinished: Runnable?, + ) { + markSearching() + super.replaceAll(replacement, whenFinished) + } + + override fun replaceThis(replacement: String) { + markSearching() + super.replaceThis(replacement) + } + + override fun gotoNext(): Boolean { + markSearching() + return super.gotoNext() + } + + override fun gotoPrevious(): Boolean { + markSearching() + return super.gotoPrevious() + } + + override fun stopSearch() { + isSearching = false + super.stopSearch() + } + + fun onClose() { + isSearching = false + stopSearch() + } + + private fun markSearching() { + isSearching = true + } + + fun isSearchCompleteWithNoMatches(): Boolean { + if (!hasQuery() || !isResultValid()) return false + val results = lastResults ?: return false + return results.size() == 0 + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c56ca9ac40..82e2b0cf07 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -686,6 +686,8 @@ Logs from the IDE are shown here. Open a file to show its diagnostic results Filter lines + No matching log entries found. + No search matches found. Filter Search Share