В этот раз я пришёл с двухчастным текстом про F#, тайппровайдеры, скрипты, спортивное программирование, MVU, консольные интерфейсы в стиле FarManager, низкоуровневый интероп с виндой, некоторое количество хаков ХМ и, внезапно, живопись. Причём только консольные интерфейсы с некоторой натяжкой можно назвать центральным элементом повествования, поэтому я понятия не имею, как должен выглядеть потенциальный читатель этой статьи. Будем исходить из предположения, что он либо достаточно опытен, либо у него достаточно времени и усердия, чтобы расковырять всё, что я не счёл должным разобрать детально (я могу, но только за 16 глав).

Сегодня мы изучим небольшой .fsx-скрипт, который я ежедневно запускаю, чтобы получить очередную порцию задачек из архива codeforces.com. Необычно то, что скрипт функционирует как полноценное GUI-приложение, пусть и очень страшное старое:

Кажется, что до меня так никто не делал, так что с осторожностью назову это авторской инновацией.

Зачем это?

Я пришёл к этому сценарию «по блату». Дело в том, что в скрипте используется GUI-либа Thuja, с автором которой (@henrykovalevsky) я «дружу по переписке». Мы с ним никогда не работали в рамках одного проекта и даже никогда не виделись, но несколько раз независимо друг от друга приходили к очень похожим решениям для одних и тех же проблем. Поэтому всякая неожиданная активность (даже экспериментальная или незавершённая) на его фланге трактуется мною как потенциальный признак моей неосмотрительности. Внезапный выпуск Thuja вынудил меня поднять голову над бруствером, потому что консольные графические интерфейсы — это определённо не то, что я воспринимал как перспективное направление, особенно с учётом моего опыта в «настоящих» GUI-фреймворках.

Но консольные кнопки внезапно оказались очень к месту в скриптах, которые предполагают чуть более активное взаимодействие с пользователем. Раньше такое приходилось принудительно превращать в полноценные приложения, а теперь такое можно оперативно захакать и отложить переход до тех пор, пока скрипт не разрастётся до размеров проекта.

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

Лирическое отступление на тему моей мотивации

Я добавил эту главу в хаб «Спортивное программирование», но на деле это лишь фон повествования. Мне нечего сказать об этой предметной области, так как я в ней аутсайдер. В школе я специализировался на олимпиадной математике, а информатику держал за развлечение, в котором постоянно упирался в слабое знание языков программирования. Даже сейчас, спустя 15 лет, у меня напрочь отсутствуют связи с миром олимпиадного программирования, зато ветераны-математики встречаются мною регулярно, причём как из младших поколений, так и из старших (СиСиСиПи всё ещё стронг).

Думаю, что я бы поковыривал спортивное программирование на протяжении всех этих лет, если бы Codeforces поддерживал F#, но его по техническим причинам изъяли из списка доступных языков где-то в середине десятых годов. Весной 2025 года я за компанию поучаствовал в соревновании по инфобезу, «словил вайбы» юности и завёлся достаточно сильно, чтобы продавить возвращение F# на сайт. Серьёзных планов на последующую деятельность у меня не было, но за пару недель вялой практики подтвердились мои опасения, что из-за рутины и автоматизации я соображаю не так резво, как мне надо в долгосроке.

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

Это дорогое удовольствие. Оно жрёт много времени и организаторский усилий, но не всегда даёт результат. Мне нужно было что-то более дешёвое, регулярное и системное, типа кнопки GAME OF THE WEEK в Old World. Я решил, что буду каждый день заходить на Codeforces и решать несколько задач разного уровня сложности начиная снизу. Тогда сложность будет повышаться сама собой, когда задачи нижних уровней просто закончатся (их по 300-500 штук на уровень). Никаких принудительных или добровольных прыжков через ступеньки.

Звучит просто и выглядит технически осуществимо, но, к сожалению, самодисциплина не относится к числу моих добродетелей несмотря на то, что я осознаю её полезность. Каждый раз мне приходится тянуть из себя жилы. Чтобы упростить себе этот процесс, я годами выпиливаю из своего окружения людей на расслабоне и, наоборот, подтягиваю всех, кто демонстрирует склонность к самодисциплине. Однако применительно к Codeforces найти сопартийцев на местности мне не удалось, и пришлось искать эмоциональные подпорки в иной области.

У меня есть хобби — пастель обоих видов, так что «я сам своего рода художник». Всю жизнь я тяготел к тёмным низким тонам в комбинации с резкими пожухлыми разбелами. Предположу, что цветовая гамма Макбета (2015) — это нечто близкое к моему эстетическому идеалу:

К сожалению, «сезон Макбета» в моих местах — это всего лишь пара десятков дней, разбросанных на временном интервале в 5-7 месяцев. Остальное время я наблюдаю гораздо более насыщенные цветом пейзажи и не могу сказать, что они мне не нравятся.

В попытках отобразить их я начал искать художников с подходящей манерой письма и набрёл на одного околобританского пастелиста по имени Tony Allain (транскрипция на русский вызывает затруднение). Его работы заинтересовали меня из-за их палитры. Они чрезвычайно яркие, выглядят как токсический взрыв, но при этом смотрятся очень органично и естественно:

Это очень похоже на то, ради чего я месяцами наматываю десятки и сотни километров на велике. Знакомства с его работами хватило, чтобы я начал делать свои ярче и контрастнее.

Однако у этого художника есть ещё одна особенность. Он преподаёт и иногда рисует на камеру, и всем, кто пробовал рисовать пастелью, определённо стоит посмотреть на то, как это делает Allain, потому что я не видел никого с такой же дикой техникой письма (справедливости ради, я знаю не так много художников). Он очень сильно давит пастелью на бумагу, как будто хочет её вспороть. Хуже всего то, что он давит ДОРОГОЙ пастелью на ДОРОГУЮ бумагу. На это физически больно смотреть, особенно первое время, когда ты ещё не понял, что он собирается изобразить. Но через несколько минут ситуация меняется, и приходит осознание, что мы наблюдаем за работой очень намаханного профессионала, а не «актом современного европейского искусства».

Несмотря на всю чрезмерность, Allain попадает ровно туда, куда надо, именно тем, чем надо, и ровно тогда, когда надо. Ещё раз, чтобы меня правильно поняли, речь не об очередном тиктоке вида «Некто пришёл на шоу талантов, начал рисовать фигню, его заабортили, а потом он перевернул холст, и выяснилось, что он нарисовал шедевр. Как же они ошибались!». Нет. Если пересматривать его ролики с послезнанием, можно обнаружить, что Allain аномально прямолинеен. Во время работы он не юлит, почти ничего не скрывает, не прибегает к коррекции, может не использовать опорники и т. д. За счёт этого он экономит время и получает очень чистые «вибрирующие» картины. Иными словами, он разбивает конечную стадию полировки на составляющие и «предельно влево» втыкает их в основную фазу разработки.

Это круто. Я не уверен, что оно мне надо, но это очень круто, и я хотел бы так уметь. Разумеется, это достигается солидной интеллектуальной работой, которая на столь коротких временных интервалах может быть реализована только за счёт таблицы объёмов красных пластиковых шаров огромной практики. В каком-то из интервью он говорил, что пишет по шесть полноценных работ в день и делает какое-то неприличное число скетчей.

Такой объём неподъёмен для моего хобби, но для программирования он выглядит почти естественно. Я решил, что смогу ужаться и вклинить в расписание несколько задачек с codeforces. А основную дофаминовую петлю построю не на числовых показателях или местах в контестах, а на отслеживании того, как изменяется моя манера программирования под воздействием такой практики. На самом деле у меня не было уверенности, что эти изменения действительно произойдут. Какая-то часть меня подозревала, что я просто выгорю, будучи неспособным потянуть и то, и то, но случилось чудо, и кое-что таки начало двигаться, пусть и не там, где ожидалось.

Предметная область и постановка задачи

На Codeforces есть архив из одиннадцати с лишним тысяч задач по олимпиадному программированию (мне б такое в детстве). Задачи принадлежат контестам (соревнованиям), но всю статистику по всем задачам, а также их базовую информацию (без самого условия и примеров) можно выкачать одним запросом. Каждая задача характеризуется:

  • своим контестом (число из 1..2240+),

  • индексом в рамках контеста (обычно заглавная буква, но возможны подсуффиксы, типа A1, A2 и т. д.),

  • «литературным» названием,

  • почти всегда сложностью (число из 800..100..3500), но возможен None,

  • набором строк-тегов,

  • и чем-то ещё, что нам не понадобится.

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

  • Каждый день требует от меня решения 6 новых задач.

  • Эти 6 задач должны принадлежать разным уровням сложности.

  • Эти уровни сложности должны быть минимальными из всех доступных (запас уровня может быть исчерпан).

  • Каждая из этих задач в своём уровне должна иметь самое большое число решений (т. е. она должна быть «самой популярной»).

Штрафов за отставание от графика нет. В какой-то день я решаю по 20 задач, в какой-то всего одну, могу неделями отставать на 2-3 дня или, наоборот, опережать, но после закрытия долга указанная квота неукоснительно соблюдается.

Решения можно отправлять на сайт как в виде файла, так и в виде текста. Однако у меня нет общего проекта и все решения лежат в отдельных .fsx-файлах (я активно использую REPL) в двухуровневом дереве папок, которые мне совершенно не хочется искать через OpenFileDialog, поэтому я работаю исключительно с текстом. Публичных методов для отправки решений нет — всё грузится через сайт. И только через него же мы можем увидеть условие задачи, так что использование браузера неизбежно.

Каждая попытка сохраняется в системе и доступна для изучения. Все попытки пользователя можно выкачать за один запрос. Сгруппировав попытки по задачам, мы можем определить статус этих задач (Some (Solved | Wip | Failed) | None).

Каждый день мне необходимо знать:

  • Какие задачи решать следующими;

  • Какие задачи я решил или пытался решить в недавнем прошлом (к вечеру могу забыть, сколько именно задач было решено утром);

  • Общую статистику по всем задачам, например, в категории «800 решена 431 задача из 1080» (это для ЧСВ).

Всё это в каком-то виде доступно через сайт, но для этого надо возиться с фильтрами, что здорово утомляет. Чтобы сократить эти издержки, мне и понадобился скрипт.

Провайдеры типов

На момент написания статьи API Codeforces доступно без каких-либо ключей. Что, в общем-то, ожидаемо, если знать, что в его публичной части отсутствуют команды с сайд-эффектами. Поэтому с credentials-ами нам возиться не надо.

С написанием DTO тоже можно повременить, но уже благодаря такой специфической F#-штуке, как провайдеры типов. Их концепция проста — берём некоторую схему (XML/JSON/SQL/XAML и т. д.) и строим по ней пачку DTO-типов. Именно типов, а не код для этих типов. Что там внутри, не знает никто, кроме авторов конкретного провайдера.

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

#r "nuget: FSharp.Data"

open FSharp.Data

let inline (^) f x = f x

type Problems = JsonProvider<"""https://codeforces.com/api/problemset.problems""">

//Urls.UserStatus.create "Kleidemos"
type UserStatus = JsonProvider<"""https://codeforces.com/api/user.status?handle=Kleidemos""">

Дальше можно скачивать данные:

// Загружаем по дефолтному адресу,
// на основе которого был определён тип.
let! problemsSample = Problems.AsyncGetSample()

// Загружаем по переданной ссылке.
// `handle` в терминологии Codeforces -- это ник участника.
let! userStatus = UserStatus.AsyncLoad ^ Urls.UserStatus.create handle

Компилятор подстроится и IDE на ходу врубит подсказки кода:

userStatus.Result
|> Seq.filter ^ fun p -> p.Verdict = "OK"
|> Seq.groupBy ^ fun p -> p.Problem.ContestId, p.Problem.Index
|> Seq.map ^ fun (key, items) ->
    let (!) reduction =
        items
        |> Seq.map ^ fun p -> p.CreationTimeSeconds
        |> Seq.reduce reduction
        |> System.DateTime.UnixEpoch.AddSeconds
    key
    , {|
        First = !min
        Last = !max
    |}
|> Map.ofSeq

Название полей, их типы и названия типов в DTO провайдер берёт из конкретного JSON. Так что, если мы знаем, как выглядит тело ответа, мы можем легко сориентироваться в происходящем. Правда, я ничего об этих запросах не знал и изучал их структуру и содержимое на ходу прямо в REPL.

Типы, сгенерированные провайдером, доступны для дальнейшей кастомизации:

type Problems.IntOrString with
    member this.AsString =
        match this.String with
        | None -> string this.Number.Value
        | Some str -> str

// Какие-то `Problem.Index` были интерпретированы
// как строка, как число и как дата.
type Problems.IntOrStringOrDateTime with
    member this.AsString =
        this.JsonValue.ToString().Trim '"'

// Компилятор падает, если
// у обоих `UserStatus.Problem` и `Problems.Problem` 
// добавляются одноимённые свойства.
type UserStatus.Problem with
    member this.ProblemId = this.ContestId, this.Index

type Problems.Problem with
    member this.Id = this.ContestId, this.Index.AsString

type Problems.ProblemStatistic with
    member this.Id = this.ContestId, this.Index.AsString

Не всё проходит гладко, с Problem.Id пришлось повозиться, но это не та проблема, которая займёт моё время.

let solved, wip, failed =
    let create predicate =
        userStatus.Result
        |> Seq.filter predicate
        |> Seq.groupBy ^ fun p -> p.Problem.ProblemId
        |> Seq.map ^ fun (key, items) ->
            let (!) reduction =
                items
                |> Seq.map ^ fun p -> p.CreationTimeSeconds
                |> Seq.reduce reduction
                |> System.DateTime.UnixEpoch.AddSeconds
            key
            , {|
                First = !min
                Last = !max
            |}
        |> Map.ofSeq
    let solved = create ^ fun p -> p.Verdict = "OK"
    let wip = create ^ fun p ->
        match p.Verdict with
        | "" 
        | "TESTING"
        | "SUBMITTED" -> not ^ solved.ContainsKey p.Problem.ProblemId
        | _ -> false
    let failed = create ^ fun p ->
        p.Verdict <> "OK"
        && not ^ solved.ContainsKey p.Problem.ProblemId
        && not ^ wip.ContainsKey p.Problem.ProblemId
    solved, wip, failed

Тайппровайдеры выглядят как киллер-фича, но у них есть недостатки. Во-первых, всё держится на доступности сэмпла. Если он недоступен, код превращается в тыкву, и пока сервер не вернётся в онлайн, мы не сможем продолжить разработку. В частности, Codeforces иногда имеет стабильность домашней заготовки уровня «Посоны, я написал пати-кликер, где команда Кимов нюкает рыжую Годзиллу в котелке из монополии, качайте .apk с 192.168.0.107:1953» (рисовка авторская). То есть пока идёт или грядёт соревнование, оно работает как часы. В остальное время возможны спорадические отключения API, сайта, исторических данных или тестирующих мощностей. В случае Codeforces мы всегда можем подождать, но в обычной разработке это роскошь.

Во-вторых, есть зависимость от данных в сэмпле. Например, если в качестве сэмпла взять профиль, в котором нет попыток, то его основная коллекция будет пуста, и пока мы не пригоним пример с хоть какими-то данными, что-то требовать от провайдера будет просто бессмысленно. Кроме того, провайдер может иначе трактовать опциональные поля. Так, компилятор может запросить сэмпл в неудачный момент обработки попытки, когда некоторые поля то ли исчезают, то ли null-еют (очень редкое явление), что приводит к ошибке компиляции вида «Тут в поле string option, а ты работаешь с ним как со string, переписывай». Переписывать, конечно же, не надо, так как при возвращении к норме, оно сломается в обратную сторону.

В общем, провайдеры генерируют типы в зависимости от факторов, часть которых нами не контролируется. Из-за этого несколько лет назад некоторые F#-исты признали эту технологию чересчур опасной, и теперь ей приходится преодолевать излишние барьеры на пути к нашим кодовым базам. Я нахожу это странным в свете того, что примерно те же люди сейчас активно признаются в любви к AI, который лажает гораздо жёстче и, в отличие от провайдеров, почти всегда скрытно. Когда исправят AI, мне неизвестно, но купировать проблемы провайдеров можно уже сейчас через переориентацию на сэмпл в локальном файле, чего я не сделал только потому, что хотел и дальше бравировать однофайловостью.

Агрегация данных

Способность очень сжато описывать предметную область при помощи алгебраических типов (рекорды и DU) — это одна из сильнейших сторон F#. На ней делают акцент в докладах, её всячески выпячивают, ей гордится каждый F#-ист и т. д., однако в ряде случаев, таких как наш скрипт, мы можем обойтись вообще без какого-либо описания. У нас есть провайдеры, которые дали нам DTO, а всё остальное будет прямой проекцией этих DTO в форму данных, которая нужна нам в самой программе. Поэтому нам потребуется лишь одно DU, а всё остальное возьмут на себя анонимные рекорды:

#r "nuget: Hopac, 0.5.0"

open Hopac
open Hopac.Infixes

...

type Status =
    | Solved
    | Wip
    | Failed

module Data =
    let load handle timeWindowMinutes = job {
        let! problemsSample = Problems.AsyncGetSample()
    
        let! userStatus = UserStatus.AsyncLoad ^ Urls.UserStatus.create handle

        let solved, wip, failed =
            ...

        ...

        let groups =
            problemsSample.Result.Problems
            |> Seq.map ^ fun p ->
                let id = p.Id
                {|
                    Id = id
                    Problem = p
                    Stats = problemStats.[id]
                    Interaction =
                        let f map status =
                            Map.tryFind id map
                            |> Option.map ^ fun period -> {|
                                Status = status
                                Period = period
                            |}
                        f solved Status.Solved
                        |> Option.orElseWith ^ fun () -> f wip Status.Wip
                        |> Option.orElseWith ^ fun () -> f failed Status.Failed
                |}
            |> ...

        ...

        return {|
            Args = {|
                Handle = handle
                TimeWindow = timeWindowMinutes
            |}
            Origin = {|
                Problems = problemsSample
                UserStatus = userStatus
            |}
            Counts = globalCounts
            Solved = solved
            Unresolved = failed
            Problems = problems
            ProblemsOfTheDay = problemsOfTheDay
        |}
    }

Data.load — это большая асинхронная функция, кишки которой здесь неважны. Она писалась и переписывалась мелкими кусочками больше года, так что я могу ручаться только за удобство использования, а не за асимптотическую оптимальность решения. Важно лишь то, что мы комбинируем сгенерированные типы с анонимными и всё равно получаем очень «типизированный» результат. Правда, у результата Data.load по умолчанию нет удобного имени, но его можно приобрести за счёт упаковки в следующую обёртку:

module Data =
    ...

    type Main (core) as this =
        static let load handle timeWindowMinutes = job {
            let! data = load handle timeWindowMinutes
            return Main data
        }

        static member Load handle timeWindowMinutes = load handle timeWindowMinutes
    
        member _.Refresh = load core.Args.Handle core.Args.TimeWindow

        member val Core = {| core with Shell = this |}

Подробно о том, как это работает, можно прочитать здесь (в конце) и здесь, но суть кода сводится к тому, что в Data.Main может лежать анонимный рекорд исключительно определённого типа. Именно того, что можно получить из функции Data.load, что с учётом его зубодробительной сложности означает, что никаких иных сценариев его получения у нас не будет. Дальше всякий раз, когда нам надо будет упомянуть этот рекорд в сигнатурах, мы будем ссылаться на Data.Main — прокси-тип оболочку, а все данные будем находить внутри ключевого свойства Data.Main.Core.

let view (data : Data.Main) =
    let data = data.Core
    rows [ Fraction 1; Absolute 8 ] [
        ...
    ]

Консольные аргументы

Наличие GUI наталкивает на мысль, что логин пользователя надо вводить через интерфейс, но это лишняя трата времени разработчика и пользователя. Мы быстрее введём его через консоль или текстовый редактор в батнике ещё до запуска скрипта:

dotnet fsi ProblemsOfTheDay.fsx -- --handle kleidemos --timeWindow 2000

Кому-то может подойти вариант с файлом конфигурации. Когда мне нужно вводить текст по ходу работы утилиты, мы общаемся с ней через рядом лежащий input.txt. Это очень удобно, потому что я до сих пор не научился быстро вводить многострочники через консоль и в целом очень завишу от возможностей текстового редактора.

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

let data =
    let config =
        let rec go () =
            fsi.CommandLineArgs
            |> List.ofSeq
            // Выкидываем название файла.
            |> List.tail
            |> f {| Handle = None; TimeWindow = 60 |}
        and f acc args =
            match args with
            | [] ->
                match acc.Handle with
                | None -> failwith "Handle not found."
                | Some handle -> {| acc with Handle = handle |}
            | "--handle" :: handle :: args ->
                f {| acc with Handle = Some handle |} args
            | "--timeWindow" :: window :: args ->
                f {| acc with TimeWindow = int window |} args
            | unexpected ->
                failwithf "Unexpected arguments: %A" unexpected
        go ()
    Data.Main.Load config.Handle config.TimeWindow
    |> run

timeWindow — это количество минут, которые скрипт воспринимает как недавнее прошлое. Решённые задачи за пределами этого интервала не будут выводиться на экран. Аккуратных проверок на корректность числового формата timeWindow мы не делаем, и вообще всячески избегаем щадящей обработки ошибок в рамках скрипта. Как только что-то пошло не так, мы просто падаем, и пусть пользователь устраняет неполадки и перезапускает скрипт без нашего участия.

Примитивная версия UI

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

#r "nuget: Thuja.Tutu, 0.0.8"

open Thuja
open Thuja.Tutu
open Thuja.Styles
open Thuja.Elements

let view (data : Data.Main) =
    let data = data.Core
    rows [ Fraction 1; Absolute 8 ] [
        columns [ Fraction 60; Fraction 15 ] [
            panel [] [
                rows [
                    for _ in data.ProblemsOfTheDay do
                        Absolute 1
                ] [
                    for item in data.ProblemsOfTheDay do
                        let rating =
                            match item.Problem.Rating with
                            | Some p -> string p
                            | None -> "-"
                        let fullId = sprintf "%i%s" <|| item.Problem.Id
                        let status, color =
                            match item.Interaction with
                            | None -> "New", Color.Blue
                            | Some status -> 
                                match status.Status with
                                | Status.Solved -> "Done", Color.Green
                                | Status.Wip -> "WIP", Color.DarkYellow
                                | Status.Failed -> "Failed", Color.Red
                        text [
                            Color color
                        ] $"%-5s{rating} #%-4i{item.IndexInRatingGroup} %-5s{status} %-6s{fullId} x%-6i{item.Stats} %s{item.Problem.Name.AsString}"
                ]
            ]

            let cla = [|
                "# Command Line Argumets:"
                for p in fsi.CommandLineArgs do 
                    sprintf "  - %A" p
            |]
            let instruction = [|
                "# Control"
                "- Q / Ctrl + C -- exit"
                $"- F5 - reload data"
            |]
            rows [
                Absolute (cla.Length + 2)
                Absolute (instruction.Length + 2)
                Fraction 1
            ] [
                panel [] [
                    cla
                    |> String.concat System.Environment.NewLine
                    |> text []
                ]
                panel [] [
                    instruction
                    |> String.concat System.Environment.NewLine
                    |> text []
                ]
                panel [] []
            ]
        ]

        panel [] [
            let columns =
                data.Counts
                |> Map.toSeq
                |> Seq.sortBy ^ fun (rating, _) ->
                    rating
                    |> Option.defaultValue System.Int32.MaxValue
                |> List.ofSeq
            table [
                Columns [
                    Fraction 2
                    for _ in columns do
                        Fraction 1
                ]
            ] [
                "All"
                for rating, _ in columns do
                    match rating with
                    | Some p -> string p
                    | None -> "-"
            ] [
                let row mapping = [
                    columns
                    |> Seq.sumBy (snd >> mapping)
                    |> string

                    for p in columns do
                        string ^ mapping ^ snd p
                ]

                row ^ fun p -> p.Total
                row ^ fun p -> p.Solved
                row ^ fun p -> p.Wip
                row ^ fun p -> p.Failed
                row ^ fun p -> p.Bad
            ]
        ]
    ]

Я привел весь код view, чтобы ни у кого не возникло ложного ощущения присутствия некоей сокрытой сложности, но изучать здесь почти нечего. Стоит помнить только об одном — мы работаем не с полноценными контролами, а с набором функций, которые принтят текст моноширинным шрифтом в определённый прямоугольник окна. Они не имеют инструментов обратной связи, поэтому мы не можем подвязаться на какие-либо ивенты и скормить им функцию dispatch, так что местный view, в отличие от классического MVU, на уровне фреймворка принимает только модель. Квазиконтролы настолько беспомощны, что у них отсутствует механизм коммуникации между собой, и мы вынуждены перераспределять пространство между ними в ручном режиме (Absolute (instruction.Length + 2)). Со стороны это может выглядеть пугающе, но сложность местных макетов очень далека от той, что демонстрируют большие GUI. Проводя аналогию с играми, мы работаем с гексагональной картой, а не с абсолютным пространством, поэтому всё можно обсчитать в голове и при особом желании автоматизировать.

Функция update выглядит следующим образом:

module Cmd =
    let ofJob job = Cmd.ofAsync ^ Job.toAsync job

let update (data : Data.Main) event =
    match event with
    | Choice1Of2 event ->
        match event with
        | Char 'q', _
        // `Ctrl + C` может перехватываться оболочкой,
        // так что прога будет убита до нашего обработчика.
        | Char 'c', KeyModifiers.Ctrl -> data, Program.exit()
        | FKey 5, _ ->
            data
            // Запрашиваем обновление.
            , Cmd.ofJob ^ Job.map Choice2Of2 data.Refresh
        | _ -> data, Cmd.none
    | Choice2Of2 data ->
        // Принимаем обновление.
        data, Cmd.none

Семейство DU Choice (и его кейсы вида Choice3Of4) — это встроенный в BCL способ сводить несколько типов входных или выходных данных в экземпляр одного типа. В теории я мог бы создать полноценное DU для своих сообщений:

type Msg =
    | DataUpdated of Data.Main
    | KeyEvent of KeyInput * KeyModifiers

Но я ни разу так не делал при работе с Thuja. Уж слишком невелики местные проги.

Мы никак не проверяем, что data.Refresh отработал без ошибки, но, если эта джоба упадёт, за ней упадёт вся прога (так устроен хендл Thuja, не Hopac). Ужасный сценарий для больших GUI, но абсолютно приемлемый подход для нашего скрипта. Глядя на стек ошибок, мы внесём изменения или просто подождём восстановления сервиса, после чего перезапустим скрипт.

Наконец, склейка всех трёх компонентов выглядит так:

Program.make data view update
|> Program.withKeyBindings (Choice1Of2 >> Cmd.ofMsg)
|> Program.withTutuBackend
|> Program.run

На всякий случай уточню, что этот скрипт бесполезно запускать во встроенной в IDE сессии REPL. Она не умеет работать с такими программами, так что мы получим эпическую белиберду вместо работающего приложения. Если нужен запуск, то дёргаем следующую команду из папки со скриптом:

dotnet fsi ProblemsOfTheDay.fsx -- --handle kleidemos --timeWindow 2000

И не забудьте развернуть окно на весь экран, чтобы не столкнуться с проблемами заполнения малого пространства большими данными. Я никогда не пытался адаптировать скрипт под маленький формат.

Промежуточное заключение

Мы прошли весь путь от модели данных до их визуального представления. Полный скрипт можно посмотреть здесь. Сделаем вид, что приложение готово: превратить идентификатор задачи в ссылку в адресной строке браузера читатель сможет сам — достаточно иметь под рукой адрес хотя бы одной.

В следующей главе мы усложним UI, сделаем его интерактивным и чуточку более вариативным, для чего потребуется завести полноценную модель и соответствующим образом обновить update и view. Система запросов данных останется без изменений, в этом деле провайдеры показывают себя достаточно хорошо. Также надо будет поговорить о том, как запуск из .fsx сказывается на общей архитектуре приложения. Тут многое можно из того, что обычно нельзя.


НЛО прилетело и оставило здесь промокод для читателей нашего блога: -15% на заказ нового VDSHABRFIRSTVDS.

Положение об акции