1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00
Files
DankMaterialShell/Modules/Notepad/NotepadTextEditor.qml
2025-10-19 22:35:17 -04:00

619 lines
21 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
pragma ComponentBehavior: Bound
Column {
id: root
property alias text: textArea.text
property alias textArea: textArea
property bool contentLoaded: false
property string lastSavedContent: ""
property var currentTab: NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null
property bool searchVisible: false
property string searchQuery: ""
property var searchMatches: []
property int currentMatchIndex: -1
property int matchCount: 0
signal saveRequested()
signal openRequested()
signal newRequested()
signal escapePressed()
signal contentChanged()
signal settingsRequested()
function hasUnsavedChanges() {
if (!currentTab || !contentLoaded) {
return false
}
if (currentTab.isTemporary) {
return textArea.text.length > 0
}
return textArea.text !== lastSavedContent
}
function loadCurrentTabContent() {
if (!currentTab) return
contentLoaded = false
NotepadStorageService.loadTabContent(
NotepadStorageService.currentTabIndex,
(content) => {
lastSavedContent = content
textArea.text = content
contentLoaded = true
}
)
}
function saveCurrentTabContent() {
if (!currentTab || !contentLoaded) return
NotepadStorageService.saveTabContent(
NotepadStorageService.currentTabIndex,
textArea.text
)
lastSavedContent = textArea.text
}
function autoSaveToSession() {
if (!currentTab || !contentLoaded) return
saveCurrentTabContent()
}
function setTextDocumentLineHeight() {
return
}
property string lastTextForLineModel: ""
property var lineModel: []
function updateLineModel() {
if (!SettingsData.notepadShowLineNumbers) {
lineModel = []
lastTextForLineModel = ""
return
}
if (textArea.text !== lastTextForLineModel || lineModel.length === 0) {
lastTextForLineModel = textArea.text
lineModel = textArea.text.split('\n')
}
}
function performSearch() {
let matches = []
currentMatchIndex = -1
if (!searchQuery || searchQuery.length === 0) {
searchMatches = []
matchCount = 0
textArea.select(0, 0)
return
}
const text = textArea.text
const query = searchQuery.toLowerCase()
let index = 0
while (index < text.length) {
const foundIndex = text.toLowerCase().indexOf(query, index)
if (foundIndex === -1) break
matches.push({
start: foundIndex,
end: foundIndex + searchQuery.length
})
index = foundIndex + 1
}
searchMatches = matches
matchCount = matches.length
if (matchCount > 0) {
currentMatchIndex = 0
highlightCurrentMatch()
} else {
textArea.select(0, 0)
}
}
function highlightCurrentMatch() {
if (currentMatchIndex >= 0 && currentMatchIndex < searchMatches.length) {
const match = searchMatches[currentMatchIndex]
textArea.cursorPosition = match.start
textArea.moveCursorSelection(match.end, TextEdit.SelectCharacters)
const flickable = textArea.parent
if (flickable && flickable.contentY !== undefined) {
const lineHeight = textArea.font.pixelSize * 1.5
const approxLine = textArea.text.substring(0, match.start).split('\n').length
const targetY = approxLine * lineHeight - flickable.height / 2
flickable.contentY = Math.max(0, Math.min(targetY, flickable.contentHeight - flickable.height))
}
}
}
function findNext() {
if (matchCount === 0 || searchMatches.length === 0) return
currentMatchIndex = (currentMatchIndex + 1) % matchCount
highlightCurrentMatch()
}
function findPrevious() {
if (matchCount === 0 || searchMatches.length === 0) return
currentMatchIndex = currentMatchIndex <= 0 ? matchCount - 1 : currentMatchIndex - 1
highlightCurrentMatch()
}
function showSearch() {
searchVisible = true
Qt.callLater(() => {
searchField.forceActiveFocus()
})
}
function hideSearch() {
searchVisible = false
searchQuery = ""
searchMatches = []
matchCount = 0
currentMatchIndex = -1
textArea.select(0, 0)
textArea.forceActiveFocus()
}
spacing: Theme.spacingM
StyledRect {
id: searchBar
width: parent.width
height: 48
visible: searchVisible
opacity: searchVisible ? 1 : 0
color: Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, 0.95)
border.color: searchField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: searchField.activeFocus ? 2 : 1
radius: Theme.cornerRadius
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingS
// Search icon
DankIcon {
Layout.alignment: Qt.AlignVCenter
name: "search"
size: Theme.iconSize - 2
color: searchField.activeFocus ? Theme.primary : Theme.surfaceVariantText
}
// Search input field
TextInput {
id: searchField
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
height: 32
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
Component.onCompleted: {
text = root.searchQuery
}
Connections {
target: root
function onSearchQueryChanged() {
if (searchField.text !== root.searchQuery) {
searchField.text = root.searchQuery
}
}
}
onTextChanged: {
if (root.searchQuery !== text) {
root.searchQuery = text
root.performSearch()
}
}
Keys.onEscapePressed: event => {
root.hideSearch()
event.accepted = true
}
Keys.onReturnPressed: event => {
if (event.modifiers & Qt.ShiftModifier) {
root.findPrevious()
} else {
root.findNext()
}
event.accepted = true
}
Keys.onEnterPressed: event => {
if (event.modifiers & Qt.ShiftModifier) {
root.findPrevious()
} else {
root.findNext()
}
event.accepted = true
}
}
// Placeholder text
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: I18n.tr("Find in note...")
font: searchField.font
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
visible: searchField.text.length === 0 && !searchField.activeFocus
Layout.leftMargin: -(searchField.width - 20) // Position over the input field
}
// Match count display
StyledText {
Layout.alignment: Qt.AlignVCenter
text: matchCount > 0 ? "%1/%2".arg(currentMatchIndex + 1).arg(matchCount) : searchQuery.length > 0 ? I18n.tr("No matches") : ""
font.pixelSize: Theme.fontSizeSmall
color: matchCount > 0 ? Theme.primary : Theme.surfaceTextMedium
visible: searchQuery.length > 0
Layout.rightMargin: Theme.spacingS
}
// Navigation buttons
DankActionButton {
id: prevButton
Layout.alignment: Qt.AlignVCenter
iconName: "keyboard_arrow_up"
iconSize: Theme.iconSize
iconColor: matchCount > 0 ? Theme.surfaceText : Theme.surfaceTextAlpha
enabled: matchCount > 0
onClicked: root.findPrevious()
}
DankActionButton {
id: nextButton
Layout.alignment: Qt.AlignVCenter
iconName: "keyboard_arrow_down"
iconSize: Theme.iconSize
iconColor: matchCount > 0 ? Theme.surfaceText : Theme.surfaceTextAlpha
enabled: matchCount > 0
onClicked: root.findNext()
}
// Close button
DankActionButton {
id: closeSearchButton
Layout.alignment: Qt.AlignVCenter
iconName: "close"
iconSize: Theme.iconSize - 2
iconColor: Theme.surfaceText
onClicked: root.hideSearch()
}
}
}
StyledRect {
width: parent.width
height: parent.height - bottomControls.height - Theme.spacingM - (searchVisible ? searchBar.height + Theme.spacingM : 0)
color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, Theme.notepadTransparency)
border.color: Theme.outlineMedium
border.width: 1
radius: Theme.cornerRadius
DankFlickable {
id: flickable
anchors.fill: parent
anchors.margins: 1
clip: true
contentWidth: width - 11
Rectangle {
id: lineNumberArea
anchors.left: parent.left
anchors.top: parent.top
width: SettingsData.notepadShowLineNumbers ? Math.max(30, 32 + Theme.spacingXS) : 0
height: textArea.contentHeight + textArea.topPadding + textArea.bottomPadding
color: "transparent"
visible: SettingsData.notepadShowLineNumbers
ListView {
id: lineNumberList
anchors.top: parent.top
anchors.topMargin: textArea.topPadding
anchors.right: parent.right
anchors.rightMargin: 2
width: 32
height: textArea.contentHeight
model: SettingsData.notepadShowLineNumbers ? root.lineModel : []
interactive: false
spacing: 0
delegate: Item {
id: lineDelegate
required property int index
required property string modelData
width: 32
height: measuringText.contentHeight
Text {
id: measuringText
width: textArea.width - textArea.leftPadding - textArea.rightPadding
text: modelData || " "
font: textArea.font
wrapMode: Text.Wrap
visible: false
}
StyledText {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
text: index + 1
font.family: textArea.font.family
font.pixelSize: textArea.font.pixelSize
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
horizontalAlignment: Text.AlignRight
}
}
}
}
TextArea.flickable: TextArea {
id: textArea
placeholderText: I18n.tr("Start typing your notes here...")
placeholderTextColor: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
font.family: SettingsData.notepadUseMonospace ? SettingsData.monoFontFamily : (SettingsData.notepadFontFamily || SettingsData.fontFamily)
font.pixelSize: SettingsData.notepadFontSize * SettingsData.fontScale
font.letterSpacing: 0
color: Theme.surfaceText
selectedTextColor: Theme.background
selectionColor: Theme.primary
selectByMouse: true
selectByKeyboard: true
wrapMode: TextArea.Wrap
focus: true
activeFocusOnTab: true
textFormat: TextEdit.PlainText
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
persistentSelection: true
tabStopDistance: 40
leftPadding: (SettingsData.notepadShowLineNumbers ? lineNumberArea.width + Theme.spacingXS : Theme.spacingM)
topPadding: Theme.spacingM
rightPadding: Theme.spacingM
bottomPadding: Theme.spacingM
Component.onCompleted: {
loadCurrentTabContent()
setTextDocumentLineHeight()
root.updateLineModel()
Qt.callLater(() => {
textArea.forceActiveFocus()
})
}
Connections {
target: NotepadStorageService
function onCurrentTabIndexChanged() {
loadCurrentTabContent()
Qt.callLater(() => {
textArea.forceActiveFocus()
})
}
function onTabsChanged() {
if (NotepadStorageService.tabs.length > 0 && !contentLoaded) {
loadCurrentTabContent()
}
}
}
Connections {
target: SettingsData
function onNotepadShowLineNumbersChanged() {
root.updateLineModel()
}
}
onTextChanged: {
if (contentLoaded && text !== lastSavedContent) {
autoSaveTimer.restart()
}
root.contentChanged()
root.updateLineModel()
}
Keys.onEscapePressed: (event) => {
root.escapePressed()
event.accepted = true
}
Keys.onPressed: (event) => {
if (event.modifiers & Qt.ControlModifier) {
switch (event.key) {
case Qt.Key_S:
event.accepted = true
root.saveRequested()
break
case Qt.Key_O:
event.accepted = true
root.openRequested()
break
case Qt.Key_N:
event.accepted = true
root.newRequested()
break
case Qt.Key_A:
event.accepted = true
selectAll()
break
case Qt.Key_F:
event.accepted = true
root.showSearch()
break
}
}
}
background: Rectangle {
color: "transparent"
}
}
}
}
Column {
id: bottomControls
width: parent.width
spacing: Theme.spacingS
Item {
width: parent.width
height: 32
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingL
Row {
spacing: Theme.spacingS
DankActionButton {
iconName: "save"
iconSize: Theme.iconSize - 2
iconColor: Theme.primary
enabled: currentTab && (hasUnsavedChanges() || textArea.text.length > 0)
onClicked: root.saveRequested()
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
}
Row {
spacing: Theme.spacingS
DankActionButton {
iconName: "folder_open"
iconSize: Theme.iconSize - 2
iconColor: Theme.secondary
onClicked: root.openRequested()
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Open")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
}
Row {
spacing: Theme.spacingS
DankActionButton {
iconName: "note_add"
iconSize: Theme.iconSize - 2
iconColor: Theme.surfaceText
onClicked: root.newRequested()
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("New")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
}
}
DankActionButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
iconName: "more_horiz"
iconSize: Theme.iconSize - 2
iconColor: Theme.surfaceText
onClicked: root.settingsRequested()
}
}
Row {
width: parent.width
spacing: Theme.spacingL
StyledText {
text: textArea.text.length > 0 ? I18n.tr("%1 characters").arg(textArea.text.length) : I18n.tr("Empty")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
StyledText {
text: I18n.tr("Lines: %1").arg(textArea.lineCount)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
visible: textArea.text.length > 0
opacity: 1.0
}
StyledText {
text: {
if (autoSaveTimer.running) {
return I18n.tr("Auto-saving...")
}
if (hasUnsavedChanges()) {
if (currentTab && currentTab.isTemporary) {
return I18n.tr("Unsaved note...")
} else {
return I18n.tr("Unsaved changes")
}
} else {
return I18n.tr("Saved")
}
}
font.pixelSize: Theme.fontSizeSmall
color: {
if (autoSaveTimer.running) {
return Theme.primary
}
if (hasUnsavedChanges()) {
return Theme.warning
} else {
return Theme.success
}
}
opacity: textArea.text.length > 0 ? 1.0 : 0.0
}
}
}
Timer {
id: autoSaveTimer
interval: 2000
repeat: false
onTriggered: {
autoSaveToSession()
}
}
}