The Daily Newsstand · Free, Always
Monday, October 26, 2026

Конвертер файлов, который открывается во время перетаскивания: как я сделал Kumquat для macOS без Xcode

Translate

Я тащу файл в Finder, коротко нажимаю Shift, и под курсором раскрывается колесо с действиями: «в JPG», «сжать до 2 МБ», «распознать текст», «склеить в PDF». Бросаю файл на лепесток, и результат появляется рядом с оригиналом. Расскажу, как поймать такой жест без разрешения Accessibility, зачем пришлось собирать ffmpeg и девять кодеков из исходников, насколько VideoToolbox быстрее libx264 и как всё это писалось вместе с Claude от Anthropic.

Kumquat – это открытый (MIT) конвертер файлов, который живёт в строке меню macOS. Работает на Apple Silicon с macOS 14 и новее, интерфейс на русском и английском.

Сразу о соавторстве. Приложение сделано мной (kipperstudio) вместе с Claude, ИИ-моделью Anthropic. Большую часть кода написал Claude под моим руководством, а я придумывал, ставил задачи, проверял руками и решал, что выпускать. В конце статьи есть отдельный раздел о том, как это было устроено и где ИИ ошибался.

Проблема

У меня это выглядело так: несколько раз за день нужно не открыть файл, а что-то с ним сделать. Перевести снимок с iPhone в формат, который понимает сайт. Ужать запись экрана до лимита почты. Достать текст из скриншота с ошибкой. Склеить сканы договора в один документ. Вычистить геометки перед тем, как выложить фото.

Каждая такая мелочь превращалась в маленький квест: найти подходящую утилиту или веб-сервис, загрузить туда файл (часто вполне личный), выставить настройки, скачать результат обратно. Мне хотелось, чтобы действие было там, где уже находится файл: под курсором, в момент, когда я его тащу.

Идея жеста: перетаскивание плюс короткий Shift

Перетаскивание на macOS и так означает «я хочу что-то сделать с этим файлом». Не хватает только вопроса «что именно». Поэтому жест такой:

  1. Начинаете тащить файлы откуда угодно: из Finder, с рабочего стола, из почты.

  2. Не отпуская кнопку мыши, коротко нажимаете Shift.

  3. Под курсором появляется колесо. Задержка на категории раскрывает её, задержка на центре возвращает назад.

  4. Бросаете файлы на лепесток. Результат сохраняется рядом с оригиналом, оригинал не меняется.

Почему колесо, а не обычное меню? В радиальном меню все пункты на одинаковом расстоянии от курсора, а направление запоминается мышечной памятью: через неделю рука сама уходит «вверх» к конвертации. К тому же во время перетаскивания нельзя кликать, есть только движение и отпускание, а колесо идеально подходит под «навёл и бросил».

Колесо показывает только то, что подходит ко всем перетаскиваемым файлам сразу. Если тащить HEIC и MOV вместе, пункта «в MP3» не будет.

Корневое колесо с категориями

Корневое колесо с категориями

Как поймать жест без Accessibility

Самый очевидный путь – глобальный CGEventTap или NSEvent.addGlobalMonitorForEvents. Но мониторинг клавиатуры в чужих приложениях требует разрешения Accessibility или Input Monitoring. Для конвертера файлов просить у пользователя доступ ко всем нажатиям клавиш – перебор, и я бы сам такое приложение не поставил.

Оказалось, что всё нужное можно получить опросом публичного состояния, без единого разрешения:

  • NSEvent.pressedMouseButtons – нажата ли сейчас кнопка мыши;

  • NSEvent.modifierFlags – какие модификаторы зажаты;

  • NSPasteboard(name: .drag) – системный буфер перетаскивания. Когда начинается drag, его changeCount меняется, а внутри лежат URL файлов.

Таймер тикает редко (раз в 120 мс), пока кнопка мыши отпущена, и переходит на 60 Гц, как только кнопку нажали. Вот ядро DragGestureMonitor.swift:

private func tick() {
    let mouseDown = NSEvent.pressedMouseButtons & 1 != 0

    if mouseDown && !wasMouseDown {
        changeCountAtMouseDown = dragPasteboard.changeCount
        isDraggingFiles = false
        draggedURLs = []
        if !isPollingActively { schedule(interval: activeInterval) }
    }

    if mouseDown && !isDraggingFiles && dragPasteboard.changeCount != changeCountAtMouseDown {
        let urls = readFileURLs()
        if !urls.isEmpty {
            isDraggingFiles = true
            draggedURLs = urls
            previousModifiers = NSEvent.modifierFlags.intersection(relevantFlags)
            pressedUnion = []
        } else {
            // Not a file drag; ignore until next mouse down.
            changeCountAtMouseDown = dragPasteboard.changeCount
        }
    }

    if isDraggingFiles && mouseDown {
        trackModifierTap()
    }
    // ... отпускание кнопки: onDragEnded, возврат на медленный таймер

Ключевая деталь – сравнение changeCount с моментом нажатия кнопки. Просто проверять «в буфере есть файлы» нельзя: там остаются данные от прошлого перетаскивания.

Тап, а не удержание

Второй момент: Shift во время перетаскивания люди жмут и по другим причинам. Поэтому колесо открывается только на короткое нажатие (до 0,6 с) ровно одного модификатора. Если в процессе зажали Shift+Option, это уже не наш жест. Какой модификатор открывает колесо, настраивается.

private func trackModifierTap() {
    let now = ProcessInfo.processInfo.systemUptime
    let mods = NSEvent.modifierFlags.intersection(relevantFlags)
    defer { previousModifiers = mods }
    guard mods != previousModifiers else { return }

    if previousModifiers.isEmpty && !mods.isEmpty {
        pressStarted = now
        pressedUnion = mods
    } else if !mods.isEmpty {
        pressedUnion.formUnion(mods)
    } else if !previousModifiers.isEmpty && mods.isEmpty {
        // Released: a tap if it was short and exactly one modifier key was involved.
        defer { pressedUnion = [] }
        let held = now - pressStarted
        guard held <= tapGraceInterval else { return }
        guard let key = ModifierKey.allCases.first(where: { pressedUnion == $0.flag }),
              let mode = AppSettings.shared.mode(for: key) else { return }
        // Re-read in case more files were added to the drag.
        let urls = readFileURLs()
        onTrigger?(mode, NSEvent.mouseLocation, urls.isEmpty ? draggedURLs : urls)
    }
}

pressedUnion накапливает все модификаторы, которые были зажаты за одно нажатие, так что «Shift, потом добавил Cmd, потом всё отпустил» корректно отбрасывается.

Панель, которая принимает drop и не крадёт фокус

Колесо – это NSPanel со стилем [.borderless, .nonactivatingPanel] на уровне .popUpMenu. Non-activating означает, что Finder остаётся активным приложением и перетаскивание не прерывается. Панель сама зарегистрирована как приёмник drag-and-drop, поэтому файлы падают прямо на лепесток, а не «куда-то под» окно. Ключевым окном она становится только в режиме клика (когда колесо открыто из меню или горячей клавишей), чтобы работал Esc.

Есть и другие входы: пункт в строке меню, Finder › Службы, горячая клавиша ⌃⌥K для выделенных в Finder файлов, наблюдаемые папки и командная строка. Но перетаскивание с Shift – главный сценарий, ради которого всё затевалось.

Колесо: геометрия и стекло

Геометрия живёт в отдельной чистой структуре FanLayout без единой зависимости от AppKit-вьюх, поэтому её легко тестировать. Каждый лепесток – кольцевой сектор. Несколько решений, которые выросли из практики:

  • Минимум три слота. Если действий одно или два, они всё равно рисуются как лепестки на кольце из трёх слотов, а не как полный бублик. Два лепестка ставятся симметрично относительно верха.

  • Два кольца. Когда пунктов больше десяти, они делятся между внутренним и внешним кольцом пропорционально длине окружности, чтобы дуги лепестков были примерно одинаковыми. Внешнее кольцо повёрнуто на полпетали, чтобы швы не совпадали.

  • Ровный зазор. Между соседними лепестками зазор постоянной ширины в 5 pt, а не постоянного угла. Для этого края сектора смещаются на asin(gap / 2r) отдельно для внешней и внутренней дуги, иначе у центра щели схлопываются.

  • Скруглённые углы. Сектор сначала уменьшается на радиус скругления, затем к нему добавляется его же обводка шириной 2r с круглыми соединениями. Получается аккуратный скруглённый лепесток без ручной работы с дугами.

  • Задержки. Категория раскрывается через 0,32 с наведения, возврат через центр срабатывает через 0,45 с. Меньше – случайные срабатывания при пролёте курсора, больше – ощущение тормозов.

Стекло сделано через NSVisualEffectView с материалом .hudWindow и blendingMode = .behindWindow. Чтобы размытие было только под лепестками, объединение всех путей рисуется в maskImage. Тень отдельной вьюхой с shadowPath той же формы. Колесо всегда в тёмном оформлении, а у каждой категории свой цвет из палитры: конвертация – цвета кумквата, оптимизация – листа, редактирование – мёда и так далее.

Раскрытая категория «Изменить»

Раскрытая категория «Изменить»

Движки

Внутри Kumquat – набор движков за общим протоколом ConversionEngine. Что во что можно конвертировать, описано в одном switch в ConversionCatalog.swift, и колесо строится из него же. Например, картинки можно перевести в JPG, PNG, HEIC, WebP, AVIF, TIFF, BMP, GIF и PDF, а если в сборке нет кодера WebP, этот пункт просто исчезает.

Для нескольких файлов commonOutputs(for:) берёт пересечение списков. Главный принцип – по максимуму системные фреймворки, сторонний код только там, где без него никак.

Картинки идут через ImageIO и Core Image. Отдельная боль – ориентация EXIF: фото с телефона хранится как 400×300 с тегом «повернуть на 90°», а показывается как 300×400. Если про это забыть, конвертированная картинка ляжет набок. В тестах есть специальная фикстура ровно для этого случая.

Vision отвечает за распознавание текста (русский и английский), поиск лиц для размытия и удаление фона через VNGenerateForegroundInstanceMaskRequest. Всё локально, ничего не уходит в сеть.

Keynote, Pages, Numbers конвертируются без самих приложений iWork. Старые документы iWork '09 содержат готовый QuickLook/Preview.pdf. Новые хранят только превью первой страницы, поэтому Kumquat просит системный генератор Quick Look (qlmanage -p -o) отрисовать все страницы в векторный PDF и склеивает их.

EPUB и офисные форматы рендерятся в PDF через оффскрин WKWebView, у которого выключен JavaScript и заблокированы все нелокальные запросы. EPUB 3 на выходе собирается вручную: по главе на каждый <h1>, mimetype первым файлом без сжатия, плюс NCX для старых читалок.

Таблицы XLSX читаются и пишутся без сторонних библиотек: OOXML – это ZIP с XML внутри, а для упаковки хватает системных /usr/bin/zip и unzip. Отдельно пришлось научиться понимать, какие числовые форматы ячеек на самом деле даты.

Плюс интерактивные редакторы: разметка скриншотов, закрашивание, коррекция фото, страницы PDF, обрезка видео и аудио на таймлайне, «запикивание» и закрашивание областей на видео.

Редактор обрезки с таймлайном и формой волны

Редактор обрезки с таймлайном и формой волны

Видео быстрее в два раза: VideoToolbox против libx264

Встроенный ffmpeg собран под LGPL, а значит в нём нет libx264 и libx265 (они GPL). H.264 и HEVC кодируются аппаратно через VideoToolbox, медиадвижок Apple Silicon. Вопрос был в том, как сопоставить качество: у x264 есть привычный CRF, а у h264_videotoolbox – постоянное качество -q:v от 1 до 100 с совершенно другой шкалой.

Я взял ролик 1080p30 длиной 45 секунд, прогнал его через libx264 -preset medium с шестью значениями CRF и через VideoToolbox, подбирая -q:v так, чтобы VMAF совпадал в пределах примерно одного пункта:

libx264 CRF

Время

Размер

VMAF

VT -q:v

Время

Размер

VMAF

18

18,9 с

39,2 МБ

96,0

77

7,1 с

36,6 МБ

95,4

21

17,5 с

25,0 МБ

94,8

73

7,1 с

29,2 МБ

94,6

23

17,2 с

18,3 МБ

93,6

68

8,2 с

21,5 МБ

93,4

26

13,6 с

11,2 МБ

90,8

62

7,2 с

14,0 МБ

91,1

28

12,1 с

7,9 МБ

88,2

57

7,1 с

10,1 МБ

88,8

32

10,7 с

4,0 МБ

81,1

48

7,0 с

5,7 МБ

82,5

Выводы:

  • VideoToolbox быстрее в 1,5–2,7 раза, и его время почти не зависит от качества: около 7 секунд на любом пресете.

  • Платить приходится байтами: при равном VMAF файл от VideoToolbox на 0–40% больше. Хуже всего на низких битрейтах, где x264 со своим анализом движения выигрывает заметно.

  • Для сжатия до целевого размера («не больше 25 МБ») используется ограниченный средний битрейт. Файлы получаются в пределах 96–98,5% от цели, то есть не перелетают лимит.

Эти шесть пар легли в код как опорные точки, между которыми vtQuality(crf:hevc:) интерполирует линейно. Для HEVC к качеству добавляется +5: при том же -q:v HEVC через VideoToolbox даёт VMAF ниже, чем H.264.

Аппаратный кодер не всемогущ. H.264 в VideoToolbox не берёт кадры шире 4096 px, сессия кодера может не создаться. Поэтому при ошибке FFmpeg.run проходит по цепочке запасных кодеров и переписывает аргументы под каждый (-q:v в CRF, профиль, тег avc1/hvc1):

a = ["-c:v", hevc ? "hevc_videotoolbox" : "h264_videotoolbox", "-allow_sw", "1",
     "-profile:v", hevc ? "main" : "high", "-pix_fmt", "yuv420p",
     "-g", "\(keyframeInterval)", "-bf", "2"]
if let br = s.videoBitrateKbps {
    a += bitrateArgs(br)
} else {
    a += ["-q:v", "\(vtQuality(crf: crf, hevc: hevc))"]
}

// ...

static func encoderFallbacks(_ args: [String]) -> [[String]] {
    guard let current = videoEncoder(in: args), vtEncoders.contains(current) else { return [] }
    var chain: [String] = []
    if current == "h264_videotoolbox", FFmpeg.encoders.contains("hevc_videotoolbox") { chain.append("hevc_videotoolbox") }
    if current == "hevc_videotoolbox", FFmpeg.encoders.contains("libx265") { chain.append("libx265") }
    if FFmpeg.encoders.contains("libx264") { chain.append("libx264") }
    chain.append("mpeg4")
    return chain.map { rewrite(args, to: $0) }
}

Цепочка такая: VT H.264 → VT HEVC (на Apple Silicon тянет до 8K) → libx264/libx265, если ffmpeg собран с ними → LGPL-кодер mpeg4 как последний рубеж. Ещё одна мелочь, которую легко пропустить: без -g 250 VideoToolbox ставит ключевой кадр каждые 12 кадров, и файл раздувается на ровном месте.

Окно прогресса: параллельная обработка, понятные ошибки, отмена

Окно прогресса: параллельная обработка, понятные ошибки, отмена

ffmpeg под LGPL для macOS 14

Первая версия просто копировала в бандл ffmpeg из Homebrew. На моей машине всё работало. Потом я посмотрел на загрузочные команды библиотек (otool -l, секция LC_BUILD_VERSION) и увидел minos 26.0. Бутылки Homebrew собираются под ту версию macOS, на которой стоит Homebrew, и на macOS 14 такое приложение просто не запустится. Вдобавок ffmpeg из Homebrew собран с --enable-gpl (x264, x265), что для MIT-приложения с встроенными бинарниками означает GPL на всё распространяемое.

Решение – собирать всё самому. scripts/build-deps.sh скачивает из официальных источников lame, opus, libvpx, dav1d, SVT-AV1, libwebp, libpng, libjpeg-turbo и xz. Каждый архив закреплён по версии и SHA-256, сборка идёт под arm64 с MACOSX_DEPLOYMENT_TARGET=14.0, а заголовки и библиотеки Homebrew явно исключены из путей поиска. Homebrew остаётся только поставщиком инструментов сборки: pkgconf, meson, ninja, cmake.

# fetch <name> -> sets SRC to the extracted, verified source directory
fetch() {
    local name="$1" spec ver url sha file dir
    for spec in "${PACKAGES[@]}"; do [[ "${spec%%|*}" == "$name" ]] && break; done
    IFS='|' read -r _ ver url sha <<<"$spec"
    file="$SRC_ROOT/$name-$ver.tarball"   # tar auto-detects gz/bz2/xz
    if [[ ! -f "$file" ]]; then
        echo "==> Downloading $url"
        curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 30 --retry 5 --retry-all-errors -o "$file.part" "$url"
        mv "$file.part" "$file"
    fi
    local actual; actual="$(shasum -a 256 "$file" | awk '{print $1}')"
    if [[ "$actual" != "$sha" ]]; then
        echo "Checksum mismatch for $file: $actual (want $sha)" >&2; rm -f "$file"; exit 1
    fi
    echo "==> $name $ver (sha256 $sha OK)"
    # ... распаковка в build/deps-src
}

Затем scripts/build-ffmpeg.sh собирает ffmpeg 9.0.2 с --disable-gpl --disable-nonfree, без сети (--disable-network, только протоколы file и pipe), с VideoToolbox и AudioToolbox. Важно, как всё линкуется:

  • кодеки (opus, vpx, dav1d, SVT-AV1, webp и остальные) – статически;

  • libav* и LAME (они под LGPL) – динамически, в Tools/lib, чтобы пользователь мог заменить их своей сборкой, как того требует LGPL.

В итоге число встроенных Mach-O файлов сократилось с 24 до 12. Скрипт сам проверяет лицензию и падает, если что-то GPL просочилось в сборку:

LICENSE_TEXT="$("$PREFIX/bin/ffmpeg" -hide_banner -L | head -3)"
echo "$LICENSE_TEXT"
if "$PREFIX/bin/ffmpeg" -hide_banner -buildconf | grep -Eq -- '--enable-(gpl|nonfree|libx264|libx265)'; then
    echo "GPL/nonfree option found in -buildconf" >&2; exit 1
fi
echo "$LICENSE_TEXT" | grep -q "Lesser General Public" || { echo "ffmpeg -L is not LGPL" >&2; exit 1; }

Последний рубеж – scripts/bundle-tools.py. Он копирует бинарники в бандл, переписывает пути через install_name_tool на @loader_path/, проверяет, что ничего не ссылается за пределы приложения, и читает LC_BUILD_VERSION у каждого Mach-O. Если хоть у одного minos выше 14.0, сборка падает. Тексты лицензий всех десяти компонентов кладутся в Contents/Resources/Tools/licenses и доступны в «Настройки › О программе › Лицензии…».

Без Xcode

Kumquat собирается Swift Package Manager и Command Line Tools. Проекта Xcode нет вообще, весь Package.swift умещается в полтора десятка строк. Около 24 тысяч строк Swift в 95 файлах.

scripts/build-app.sh делает то, что обычно делает Xcode:

  1. swift build -c release --arch arm64;

  2. собирает структуру Kumquat.app, копирует ресурсы и .lproj с проверкой plutil -lint;

  3. генерирует Info.plist с LSUIElement (приложение только в строке меню, без иконки в Dock) и LSMinimumSystemVersion;

  4. встраивает ffmpeg, ffprobe и cwebp через bundle-tools.py;

  5. ставит ad-hoc подпись и проверяет её через codesign --verify --deep --strict.

scripts/release.sh упаковывает DMG, а если в окружении есть DEVELOPER_ID и NOTARY_PROFILE, подписывает каждый Mach-O изнутри наружу с hardened runtime и отправляет на нотаризацию через notarytool. Он тоже есть в Command Line Tools, Xcode для этого не нужен. Для установки есть Homebrew tap: brew install --cask thealexray/tap/kumquat.

Из минусов жизни без Xcode: часть SwiftUI-кода, завязанная на макросы (их раскрывают плагины компилятора, которые идут с Xcode), в чистых Command Line Tools не собиралась. Поэтому модели представлений здесь написаны по-старому, через ObservableObject и @Published. Превью SwiftUI тоже нет, так что интерфейс проверялся скриншотами (о них ниже).

Как это писалось с ИИ

Теперь о самом интересном для меня. Kumquat писала команда параллельных агентов Claude, а я был кем-то вроде техлида и продакт-менеджера одновременно.

Как была устроена работа

  • Общие контракты. Файл Model/Contracts.swift описывает протоколы и типы, через которые общаются модули: ConversionEngine, ConversionOptions, ProgressHandler, правила именования результатов. Первая строка файла: «Change only with the coordinator's agreement». Агенты, пишущие движки, не трогают контракты без согласования.

  • Файлы-заглушки. Перед параллельной работой создавались пустые реализации с правильными сигнатурами, чтобы проект компилировался с первого дня, а каждый агент заполнял свой файл.

  • Владение файлами. У каждого агента был свой набор файлов. Агент движка изображений не правит видео, агент колеса не лезет в настройки. Это убрало почти все конфликты слияния.

  • Тесты как страховка. scripts/test-suite.sh гоняет приложение через его же CLI (Kumquat --convert webp photo.png) и проверяет не код возврата, а сам результат: размеры и ориентацию картинок через ImageIO, кодеки и длительность через ffprobe, число страниц и текстовый слой PDF через PDFKit, кириллицу в документах, кавычки и переносы строк в CSV после круга через XLSX. Каталог конвертаций из ConversionCatalog.swift зеркалится в тестах, и если в приложении появился формат, о котором тесты не знают, проверка --list падает.

Сейчас в наборе 393 случая: 389 проходят, 4 пропускаются (исходники XLS, Keynote, Pages и Numbers невозможно детерминированно сгенерировать без Excel и iWork). Быстрая версия набора и проверка локализации запускаются в GitHub Actions на macos-15 на каждый пуш.

Что пошло не так

Было бы нечестно писать только про успехи.

  • Жест мышью агенты проверить не могли. Реальное перетаскивание с нажатием Shift не воспроизвести из терминала синтетическими событиями так, чтобы это было похоже на руку человека. Логика с changeCount, порогом «тапа» в 0,6 с и задержками раскрытия проверялась по отдельности: колесо рендерилось оффскрин, обработчики вызывались из тестовой программы. А целиком жест можно проверить только руками. Поэтому в код заложены подробные отладочные логи жеста в os.Logger: если у кого-то колесо не появится, по ним видно, на каком шаге всё сломалось.

  • Оффскрин-рендер не показывает стекло и видео. Для проверки интерфейса агенты рендерили окна в картинки. Но NSVisualEffectView в таком рендере не размывает то, что под окном, а плеер видео остаётся пустым прямоугольником. Внешний вид колеса и редакторов видео приходилось проверять глазами на живом приложении.

  • SwiftUI-макросы без Xcode. Агенты по привычке писали современный SwiftUI, где состояние (@State и компания) строится на макросах, а Command Line Tools отказывались это собирать. Пришлось переписывать на классический ObservableObject.

  • Первый прогон CI упал. Тестовая фикстура AVIF создавалась через sips, а на раннерах с macOS 15 sips не умеет писать AVIF. Теперь при неудаче фикстура делается через SVT-AV1 во встроенном ffmpeg.

  • Локализация сломала тест. Когда приложение перевели на русский, переведёнными оказались и сообщения командной строки, и тест, который искал английский текст ошибки, упал. С тех пор вывод CLI всегда на английском, чтобы на него могли опираться скрипты.

Общий вывод: ИИ отлично пишет код по чётким контрактам и сам себя проверяет, если есть автоматические тесты. А вот всё, что про ощущения (как жест ложится в руку, как выглядит стекло, не раздражает ли задержка), остаётся на человеке.

Редактор разметки скриншотов

Редактор разметки скриншотов

Итог

Главные выводы, которые я вынес из проекта:

  • Глобальный жест можно поймать без Accessibility: достаточно опрашивать состояние мыши, модификаторов и changeCount у drag-пастборда. Для пользователя это проще, а пугающих разрешений не нужно.

  • Аппаратный VideoToolbox в 1,5–2,7 раза быстрее libx264 при сопоставимом VMAF, но на низких битрейтах ему нужно до 40% больше байтов. Для конвертера «по кнопке» это хороший компромисс.

  • Бутылки Homebrew нельзя просто положить внутрь приложения: они собраны под версию macOS машины сборки. Если нужна поддержка старых систем, зависимости придётся собирать из исходников с нужным deployment target.

  • Без Xcode вполне можно жить: SwiftPM, Command Line Tools и пара скриптов закрывают сборку, подпись и DMG.

  • С командой ИИ-агентов скорость упирается не в написание кода, а в проверку: общие контракты, владение файлами и автотесты оказались важнее любых промптов.

Нотаризации пока нет, поэтому первый запуск – через правый клик › «Открыть». Отдельно интересно мнение о самом жесте: насколько естественным кажется Shift во время перетаскивания и что было бы удобнее.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.