
В прошлый раз мы пробежались по предметной области, добыли данные и вывели их на экран. В принципе, этого достаточно для выполнения моего ежедневного ритуала, но в быту я использую более навороченную версию скрипта. Её стоит рассмотреть, чтобы закончить вводный курс по Thuja и поговорить об особенностях консольных графических интерфейсов. Слишком уж многое здесь можно и нужно делать не так, как в больших фреймворках.

Модель
В первой версии скрипта мы не стали заводить отдельный тип для модели в MVU и просто взяли уже готовый Data.Main. Пока наша страница не имела состояния, это работало. Но в этот раз состояние точно появится, а для него модель просто необходима. В MVU она, как правило, выражается через рекорд вида:
type Model = { Data : Data.Main // SelectedProblem : ... }
Схема рабочая, но уж слишком открытая. Model можно будет «изменить» неправильным образом или собрать вручную по частям в каком-нибудь неожиданном месте, и какая-нибудь зараза это обязательно сделает. Чтобы этого не произошло, я предлагаю начинать с чего-то такого:
type Model private (core) as this = static let init (data : Data.Main) = Model {| Data = data.Core |} static member Create data = init data member val Core = {| core with Shell = this |} member this.UpdateData (data : Data.Main) = Model {| Data = data.Core |}
Получаем тот же рекорд, но теперь он спрятан в оболочке, которая контролирует все входы и выходы из системы, так как только у Model есть доступ к конструктору. Кроме того, для создания Model с частично изменённым ядром требуется иметь доступ к объекту core, который не покидает границы экземпляра (обратите внимание, что Core имеет дополнительное поле, которого нет в core). Из-за этого только Model может определять функции вида:
member _.Increment () = Model {| core with Counter = core.Counter + 1 |} // Невалидный код, т. к. model.Core имеет лишнее поле. let increment (model : Model) = Model {| model.Core with Counter = model.Core.Counter + 1 }
Важно, что при такой связке компилятор узнаёт состав полей core не из декларации типа рекорда, а из функции init. Типизация тоже происходит явочным порядком, но она может быть размазана по всему Model, если используются крайние случаи обобщения типа None или []. За счёт этого core очень легко расширять. Он стремительно разбухает «под давлением обстоятельств» и в какой-то момент становится настолько большим и сложным, что его физически сложно собрать руками. Полагаясь на человеческую лень (у машины с этим проблемы) я обычно вообще не привачу конструктор и делаю исключение лишь при транспортировке кода на Хабр.
Статусность
Первое, что надо сделать, при разработке кнопочного консольного интерфейса — завести статусную строку где-нибудь на краю окна, наподобие тех, что мы видим в IDE. Это необходимо, потому что ни один из местных контролов не имеет встроенной анимации. Нажатия на псевдокнопки (не мышью, а с клавиатуры) не дают никакой отдачи, из-за чего мы можем просто не увидеть разницу между работающим и неработающим приложением. Для выражения статуса обычно хватает одной строки, но их может быть несколько:
static let init (data : Data.Main) = Model {| Data = data.Core Status = "We are ready!" |}
Поле Status надо обновлять каждый раз, когда мы что-то делаем по команде пользователя. Например, когда начали или закончили обновление данных:
member this.InRefreshing () = Model {| core with Status = "Refreshing data..." |} member this.UpdateData (data : Data.Main) = Model {| Data = data.Core Status = "Data refreshed." |}
Этот способ дёшев, универсален и при этом не мешает альтернативным вариантам обратной связи.
Одно обновление в руки
В прошлой версии каждое нажатие F5 запускало новый запрос на обновление. Учёт уже начатых запросов не вёлся, из-за этого профессиональный игрок в биос мог бы заддосить сервер ещё до первого ответа.

На самом деле в codeforces встроен фильтр, который препятствует слишком частому обращению к серверу, так что такое закликивание куда вероятнее убьёт наше приложение, а не сервис (парсер умрёт, уронит джобу, она уронит скрипт). Такое поведение некорректно. Нужно как-то отменять предыдущий запрос, ну или хотя бы блокировать новый. Обычно для таких задач мы поднимаем небольшой гопаковский сервер (квазиактор), который принимает запросы от пользователя, и, основываясь на своём состоянии, решает, что с этими запросами делать. MVU-цикл в такой схеме выступает лишь как входной прокси (если без этого нельзя) и как проекция состояния актора во вью. То есть он узнаёт о решениях актора постфактум и никак не участвует в их принятии.

Если вы хорошо поняли, что я имею в виду, берите Hopac. Если нет, то поэкспериментируйте в другой раз, потому что в нашем случае можно обойтись одним булевым полем:
static let init (data : Data.Main) = Model {| Data = data.Core InProgress = false Status = "We are ready!" |} member this.InRefreshing () = Model {| core with InProgress = true Status = "Refreshing data..." |} member this.UpdateData (data : Data.Main) = Model {| Data = data.Core InProgress = false Status = "Data refreshed." |}
Местные контролы беспомощны и безынициативны, поэтому все возможные изменения будут инициированы пользователем с клавиатуры. Они всегда будут проходить через функцию update, где их можно вовремя остановить:
let update (model : Model) event = let core = model.Core match event with | Choice1Of2 event -> match event with | FKey 5, _ when not core.InProgress -> model.InRefreshing () , Cmd.ofJob ^ Job.map Choice2Of2 core.Data.Shell.Refresh | _ -> model, Cmd.none | Choice2Of2 data -> model.UpdateData data, Cmd.none
Повторю, что негативный сценарий обновления мы осознанно игнорируем. Пускай скрипт падает, его подберут.
Пользователь узнаёт о нашей системе управления из инструкции, которую мы выводим на боковую панель. Очень вероятно, что он будет пялиться именно на неё, когда будет первый раз нажимать соответствующую клавишу. С учётом этого имеет смысл вывести на ту же панель зависимые флаги:
let instruction = [| "# Control" "- Q / Ctrl + C -- exit" ( let suffix = if model.InProgress then " (in progress)" else "" $"- F5 - reload data%s{suffix}" ) |]
Субъективно, консольные интерфейсы настолько статичны, что любое добавление текста улавливается краем глаза без особых усилий. Поэтому даже отвлёкшийся пользователь засечёт движение и поймёт, что его команды породили действия. Я воспроизвожу эту схему всякий раз, когда имею дело с приложениями, чьё управление описано на экране.
Навигация по задачам
Чтобы открыть страницу конкретной задачи, нам надо ввести в модель понятие выбранного элемента. Тут обычно конфликтуют две идеологии: исходными данными объявляется либо сам объект, либо его индекс в коллекции. В зависимости от этого выбора возможны разные корнер-кейсы и вычислительные потери. Я предпочитаю синтезировать нечто третье, благо размер проги и природа данных позволяют сделать это максимально прямолинейно. При подготовке данных для комплекта «сегодняшних задач» мы можем зашить индекс каждой задачи непосредственно в её рекорд:
let problemsOfTheDay = groups |> Seq.collect ^ fun p -> let next = p.Problems |> List.tryFind ^ fun p -> not ^ badContests.Contains p.Problem.ContestId && p.Interaction.IsNone let tried = p.Problems |> List.filter ^ fun p -> p.Interaction |> Option.exists ^ fun p -> match p.Status with | Status.Solved | Status.Wip -> (System.DateTime.UtcNow - p.Period.Last).TotalMinutes < timeWindowMinutes | Status.Failed -> true Option.toList next @ tried |> Seq.sortBy ^ fun p -> p.IndexInRatingGroup |> Seq.mapi ^ fun index p -> {| p with UiIndex = index |} // Кому трёт перфоманс, может взять массив. |> List.ofSeq
Тогда, сохранив в модели рекорд, мы автоматически сохраним и его индекс:
static let init (data : Data.Main) = Model {| SelectedProblem = data.Core.ProblemsOfTheDay.Head Data = data.Core InProgress = false Status = "We are ready!" |}
Дальше используем содержимое рекорда по его назначению, например, для вывода тегов:
[ model.SelectedProblem.Id ||> sprintf "# %i%s Tags:" "" for tag in model.SelectedProblem.Problem.Tags do $" - %s{tag}" ] |> String.concat Environment.NewLine |> text []
А сохранённые индексы понадобятся нам для навигации: клавиши вверх и вниз переключают задачу на предыдущую и следующую:
member private _.Select problem = Model {| core with SelectedProblem = problem Copied = false Status = sprintf "Selected %i%s." <|| problem.Id |} member this.SelectNext () = core.Data.ProblemsOfTheDay |> List.tryItem (core.SelectedProblem.UiIndex + 1) |> function | None -> this | Some next -> this.Select next member this.SelectPrevious () = core.Data.ProblemsOfTheDay |> List.tryItem (core.SelectedProblem.UiIndex - 1) |> function | None -> this | Some previous -> this.Select previous
Недра update про внутреннее устройство модели ничего не знают:
| Down, _ -> model.SelectNext(), Cmd.none | Up, _ -> model.SelectPrevious(), Cmd.none
И это очень хорошо, потому что километровые update и километровые view читаются с совершенно разными ощущениями. Если на нашей странице будет сразу несколько списков (как в упоминавшемся Far Manager), то подбор нужной реакции на Up/Down превратится в хрупкую череду развилок, которую лучше держать поглубже в модели. Быть может, процесс перехода к соседу можно автоматизировать, частично загнав его в какой-то набор готовых контролов, но я ставлю на то, что этот путь ведёт в никуда.
Кстати, в Thuja есть специальный списочный контрол, но в нём не зашито никакой бизнес-логика. Мы можем передать ему набор строк и сказать, какая из них должна быть подсвечена. На этом его возможности и полномочия заканчиваются. Если нам нужно что-то более сложное, то это придётся делать руками:
for item in model.Data.ProblemsOfTheDay do ... let textColor, backgroundColor = if item = model.SelectedProblem then Color.White, Some color else color, None text [ Color textColor match backgroundColor with | None -> () | Some color -> Background color ...
Даже мне это кажется немного необычным, но я часто имитирую большие контролы при помощи множества однострочных text. Мне это напоминает то, как в HTML всё разнообразие тегов было сожрано обычным <div>. Пошло ли это на пользу вебу, не знаю, но в случае с Thuja дистанция до экрана достаточно коротка, чтобы об этом не беспокоиться.
Наконец, необходимо убедиться, что при обновлении данных наше выделение по возможности сохраняется, для чего надо сопоставить идентификаторы рекордов от разных запросов:
member this.UpdateData (data : Data.Main) = Model {| Data = data.Core SelectedProblem = data.Core.ProblemsOfTheDay |> Seq.tryFind ^ fun p -> p.Id = core.SelectedProblem.Id |> Option.defaultValue data.Core.ProblemsOfTheDay.Head InProgress = false Status = "Data refreshed." |}
«Открытие» задачи
По нажатию Enter должно происходить «открытие» задачи, под которым можно ожидать открытие соответствующей вкладки в браузере, но я параноик, и меня такой вариант не устраивает. Мне достаточно получить ссылку в буфер обмена, дальше я всё сделаю сам.
В .NET Framework буфер обмена можно было дёрнуть из стандартной библиотеки, а значит, и из REPL, чем я в те времена активно пользовался. К сожалению, после перехода на .NET Core его приходится эмулировать через вызов других прог:
module Clipboard = open System.Diagnostics let run filename arguments = let pcs = new Process( StartInfo = ProcessStartInfo( FileName = filename, Arguments = arguments, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = false ) ) ignore ^ pcs.Start() let result = pcs.StandardOutput.ReadToEnd() pcs.WaitForExit() result let bat (cmd : string) = cmd.Replace("\"", "\\\"") |> sprintf "/c %A" |> run "cmd.exe" let copy text = bat $"echo %s{text} | clip"
Процесс отрабатывает моментально, но сам способ тяжеловесен и в сферическом вакууме мне не нравится. Но наши требования гораздо скромнее, чем у тех, кто хочет сразу открывать ссылку в браузере. К тому же мне всё равно нужны процессы, чтобы дёргать другие .fsx-скрипты, содержимое которых не адаптировано под загрузку через #load, например, из-за сайд-эффекта, который объявлен в корне файла. То есть несмотря на то, что и там, и тут используется fsi, нам может быть выгоднее запускать их через bat "dotnet fsi <path-to-other-script.fsx>".
Консольные команды зависимы от используемой операционной системы, так что наш Clipboard может не работать на линуксе, но он точно работает на винде, в том числе внутри обычных приложений (не скриптов). Опять же, возможны хитрые кейсы, где он стреляет в ногу, но у нас тут .fsx, можем вести себя дерзко.
В процессе вычитки мне напомнили про библиотеку TextCopy, в которой содержатся готовые интеропные привязки к большинству рантаймов dotnet. Она работает в .fsx, и её стоит считать вариантом по умолчанию для тех сценариев, где запуск иных процессов не предполагается.
#r "nuget: TextCopy" TextCopy.ClipboardService.SetText "Я в системе."
Дальше подключаем этот модуль к модели:
static let init (data : Data.Main) = Model {| SelectedProblem = data.Core.ProblemsOfTheDay.Head Copied = false Data = data.Core InProgress = false Status = "We are ready!" |} member this.UriCopied () = Model {| core with Copied = true Status = sprintf "Url %i%s copied to clipboard." <|| core.SelectedProblem.Id |} // + сбрасываем `Copied` в `false` при любом изменении селекта.
Потом в update:
// Недра `update` | Enter, _ -> core.SelectedProblem.Id ||> Urls.Problem.create |> Clipboard.copy |> ignore model.UriCopied (), Cmd.none
И во view:
( let suffix = if model.Copied then " (copied)" else "" $"- Enter -- copy url to clipboard%s{suffix}" )
Ключевая фича скрипта готова.
Ковровое накрытие
Когда я знаю теги задачки на codeforces, я значительно быстрее прихожу к правильному решению, потому что они работают как подсказки. Поэтому и на сайте, и в приложении они у меня по умолчанию спрятаны. Для чего я прикрутил к модели ещё одно булево свойство:
( let suffix = if model.ShowTags then "visible" else "hidden" $"- T -- show / hide tags (%s{suffix})" )
И вывел его в боковую панель:
model.SelectedProblem.Id ||> sprintf "# %i%s Tags:" "" if model.ShowTags then for tag in model.SelectedProblem.Problem.Tags do $" - %s{tag}" "" "Press `T` to hide." else "<HIDDEN>" "" "Press `T` to show."
Когда-то здесь я столкнулся с забавной багой: text не до конца зачищал выделенный под него прямоугольник. Если на предыдущем фрейме в выводимом string было больше строк, чем в текущем, то опустевшие строки не зачищались. Звучит как нечто, что должно застопорить всю работу, но аналогичные баги были во всех фреймворках, с которыми я работал (за исключением WPF, он потрясающе безупречен). Например, в Avalonia это проявлялось в виде полоски пикселей от предыдущего текста. Читать она не мешала, но бесила прилично. Нормальный workaround тогда мы так и не подобрали, хотя изворачивались как могли на протяжении пары месяцев, пока авторы не выпустили долгожданный фикс. В отличие от Avalonia, обойти дефект Thuja я смог почти моментально, поэтому дойти до автора с этой багой я просто забыл. Сейчас этот момент исправлен, версия с фиксом лежит в нугете. Однако я предполагаю, что проблема грязного холста в каком-то виде может опять вернуться, поэтому расковыряем решение до конца.
Консольное окно — это таблица, заполненная моноширинными символами. Если неиспользуемые клетки заполнить явным образом обычными пробелами, то у фреймворка физически не получится накосячить. Но для этого надо знать координаты доступной области. Всё это время они были перед нами. Вызов text [] "" возвращает не условный ViewElement, а каррированную функцию, которая ждёт Region и только потом возвращает результат. Соответственно, обработав этот Region явно, мы сможем скорректировать загружаемую в text строку:
fun region -> seq { model.SelectedProblem.Id ||> sprintf "# %i%s Tags:" "" if model.ShowTags then for tag in model.SelectedProblem.Problem.Tags do $" - %s{tag}" "" "Press `T` to hide." else "<HIDDEN>" "" "Press `T` to show." yield! Seq.initInfinite ^ fun _ -> "" } |> Seq.take region.Height |> Seq.map ^ fun p -> p.PadRight region.Width |> String.concat Environment.NewLine |> text [] <| region
Аналогичным образом можно работать с коллекциями элементов. Например, так я забивал всё пространство основного списка:
fun region -> seq { for item in model.Data.ProblemsOfTheDay do ... text [ ... ] $"%-5s{rating} #%-4i{item.IndexInRatingGroup} %-6s{status} %-6s{fullId} x%-6i{item.Stats} %s{item.Problem.Name.AsString}" let stub = System.String (Array.replicate region.Width ' ') while true do text [] stub } |> Seq.take region.Height |> List.ofSeq |> rows [ for _ in 1..region.Height do Absolute 1 ] <| region
Этот подход (с поправкой на особенности получения Region) работает во всех консольных интерфейсах. Это важно, потому что, судя по ишуям в аналогах Thuja, проблема некорректной зачистки в каком-то виде всплывает везде.
Развёртывание окна
Приложению необходимо занимать пространство, близкое к FullHD, иначе Thuja начинает резать текст статистики. Мы можем захватить это пространство, скорректировав абсолютные размеры окна через дефолтный System.Console, даже находясь внутри .fsx. Однако у нас отсутствует API, чтобы подвинуть окно в угол (или хоть куда-нибудь). Поэтому растягивание лучше всего полностью переложить на пользователя.
При особом желании мы можем заинтеропиться с виндой и добраться до активного окна, чтобы развернуть его на весь экран:
module Maximize = open System.Runtime.InteropServices [<DllImport("kernel32.dll", ExactSpelling = true)>] extern IntPtr GetConsoleWindow() [<DllImport("user32.dll")>] extern bool ShowWindow(IntPtr hWnd, int nCmdShow) let SW_MAXIMIZE = 3 let main () = let consoleHandle = GetConsoleWindow() ShowWindow(consoleHandle, SW_MAXIMIZE) |> ignore Maximize.main() Program.make (Model.Create data) view update |> Program.withKeyBindings (Choice1Of2 >> Cmd.ofMsg) |> Program.withTutuBackend |> Program.run
Сей код отлично работает, но не очень хорошо дружит с компилятором в IDE. Понятия не имею, что в REPL сделано иначе, но моей Visual Studio становится очень плохо, когда я начинаю править скрипт с DllImport-ами. С .fs такого не происходит. Лаги возникают независимо от того, затрагивается ли код интеропа или речь идёт о чём-то совсем плюшевом. Так что частота раскомментирования модуля Maximize обратно пропорциональна тому, как часто я редактирую утилиту в IDE.
Заключение

Итоговый скрипт можно посмотреть здесь. Это почти та же самая версия, что я использую у себя. Разница между моей и публичной — в нескольких дополнительных бизнес-правилах, которые я раскрывать не буду.
Момент публикации совпал с периодом, в ходе которого у меня осталось очень мало времени для codeforces. Кто будет ковырять скрипт на моих данных, пусть увеличит timeWindow до необходимых величин.
Основной посыл этих двух статей заключается в том, что при написании скриптовых UI мы можем позволить себе игнорировать целую пачку кейсов, которые в полноценных приложениях потребовали бы солидной дополнительной работы. Это не только экономия времени, но и возможность заюзать или попробовать технологии, чья репутация хромает, или чьи возможности не до конца прояснены. Это настолько безответственный опыт, что я испытываю от него удовольствие, особенно на контрасте с обычной разработкой.
Вторым пунктом идёт акцент на восприятии консольного окна как ограниченной таблицы символов. Мне это напоминает работу с битмапами, только вместо RGBA-пикселей и операций с линиями и прямоугольниками у нас цветной текст со строками, столбцами и прочими контейнерами. Поверх них (или даже вместо них, если готовы нырять в TuTu) можно накручивать собственные хелперы произвольной сложности. Из-за ячеистости окна это делается значительно проще, чем во взрослых фреймворках. Поэтому, если кто-то хочет поупражняться в написании DSL или просто поэкспериментировать, то Thuja и его аналоги — это очень хороший полигон. 100-200 несложных строк вполне достаточно, чтобы породить нечто принципиально новое, что можно мужикам показать и через пару витков затащить на основной проект.
Насчёт передачи таких скриптов неспециалистам могу сказать только то, что похоже, всё упирается в наличие мозгов у этих самых неспециалистов. Кто справлялся с нашими указаниями раньше, те справляются (и местами этому радуются), кто не справлялся, те и сейчас не справляются. Скачка от обретения UI я как-то не заметил, но чисто организационно кейс довольно сложный, и нормальный эксперимент я поставить не могу.
PS: Для тех, кто присматривается к F# и codeforces

Я не стану публично оценивать то, как мой эксперимент с codeforces повлиял на мой процесс разработки. Скажу лишь, что результат мне понравился несмотря на то, что спрогнозировать его я бы не смог. Я также не буду рекомендовать повторять этот опыт кому-либо, если этот кто-то не ощущает потребности порешать задачки без какого-либо вознаграждения. И уж тем более я не собираюсь отвечать на вопросы вида: «Откуда ты берёшь мотивацию?» Мне хватает залётных чудаков в спортзале.
Однако я могу дать пару практических замечаний для тех, кто хочет порешать задачки на codeforces.com на F#.
// Решение некоторой задачи. // Номер не скажу. let inline (^) f x = f x let gcd a b = let rec go () = if a > b then f a b else f b a and f a b = match a % b with | 0 -> b | a -> f b a go () let main x y p = let gcd = gcd x y p |> Seq.mapi ^ fun index value -> (value - 1) % gcd = index % gcd |> Seq.forall id main 2 3 [|5;4;3;2;1|] main 2 4 [|2;1;4;3;6;5|] main 2 2 [|1;2;3;4|] main 2 3 [|1;2;3;5;4|] let t = int ^ System.Console.ReadLine() let readInts count = System.Console.ReadLine().Split(' ', count = count) |> Array.map int Seq.init t ^ fun _ -> let [|n;x;y|] = readInts 3 let p = readInts n if main x y p then "YES" else "NO" |> Seq.iter ^ printfn "%s"
Ввод и вывод через консоль — пустая трата времени. REPL решает. Осваивайте его в первую очередь, и грузите через него данные в функции при ручном тестировании. System.Console и System.IO.File (редко, но бывает) оставьте для сервера.
BCL dotnet — это очень большая штука. В ней много чего есть. За счёт неё я смог решить 2500+ задач без использования кастомных структур данных (примитивные графы и деревья не в счёт). При этом переоценивать BCL тоже не стоит. В некоторых моментах мы отстаём от C++.
Указанные 2500 задач со дна архива — это не совсем спортивное программирование. Оно больше напоминает лечебную физкультуру после тяжёлой травмы, чем спорт высоких достижений. В школе мне удавалось разово решать гораздо более сложные вещи, поэтому здесь я ждал очень жёсткого старта, но вместо этого получил чуть более хитрую оптимизацию тырпрайза. Я определённо недоволен, но не результатом, а качеством своего плана.
Алгоритмы на хвостовой рекурсии — это вариант чита. С ними некоторые задачки решаются значительно легче, чем через циклы. Особенно в ситуациях, когда рекурсия меняет свой итератор в процессе вычисления. При этом циклы у нас тоже никто не отбирал, и мы вольны каждый раз выбирать именно то, что подходит под конкретное решение.
То же самое можно сказать про списки. Их можно матчить на несколько позиций вперёд и быстро наращивать при необходимости, что аннигилирует некоторые задачи на корню. На перфе эта штука особо не сказывается, если вы хоть мало-мальски знакомы с устройством списков и готовы учитывать их особенности.
Конвертация чисел между типами в F# хромает. Это самое бесячее в процессе решения. Очень хочется, чтобы компилятор умел автоматически конвертировать int32 в int64, когда видит разнотипную сумму, а не вынуждал меня вручную обегать весь алгоритм.
В целом я испытываю удовольствие от того, насколько свободно я себя чувствую в решении задач на F#. Когда я писал на Pascal, Delphi, C++ или C#, некоторые накладные расходы меня серьёзно тяготили. Сейчас ничего из этого мне не мешает, и я вынужден давить в себе желание «ещё одного хода».
НЛО прилетело и оставило здесь промокод для читателей нашего блога: -15% на заказ нового VDS — HABRFIRSTVDS.