Cljfx: A declarative desktop UI framework in Clojure
hackernews
|
|
📰 뉴스
#b2b 시장
#k-ai 선발전
#구글
#사이냅소프트
#제미나이
원문 출처: hackernews · Genesis Park에서 요약 및 분석
요약
웹케시그룹 윤완수 부회장은 AI 시대에는 인터넷이나 모바일과 달리 AI 에이전트가 고객 지시를 받아 금융 업무를 수행하는 패러다임 변화가 올 것이라고 전망했습니다. 화면 기반의 소프트웨어에서 질문과 지시 중심의 호출 시스템으로 작동 방식이 180도 달라짐에 따라, 올해 하반기부터 2~3년간 뱅킹 채널이 AI 에이전트로 급격히 진화할 것으로 예상됩니다.
본문
Cljfx is a declarative, functional and extensible wrapper of JavaFX inspired by better parts of react and re-frame. I wanted to have an elegant, declarative and composable UI library for JVM and couldn't find one. Cljfx is inspired by react, reagent, re-frame and fn-fx. Like react, it allows to specify only desired layout, and handles all actual changes underneath. Unlike react (and web in general) it does not impose xml-like structure of everything possibly having multiple children, thus it uses maps instead of hiccup for describing layout. Like reagent, it allows to specify component descriptions using simple constructs such as data and functions. Unlike reagent, it rejects using multiple stateful reactive atoms for state and instead prefers composing ui in more pure manner. Like re-frame, it provides an approach to building large applications using subscriptions and events to separate view from logic. Unlike re-frame, it has no hard-coded global state, and subscriptions work on referentially transparent values instead of ever-changing atoms. Like fn-fx, it wraps underlying JavaFX library so developer can describe everything with clojure data. Unlike fn-fx, it is more dynamic, allowing users to use maps and functions instead of macros and deftypes, and has more explicit and extensible lifecycle for components. Cljfx uses tools.deps , so you can add this repo with latest sha as a dependency: cljfx {:git/url "https://github.com/cljfx/cljfx" :sha ""} Cljfx is also published on Clojars, so you can add cljfx as a maven dependency, current version is on this badge: Minimum required version of clojure is 1.10. When depending on git coordinates, minimum required Java version is 11. When using maven dependency, both Java 8 (assumes it has JavaFX provided in JRE) and Java 11 (via openjfx dependency) are supported. You don't need to configure anything in this regard: correct classifiers are picked up automatically. Please note that JavaFX 8 is outdated and has problems some people consider severe: it does not support HiDPI scaling on Linux, and sometimes crashes JVM on macOS Mojave. You should prefer JDK 11. Components in cljfx are described by maps with :fx/type key. By default, fx-type can be: - a keyword corresponding to some JavaFX class - a function, which receives this map as argument and returns another description - an implementation of Lifecycle protocol (more on that in extending cljfx section) Minimal example: (ns example (:require [cljfx.api :as fx])) (fx/on-fx-thread (fx/create-component {:fx/type :stage :showing true :title "Cljfx example" :width 300 :height 100 :scene {:fx/type :scene :root {:fx/type :v-box :alignment :center :children [{:fx/type :label :text "Hello world"}]}}})) Evaluating this code will create and show a window: The overall mental model of these descriptions is this: - whenever you need a JavaFX class, use map where :fx/type key has a value of a kebab-cased keyword derived from that class name - other keys in this map represent JavaFX properties of that class (also in kebab-case); - if prop x can be changed by user, there is a corresponding :on-x-changed prop for observing these changes To be truly useful, there should be some state and changes over time, for this matter there is a renderer abstraction, which is a function that you may call whenever you want with new description, and cljfx will advance all the mutable state underneath to match this description. Example: (def renderer (fx/create-renderer)) (defn root [{:keys [showing]}] {:fx/type :stage :showing showing :scene {:fx/type :scene :root {:fx/type :v-box :padding 50 :children [{:fx/type :button :text "close" :on-action (fn [_] (renderer {:fx/type root :showing false}))}]}}}) (renderer {:fx/type root :showing true}) Evaluating this code will show this: Clicking close button will hide this window. Renderer batches descriptions and re-renders views on fx thread only with last received description, so it is safe to call many times at once. Calls to renderer function return derefable that will contain component value with most recent description. Example above works, but it's not very convenient: what we'd really like is to have a single global state as a value in an atom, derive our description of JavaFX state from this value, and change this atom's contents instead. Here is how it's done: ;; Define application state (def *state (atom {:title "App title"})) ;; Define render functions (defn title-input [{:keys [title]}] {:fx/type :text-field :on-text-changed #(swap! *state assoc :title %) :text title}) (defn root [{:keys [title]}] {:fx/type :stage :showing true :title title :scene {:fx/type :scene :root {:fx/type :v-box :children [{:fx/type :label :text "Window title input"} {:fx/type title-input :title title}]}}}) ;; Create renderer with middleware that maps incoming data - description - ;; to component description that can be used to render JavaFX state. ;; Here description is just passed as an argument to function component. (def renderer (fx/create-renderer :middleware (fx/wrap-map-desc assoc :fx/type root))) ;; Convenient way to add watch to an atom + immediately render app (fx/mount-renderer *state renderer) Evaluating code above pops up this window: Editing input then immediately updates displayed app title. Consider this example: (defn todo-view [{:keys [text id done]}] {:fx/type :h-box :children [{:fx/type :check-box :selected done :on-selected-changed #(swap! *state assoc-in [:by-id id :done] %)} {:fx/type :label :style {:-fx-text-fill (if done :grey :black)} :text text}]}) There are problems with using functions as event handlers: - Performing mutation from these handlers requires coupling with that state, thus making todo-view dependent on mutable*state - Updating state from listeners complects logic with view, making application messier over time - There are unnecessary reassignments to on-selected-changed : functions have no equality semantics other than their identity, so on every change to this view (for example, when changing it's text),on-selected-changed will be replaced with another function with same behavior. To mitigate these problems, cljfx allows to define event handlers as arbitrary maps, and provide a function to a renderer that performs actual handling of these map-events (with additional :fx/event key containing dispatched event): ;; Define view as just data (defn todo-view [{:keys [text id done]}] {:fx/type :h-box :spacing 5 :padding 5 :children [{:fx/type :check-box :selected done :on-selected-changed {:event/type ::set-done :id id}} {:fx/type :label :style {:-fx-text-fill (if done :grey :black)} :text text}]}) ;; Define single map-event-handler that does mutation (defn map-event-handler [event] (case (:event/type event) ::set-done (swap! *state assoc-in [:by-id (:id event) :done] (:fx/event event)))) ;; Provide map-event-handler to renderer as an option (fx/mount-renderer *state (fx/create-renderer :middleware (fx/wrap-map-desc assoc :fx/type root) :opts {:fx.opt/map-event-handler map-event-handler})) You can see full example at examples/e09_todo_app.clj. Another useful aspect of renderer function that should be used during development is refresh functionality: you can call renderer function with zero args and it will recreate all the components with current description. See walk-through in examples/e12_interactive_development.clj as an example of how to iterate on cljfx app in REPL. Check out cljfx/dev for tools that might help you when developing cljfx applications. These tools include: - specs and validation, both for individual cljfx descriptions and running apps; - helper reference for existing types and their props; - cljfx component stack reporting in exceptions to help with debugging. Iterating on styling is usually cumbersome: styles are defined in external files, they are not reloaded on change, they are opaque: you can't refer from the code to values defined in CSS. Cljfx has a complementary library that aims to help with all those problems: cljfx/css. Sometimes components accept specially treated keys. Main uses are: - Reordering of nodes (instead of re-creating them) in parents that may have many children. Descriptions that have :fx/key during advancing get reordered instead of recreated if their position in child list is changed. Consider this example:(let [component-1 (fx/create-component {:fx/type :v-box :children [{:fx/type :label :fx/key 1 :text "- buy milk"} {:fx/type :label :fx/key 2 :text "- buy socks"}]}) [milk-1 socks-1] (vec (.getChildren (fx/instance component-1))) component-2 (fx/advance-component component-1 {:fx/type :v-box :children [{:fx/type :label :fx/key 2 :text "- buy socks"} {:fx/type :label :fx/key 1 :text "- buy milk"}]}) [socks-2 milk-2] (vec (.getChildren (fx/instance component-2)))] (and (identical? milk-1 milk-2) (identical? socks-1 socks-2))) => true With :fx/key -s specified, advancing of this component reordered children of VBox, and didn't change text of any labels, because their descriptions stayed the same. - Providing extra props available in certain contexts. If node is placed inside a pane, pane can layout it differently by looking into properties map of a node. Nodes placed in ButtonBar can have OS-specific ordering depending on assigned ButtonData. These properties can be specified via keywords namespaced by container's fx-type. Example: (fx/on-fx-thread (fx/create-component {:fx/type :stage :showing true :scene {:fx/type :scene :root {:fx/type :stack-pane :children [{:fx/type :rectangle :width 200 :height 200 :fill :lightgray} {:fx/type :label :stack-pane/alignment :bottom-left :stack-pane/margin 5 :text "bottom-left"} {:fx/type :label :stack-pane/alignment :top-right :stack-pane/margin 5 :text "top-right"}]}}})) Evaluating code above produces this window: For a more complete example of available pane keys, see examples/e07_extra_props.clj There are some props in JavaFX that represent not a value, but a way to construct a value from some input: :page-factory in pagination, you can use function receiving page index and returning any component description for this prop (see example in examples/e06_pagination.clj)- various versions of :cell-factory in controls designed to display multiples of items (table views, list views etc.) can be described using the following form:The lifecycle of cells is a bit different than lifecycle of other components: JavaFX pools a minimal amount of cells needed to be shown at the same time and updates them on scrolling. This is great for performance, but it imposes a restriction: cell type is "static". That's why cljfx uses{:fx/cell-type :list-cell :describe (fn [item] {:text (my.ns/item-as-text item)})} :fx/cell-type that has to be a keyword (like:list-cell or:table-cell ) and a separate:describe function that receives an item and returns a prop map for that cell type. There are various usage examples available in examples/e16_cell_factories.clj Once application becomes complex enough, you can find yourself passing very big chunks of state everywhere. Consider this example: you develop a task tracker for an organization. A typical task view on a dashboard displays a description of that task and an assignee. Required state for this view is plain and simple, just a simple data like that: {:title "Fix NPE on logout during full moon" :state :todo :assignee {:id 42 :name "Fred"}} Then one day comes a requirement: users of this task tracker should be able to change assignee from the dashboard. Now, we need a combo-box with all assignable users to render such a view, and required data becomes this: {:title "Fix NPE on logout during full moon" :state :todo :assignee {:id 42 :name "Fred"} :users [{:id 42 :name "Fred"} {:id 43 :name "Alice"} {:id 44 :name "Rick"}]} And you need to compute it once in one place and then pass it along multiple layers of ui to this view. This is undesirable: - it will lead to unnecessary re-renderings of views that just pass data further when it changes - it complects reasoning about what actually a view needs: is it just a task? or a task with some precomputed attributes? To mitigate this problem, cljfx introduces optional abstraction called context, which is inspired by re-frame's subscriptions. Context is a black-box wrapper around application state (usually a map), with 2 functions to look inside the wrapped state: fx/sub-val that subscribes a function to the value wrapped in the context directly (usually it's used for data accessors likeget orget-in );fx/sub-ctx that subscribes a function to the context itself, which is then used by the function to subscribe to some view of the wrapped value indirectly (can be used for slower computations like sorting). Returned values from subscription functions are memoized in this context (so it actually is a memoization context), and subsequent sub-* calls will result in cache lookup. The best thing about context is that not only does it support updating wrapped values via swap-context and reset-context , it also reuses this memoization cache to minimize re-calculation of subscription functions in successors of this context. This is done via tracking of fx/sub-* calls inside subscription functions, and checking if their dependencies changed. Example: (def context-1 (fx/create-context {:tasks [{:text "Buy milk" :done false} {:text "Buy socks" :done true}]})) ;; Simple subscription function that depends on :tasks key of wrapped map. Whenever value ;; of :tasks key "changes" (meaning whenever there will be created a new context with ;; different value on :tasks key), subscribing to this function will lead to a call to ;; this function instead of cache lookup (defn task-count [context] (count (fx/sub-val context :tasks))) ;; Using subscription functions: (fx/sub-ctx context-1 task-count) ; => 2 ;; Another subscription function depends on :tasks key of wrapped map (defn remaining-task-count [context] (count (remove :done (fx/sub-val context :tasks)))) (fx/sub-ctx context-1 remaining-task-count) ; => 1 ;; Indirect subscription function that depends on 2 previously defined subscription ;; functions, which means that whenever value returned by `task-count` or ;; `remaining-task-count` changes, subscribing to this function will lead to a call ;; instead of cache lookup (defn task-summary [context] (prn :task-summary) (format "Tasks: %d/%d" (fx/sub-ctx context remaining-task-count) (fx/sub-ctx context task-count))) (fx/sub-ctx context-1 task-summary) ; (prints :task-summary) => "Tasks: 1/2" ;; Creating derived context that reuses cache from `context-1` (def context-2 (fx/swap-context context-1 assoc-in [:tasks 0 :text] "Buy bread")) ;; Validating that cache entry is reused. Even though we updated :tasks key, there is no ;; reason to call `task-summary` again, because it's dependencies, even though ;; recalculated, return the same values (fx/sub-ctx context-2 task-summary) ; (does not print anything) => "Tasks: 1/2" This tracking imposes a restriction on subscription
Genesis Park 편집팀이 AI를 활용하여 작성한 분석입니다. 원문은 출처 링크를 통해 확인할 수 있습니다.
공유