Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`.

- **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches.
- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, in their own window.
- **Space previews** pictures, video, audio, PDFs, fonts, text and Markdown at full size, and the names inside archives, in their own window.
- **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP.
- **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten.
- **Split** the window in two with Ctrl+Alt+N to move things between folders.
Expand Down
14 changes: 13 additions & 1 deletion src/Format.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.pragma library
.import QtQml as QtQml

// Sizes, dates, counts and thumbnail URLs as the views show them, in one
// Sizes, dates, lengths, counts and thumbnail URLs as the views show them, in one
// copy that every tile and row shares. A library can't see context
// properties, so a binding that shows a date reads translator.language
// itself to follow a language switch; whatever goes through qsTr here is
Expand Down Expand Up @@ -33,6 +33,18 @@ function when(date) {
return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat)
}

// A length as a player's clock (1:05, or 1:02:05 past the hour); nothing when unknown.
function length(seconds) {
const perMinute = 60, perHour = 3600
if (!(seconds > 0))
return ""
const total = Math.floor(seconds)
const hours = Math.floor(total / perHour)
const minutes = Math.floor(total % perHour / perMinute)
const rest = String(total % perMinute).padStart(2, "0")
return hours > 0 ? hours + ":" + String(minutes).padStart(2, "0") + ":" + rest : minutes + ":" + rest
}

// A folder's contents; nothing until they've been counted.
function count(n) {
return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : ""
Expand Down
107 changes: 102 additions & 5 deletions src/PreviewPane.qml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Folio
import "Format.js" as Format

// A larger look at the selected item and the facts about it, with the one
Expand Down Expand Up @@ -43,6 +44,11 @@ Rectangle {
// A picture's size or a text's opening lines once they've been read off
// the UI thread.
readonly property var info: backend.details
// Text files show their opening lines in a frame, archives bsdtar can list the names inside.
readonly property bool archive: hasItem && backend.listsArchive(item.mime)
readonly property bool listed: hasItem && (item.kind === "text" || archive)
// Fonts show the alphabet and a pangram in the font itself.
readonly property bool sampled: fontSample.status !== ""
// Described once the folder's loaded has put the cursor back on its
// item: a re-read leaves another item on the cursor's row for a moment,
// and describing that would drop what's been read of this one.
Expand All @@ -68,14 +74,14 @@ Rectangle {
anchors.fill: parent
asynchronous: true
fillMode: Image.PreserveAspectFit
source: pane.hasItem && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : ""
source: pane.hasItem && !pane.item.isDir && !pane.listed && !pane.sampled && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : ""
visible: status === Image.Ready
}

// Text files show their opening lines instead of a picture.
// Text files show their opening lines instead of a picture, archives the names inside.
Rectangle {
anchors.fill: parent
visible: pane.hasItem && pane.item.kind === "text"
visible: pane.listed
color: theme.background
border.width: 1
border.color: Qt.alpha(theme.foreground, 0.12)
Expand All @@ -90,11 +96,63 @@ Rectangle {
textFormat: Text.PlainText
elide: Text.ElideRight
}
Sentence {
visible: pane.archive && !pane.info.text
margin: 14
text: pane.info.sentence || qsTr("Reading the archive…")
color: theme.secondaryForeground
font.pixelSize: 12
}
}

FontSample {
id: fontSample
path: pane.hasItem && !pane.item.isDir ? pane.item.path : ""
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
modified: pane.hasItem ? pane.item.modified : undefined
}
Rectangle {
anchors.fill: parent
visible: pane.sampled
color: theme.background
border.width: 1
border.color: Qt.alpha(theme.foreground, 0.12)
clip: true
Column {
anchors.fill: parent
anchors.margins: 14
spacing: 10
visible: fontSample.status === "ready"
// The Latin alphabet and the digits on purpose: they're what the font was checked for.
FontText {
width: parent.width
source: fontSample
text: fontSample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789"
color: theme.foreground
pixelSize: 18
anywhere: true
}
FontText {
width: parent.width
visible: !fontSample.glyphs
source: fontSample
// Not translated: the font was checked for these letters and the space, no others.
text: "The quick brown fox jumps over the lazy dog"
color: theme.foreground
pixelSize: 13
}
}
Sentence {
visible: fontSample.status === "error"
margin: 14
text: qsTr("Couldn't read this font.")
color: theme.secondaryForeground
font.pixelSize: 12
}
}

Glyph {
anchors.centerIn: parent
visible: !big.visible && pane.hasItem && pane.item.kind !== "text"
visible: !big.visible && pane.hasItem && !pane.listed && !pane.sampled
name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file"
size: 72
stroke: 1.2
Expand Down Expand Up @@ -123,7 +181,7 @@ Rectangle {
horizontalAlignment: Text.AlignLeft
text: {
translator.language // The type's name comes from the system, in the language of the moment.
return Format.summary(pane.item, backend.typeName(pane.item.mime || ""))
return Format.summary(pane.item, backend.typeName(pane.item.mime || "", pane.item.path || ""))
}
color: theme.secondaryForeground
font.pixelSize: 12
Expand Down Expand Up @@ -220,6 +278,45 @@ Rectangle {
color: theme.foreground
font.pixelSize: 12
}
Text { visible: !!pane.info.seconds; text: qsTr("Length"); color: theme.secondaryForeground; font.pixelSize: 12 }
Text {
visible: !!pane.info.seconds
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
text: Format.length(pane.info.seconds)
color: theme.foreground
font.pixelSize: 12
}
Text { visible: !!pane.info.video; text: qsTr("Video"); color: theme.secondaryForeground; font.pixelSize: 12 }
Text {
visible: !!pane.info.video
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
text: pane.info.video || ""
color: theme.foreground
font.pixelSize: 12
elide: Text.ElideRight
}
Text { visible: !!pane.info.audio; text: qsTr("Audio"); color: theme.secondaryForeground; font.pixelSize: 12 }
Text {
visible: !!pane.info.audio
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
text: pane.info.audio || ""
color: theme.foreground
font.pixelSize: 12
elide: Text.ElideRight
}
// Without ffprobe, the package that brings it.
Text {
visible: (pane.item.kind === "video" || pane.item.kind === "audio") && !!pane.info.sentence
Layout.columnSpan: 2
Layout.fillWidth: true
text: pane.info.sentence || ""
color: theme.secondaryForeground
font.pixelSize: 12
wrapMode: Text.Wrap
}
// Plugins' rows, label and value in turn, after Folio's own: a
// plugin's item's details, or what plugins say about a file.
Repeater {
Expand Down
117 changes: 109 additions & 8 deletions src/PreviewWindow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
import Folio
import "Format.js" as Format

// The large look at one file (Space in Folio, or `folio preview FILE`):
Expand Down Expand Up @@ -143,8 +144,9 @@ ApplicationWindow {
anchors.fill: parent
active: !win.external
sourceComponent: win.viewer === "image" || win.viewer === "animated" ? imageView
: win.viewer === "text" || win.viewer === "markdown" ? textView
: win.viewer === "text" || win.viewer === "markdown" || win.viewer === "archive" ? textView
: win.viewer === "folder" ? folderView
: win.viewer === "font" ? fontView
: otherView
}

Expand All @@ -167,6 +169,13 @@ ApplicationWindow {
: qsTr("Playing video and audio needs Qt Multimedia, from the qt6-multimedia package.")
color: theme.foreground
}

// An archive's names are read as bsdtar prints them.
Sentence {
visible: win.viewer === "archive" && !previewer.text
text: previewer.sentence || qsTr("Reading the archive…")
color: previewer.sentence ? theme.foreground : theme.secondaryForeground
}
}
}

Expand Down Expand Up @@ -270,7 +279,7 @@ ApplicationWindow {
}
}

// Text to read, Markdown rendered; selectable, never editable.
// Text to read, Markdown rendered, an archive's names; selectable, never editable.
Component {
id: textView

Expand All @@ -284,11 +293,7 @@ ApplicationWindow {
function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) }
function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) }

Connections {
target: previewer
function onChanged() { flick.contentY = 0 }
}

// Never sent to the top when the text changes: an archive's names keep arriving, and another file empties the text first, which brings the view back by itself, even one still gliding.
TextEdit {
id: body
width: Math.min(flick.width - 96, win.viewer === "markdown" ? 820 : 1200)
Expand All @@ -310,6 +315,102 @@ ApplicationWindow {
}
}

// A font as a sample sheet in the font itself, laid out and scrolled like the text view.
Component {
id: fontView

Flickable {
id: flick
clip: true
boundsBehavior: Flickable.StopAtBounds
contentWidth: width
contentHeight: Math.max(height, sheet.implicitHeight + 72)

function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) }
function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) }

Connections {
target: previewer
function onChanged() { flick.contentY = 0 }
}

FontSample {
id: sample
path: win.item.path || ""
modified: win.item.modified
}

Column {
id: sheet
width: Math.min(flick.width - 96, 1200)
x: (flick.width - width) / 2
y: 36
spacing: 24
visible: sample.status === "ready"

Column {
width: parent.width
spacing: 2
Text {
width: parent.width
text: sample.family
color: theme.brightForeground
font.pixelSize: 15
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: sample.faces > 1 ? sample.style + " · " + qsTr("%Ln faces", "", sample.faces) : sample.style
color: theme.secondaryForeground
font.pixelSize: 12
elide: Text.ElideRight
}
}

// The Latin alphabet and the digits on purpose: they're what the font was checked for.
FontText {
width: parent.width
source: sample
text: sample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789"
color: theme.foreground
pixelSize: 36
anywhere: true
}

// From the text view's size up to a display size.
Repeater {
model: [13, 18, 24, 36, 48]
Column {
required property int modelData
width: sheet.width
spacing: 4
Text {
text: qsTr("%1 px").arg(modelData)
color: theme.secondaryForeground
font.pixelSize: 12
}
FontText {
width: parent.width
source: sample
// Not translated: the font was checked for these letters and the space, no others.
text: sample.glyphs || "The quick brown fox jumps over the lazy dog"
color: theme.foreground
pixelSize: modelData
}
}
}
}

Sentence {
visible: sample.status === "error"
text: qsTr("Couldn't read this font.")
}

ScrollBar.vertical: FolioScrollBar { }
}
}

// A folder: what's inside, at a glance.
Component {
id: folderView
Expand Down Expand Up @@ -351,7 +452,7 @@ ApplicationWindow {
}

// Anything without a viewer of its own: the largest thumbnail the
// installed thumbnailers can make (HEIC, office files, fonts…), or the
// installed thumbnailers can make (HEIC, office files…), or the
// file's type and a pointer to its app.
Component {
id: otherView
Expand Down
13 changes: 13 additions & 0 deletions src/Sentence.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import QtQuick

// A sentence in the middle of an empty view: what it waits for, that it's empty, or why not.
Text {
// Kept clear on either side.
property int margin: 48

anchors.centerIn: parent
width: parent.width - 2 * margin
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
color: theme.foreground
}
Loading