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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
effects
effects
src/bin/
src/coverage.out
src/logs/
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ML4W Wallpaper Collection
# Wallpaper Collection

My personal wallpaper collection that fits best to tiling window managers.
Wallpaper collection that fits best to high res 16:9/16:10 aspect ratio's.

![image](https://github.com/user-attachments/assets/9f94272d-7278-4599-801c-8b104ca746c8)

Expand All @@ -10,11 +10,9 @@ Clone the directory from your home directory.

```
cd ~/Pictures # You can also choose a different location
git clone --depth=1 https://github.com/mylinuxforwork/wallpaper.git
git clone --depth=1 https://github.com/pearsonc/wallpaper.git
cd wallpaper/
```
If you are using the ML4W Dotfiles for Hyprland, you can select the the new wallpaper folder with Waypaper.

## Update

You can update the wallpapers with
Expand Down
Binary file added cyberpunk-motorcycle.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added forest-night-full-moon.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lonely_street_lamp.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed miracleOS_light.jpg
Binary file not shown.
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
Binary file removed plasmawaves.png
Diff not rendered.
Binary file added river-stream-forest.jpg
35 changes: 35 additions & 0 deletions src/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
BINARY := ne-image-sorter
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X main.version=$(VERSION)

.PHONY: all build run test cover vet fmt lint clean install

all: vet test build

build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/$(BINARY)

run: build
./bin/$(BINARY)

test:
go test ./...

cover:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1

vet:
go vet ./...

fmt:
gofmt -l -w .

lint: vet
gofmt -l . | tee /dev/stderr | test ! -s /dev/stdin

install: build
install -Dm755 bin/$(BINARY) $(HOME)/.local/bin/$(BINARY)

clean:
rm -rf bin coverage.out
85 changes: 85 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ne-image-sorter

Sorts a folder of images by aspect ratio and resolution, moving everything a
configurable policy rejects into a second folder. Built for this wallpaper
collection, but the policy is general.

## Build

```
cd src
make build # bin/ne-image-sorter
make install # ~/.local/bin/ne-image-sorter
```

## Run

```
ne-image-sorter # terminal interface
ne-image-sorter --sort --dry-run # report, move nothing
ne-image-sorter --sort # move, no interface
ne-image-sorter --source ~/pics --dest ~/pics/x # override the saved folders
```

| Flag | Meaning |
|------|---------|
| `--sort` | Run headless and exit, for scripts and cron |
| `--dry-run` | With `--sort`, report what would move without moving it |
| `--source`, `--dest` | Override the saved directories for one run |
| `--config` | Configuration file, default `~/.config/ne-image-sorter/config.json` |
| `--log-dir` | Log directory, default `~/.cache/ne-image-sorter` |
| `--version` | Print the version and exit |

## How the policy works

Rules are checked top to bottom against each image. **The first rule that
matches decides**, and anything reaching the bottom takes the default. That
ordering is what lets one narrow move rule sit above broad keep rules.

The shipped policy is the one this collection was sorted with:

```
1. move if height <= 1080
2. keep if aspect == 16:9 (+/-1%)
3. keep if aspect == 16:10 (+/-1%)
otherwise move
```

A rule tests `aspect`, `width`, or `height` with `==`, `!=`, `<`, `<=`, `>`, or
`>=`. Tolerance is a percentage and applies to `==` and `!=` only, which is what
makes `==` usable against real files: a 2912x1632 wallpaper is 0.37% off 16:9
and a strict comparison would reject it. Aspect values accept `16:9`, `16/10`,
or a decimal such as `1.7778`.

The default policy reproduces the collection's own 2025-12-17 hand sort on 34 of
its 35 moves and 115 of its 120 keeps. `TestDefaultPolicyReproducesTheManualSort`
pins that, so changing the shipped defaults breaks the build rather than
silently re-filing the library.

## Keys

`↑`/`↓` move, `enter` confirms, `esc` goes back, `q` or `ctrl+c` quits. Those
mean the same thing on every screen. Nothing moves until the preview is
confirmed with `y`, and an existing file is never overwritten: a name clash is
suffixed `-1`, `-2` and so on.

In **Rules**: `a` adds, `d` deletes, `J`/`K` reorder, `t` toggles the default,
`r` restores the shipped policy.

## Layout

```
cmd/ne-image-sorter/ entry point, flags, headless mode
internal/domain/ Image, Rule, Policy, Config. No I/O, no dependencies
internal/repository/ Images and Config interfaces, plus filesystem and JSON
internal/sorter/ Plan then Apply, over the repository interfaces
internal/tui/ Bubbletea screens
internal/logging/ zerolog to a file
```

The repository interfaces are what let the service and every screen be tested
against in-memory fakes. Only `internal/repository` touches the filesystem.

Logs go to a file and never to the console, which is both the house rule and a
hard requirement here: a terminal interface redraws continuously, so a stray
console write corrupts the display.
134 changes: 134 additions & 0 deletions src/cmd/ne-image-sorter/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Command ne-image-sorter sorts a directory of images by aspect ratio and
// resolution, moving everything a configurable policy rejects into a second
// directory. It runs as a terminal interface by default, and headless with
// --sort for scripted use.
package main

import (
"flag"
"fmt"
"io"
"os"
"path/filepath"

tea "github.com/charmbracelet/bubbletea"

"github.com/nerdexecutive/ne-image-sorter/internal/domain"
"github.com/nerdexecutive/ne-image-sorter/internal/logging"
"github.com/nerdexecutive/ne-image-sorter/internal/repository"
"github.com/nerdexecutive/ne-image-sorter/internal/sorter"
"github.com/nerdexecutive/ne-image-sorter/internal/tui"
)

// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"

func main() {
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "ne-image-sorter:", err)
os.Exit(1)
}
}

// run wires the application together. Result output goes to out rather than
// to the process stdout directly, which keeps the headless path testable and
// leaves the log file as the only place this tool writes diagnostics.
func run(out io.Writer) error {
var (
showVersion = flag.Bool("version", false, "print the version and exit")
configPath = flag.String("config", defaultConfigPath(), "path to the configuration file")
logDir = flag.String("log-dir", defaultLogDir(), "directory to write the log file into")
source = flag.String("source", "", "override the configured source directory")
dest = flag.String("dest", "", "override the configured destination directory")
dryRun = flag.Bool("dry-run", false, "with --sort, report what would move without moving it")
headless = flag.Bool("sort", false, "sort without the interface and exit")
)
flag.Parse()

if *showVersion {
fmt.Fprintln(out, "ne-image-sorter", version)
return nil
}

log, logFile, err := logging.Open(*logDir)
if err != nil {
return fmt.Errorf("start ne-image-sorter: %w", err)
}
defer logFile.Close()

configs := repository.NewJSONConfig(*configPath)
cfg, err := configs.Load()
if err != nil {
return fmt.Errorf("start ne-image-sorter: %w", err)
}
if *source != "" {
cfg.SourceDir = *source
}
if *dest != "" {
cfg.DestDir = *dest
}

svc := sorter.New(repository.NewFileImages(), log)
if *headless {
return sortHeadless(out, svc, cfg, *dryRun)
}

program := tea.NewProgram(tui.NewApp(cfg, configs, svc, log), tea.WithAltScreen())
if _, err := program.Run(); err != nil {
log.Error().Err(err).Msg("interface exited with an error")
return fmt.Errorf("run interface: %w", err)
}
return nil
}

// sortHeadless runs one sort without the interface, for scripts and cron.
func sortHeadless(out io.Writer, svc *sorter.Service, cfg domain.Config, dryRun bool) error {
plan, err := svc.Plan(cfg)
if err != nil {
return fmt.Errorf("headless sort: %w", err)
}

for _, d := range plan.Moves() {
fmt.Fprintf(out, "move %-40s %-11s %s\n", d.Image.Name, d.Image.Resolution(), d.Reason)
}
fmt.Fprintf(out, "\n%d to move, %d to keep, %d scanned\n",
plan.MoveCount, plan.KeepCount, len(plan.Decisions))

if dryRun {
fmt.Fprintln(out, "dry run: nothing was moved")
return nil
}

report, err := svc.Apply(cfg, plan)
if err != nil {
return fmt.Errorf("headless sort: %w", err)
}
fmt.Fprintf(out, "moved %d, failed %d\n", report.Moved, report.Failed)
for _, e := range report.Errors {
fmt.Fprintln(out, " failed:", e)
}
if report.Failed > 0 {
return fmt.Errorf("headless sort: %d image(s) could not be moved", report.Failed)
}
return nil
}

// defaultConfigPath is the per-user configuration location, falling back to
// the working directory when the user config directory is unavailable.
func defaultConfigPath() string {
dir, err := os.UserConfigDir()
if err != nil {
return "ne-image-sorter.json"
}
return filepath.Join(dir, "ne-image-sorter", "config.json")
}

// defaultLogDir is the per-user log location, falling back to a local logs
// directory.
func defaultLogDir() string {
dir, err := os.UserCacheDir()
if err != nil {
return "logs"
}
return filepath.Join(dir, "ne-image-sorter")
}
Loading